commit 203c498531d7359475fca41271345978300cfada
parent 4c8246c778b8c6bee9c61c3b0319b8dd6501c01b
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Wed, 24 Sep 2025 14:50:21 +0100
Large refactor
Diffstat:
| M | main.go |  |  | 474 | +++++++++++++++++++++++++++---------------------------------------------------- | 
1 file changed, 164 insertions(+), 310 deletions(-)
diff --git a/main.go b/main.go
@@ -52,6 +52,129 @@ type PageData struct {
 	Port  string
 }
 
+// sanitizeFilename removes problematic characters from filenames
+func sanitizeFilename(filename string) string {
+	if filename == "" {
+		return "file"
+	}
+
+	filename = strings.ReplaceAll(filename, "/", "_")
+	filename = strings.ReplaceAll(filename, "\\", "_")
+	filename = strings.ReplaceAll(filename, "..", "_")
+
+	if filename == "" {
+		return "file"
+	}
+	return filename
+}
+
+// detectVideoCodec uses ffprobe to detect the video codec
+func detectVideoCodec(filePath string) (string, error) {
+	cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
+		"-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", filePath)
+
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("failed to probe video codec: %v", err)
+	}
+
+	return strings.TrimSpace(string(out)), nil
+}
+
+// reencodeHEVCToH264 converts HEVC videos to H.264 for browser compatibility
+func reencodeHEVCToH264(inputPath, outputPath string) error {
+	cmd := exec.Command("ffmpeg", "-i", inputPath,
+		"-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23",
+		"-c:a", "aac", "-movflags", "+faststart", outputPath)
+
+	cmd.Stderr = os.Stderr
+	cmd.Stdout = os.Stdout
+
+	return cmd.Run()
+}
+
+// processVideoFile handles codec detection and re-encoding if needed
+// Returns final path, warning message (if any), and error
+func processVideoFile(tempPath, finalPath string) (string, string, error) {
+	codec, err := detectVideoCodec(tempPath)
+	if err != nil {
+		return "", "", err
+	}
+
+	// If HEVC, re-encode to H.264
+	if codec == "hevc" || codec == "h265" {
+		warningMsg := "The video uses HEVC and has been re-encoded to H.264 for browser compatibility."
+
+		if err := reencodeHEVCToH264(tempPath, finalPath); err != nil {
+			return "", "", fmt.Errorf("failed to re-encode HEVC video: %v", err)
+		}
+
+		os.Remove(tempPath) // Clean up temp file
+		return finalPath, warningMsg, nil
+	}
+
+	// If not HEVC, just move temp file to final destination
+	if err := os.Rename(tempPath, finalPath); err != nil {
+		return "", "", fmt.Errorf("failed to move file: %v", err)
+	}
+
+	return finalPath, "", nil
+}
+
+// saveFileToDatabase adds file record to database and returns the ID
+func saveFileToDatabase(filename, path string) (int64, error) {
+	res, err := db.Exec("INSERT INTO files (filename, path) VALUES (?, ?)", filename, path)
+	if err != nil {
+		return 0, fmt.Errorf("failed to save file to database: %v", err)
+	}
+
+	id, err := res.LastInsertId()
+	if err != nil {
+		return 0, fmt.Errorf("failed to get inserted ID: %v", err)
+	}
+
+	return id, nil
+}
+
+// processUploadedFile handles the complete file processing workflow
+func processUploadedFile(src io.Reader, originalFilename string) (int64, string, string, error) {
+	// Strict duplicate check — error out if file already exists
+	finalFilename, finalPath, err := checkFileConflictStrict(originalFilename)
+	if err != nil {
+		return 0, "", "", err
+	}
+	// Create temporary file
+	tempPath := finalPath + ".tmp"
+	tempFile, err := os.Create(tempPath)
+	if err != nil {
+		return 0, "", "", fmt.Errorf("failed to create temp file: %v", err)
+	}
+
+	// Copy data to temp file
+	_, err = io.Copy(tempFile, src)
+	tempFile.Close()
+	if err != nil {
+		os.Remove(tempPath)
+		return 0, "", "", fmt.Errorf("failed to copy file data: %v", err)
+	}
+
+	// Process video (codec detection and potential re-encoding)
+	processedPath, warningMsg, err := processVideoFile(tempPath, finalPath)
+	if err != nil {
+		os.Remove(tempPath)
+		return 0, "", "", err
+	}
+
+	// Save to database
+	id, err := saveFileToDatabase(finalFilename, processedPath)
+	if err != nil {
+		os.Remove(processedPath)
+		return 0, "", "", err
+	}
+
+	return id, finalFilename, warningMsg, nil
+}
+
 func main() {
 	// Load configuration first
 	if err := loadConfig(); err != nil {
@@ -198,7 +321,7 @@ func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) {
 	}
 	defer resp.Body.Close()
 
-	// Determine filename & extension
+	// Determine filename
 	var filename string
 	urlExt := filepath.Ext(parsedURL.Path)
 	if customFilename != "" {
@@ -214,90 +337,19 @@ func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	// Sanitize filename
-	filename = strings.ReplaceAll(filename, "/", "_")
-	filename = strings.ReplaceAll(filename, "\\", "_")
-	filename = strings.ReplaceAll(filename, "..", "_")
-	if filename == "" {
-		filename = "file_from_url"
-	}
-
-	dstPath := filepath.Join(config.UploadDir, filename)
-
-	// Avoid overwriting existing files
-	originalFilename := filename
-	for i := 1; ; i++ {
-		if _, err := os.Stat(dstPath); os.IsNotExist(err) {
-			break
-		}
-		ext := filepath.Ext(originalFilename)
-		name := strings.TrimSuffix(originalFilename, ext)
-		filename = fmt.Sprintf("%s_%d%s", name, i, ext)
-		dstPath = filepath.Join(config.UploadDir, filename)
-	}
-
-	// Save the downloaded file temporarily
-	tmpPath := dstPath + ".tmp"
-	tmpFile, err := os.Create(tmpPath)
+	// Process the uploaded file
+	id, _, warningMsg, err := processUploadedFile(resp.Body, filename)
 	if err != nil {
-		http.Error(w, "Failed to save file", http.StatusInternalServerError)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	_, err = io.Copy(tmpFile, resp.Body)
-	tmpFile.Close()
-	if err != nil {
-		os.Remove(tmpPath)
-		http.Error(w, "Failed to save file", http.StatusInternalServerError)
-		return
-	}
-
-	// --- Detect video codec using ffprobe ---
-	ffprobeCmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
-		"-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", tmpPath)
-	out, err := ffprobeCmd.Output()
-	if err != nil {
-		os.Remove(tmpPath)
-		http.Error(w, "Failed to inspect video", http.StatusInternalServerError)
-		return
-	}
-	videoCodec := strings.TrimSpace(string(out))
-
-	// If HEVC, re-encode to H.264 and warn user
-	if videoCodec == "hevc" || videoCodec == "h265" {
-		reencodedPath := dstPath // overwrite the final destination
-		warningMessage := "The uploaded video uses HEVC and will be re-encoded to H.264 for browser compatibility."
-
-		ffmpegCmd := exec.Command("ffmpeg", "-i", tmpPath, "-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23", "-c:a", "aac", "-movflags", "+faststart", reencodedPath)
-		ffmpegCmd.Stderr = os.Stderr
-		ffmpegCmd.Stdout = os.Stdout
-		if err := ffmpegCmd.Run(); err != nil {
-			os.Remove(tmpPath)
-			http.Error(w, "Failed to re-encode HEVC video", http.StatusInternalServerError)
-			return
-		}
-		os.Remove(tmpPath)
 
-		// Optional: flash a warning message to user (could be stored in session or query param)
-		http.Redirect(w, r, fmt.Sprintf("/file/%s?warning=%s", filename, url.QueryEscape(warningMessage)), http.StatusSeeOther)
-		return
+	// Redirect with optional warning
+	redirectURL := fmt.Sprintf("/file/%d", id)
+	if warningMsg != "" {
+		redirectURL += "?warning=" + url.QueryEscape(warningMsg)
 	}
-
-	// If not HEVC, just move the temp file
-	err = os.Rename(tmpPath, dstPath)
-	if err != nil {
-		http.Error(w, "Failed to save file", http.StatusInternalServerError)
-		return
-	}
-
-	// Add to database
-	res, err := db.Exec("INSERT INTO files (filename, path) VALUES (?, ?)", filename, dstPath)
-	if err != nil {
-		http.Error(w, "Failed to record file", http.StatusInternalServerError)
-		return
-	}
-
-	id, _ := res.LastInsertId()
-	http.Redirect(w, r, fmt.Sprintf("/file/%d", id), http.StatusSeeOther)
+	http.Redirect(w, r, redirectURL, http.StatusSeeOther)
 }
 
 // List all files, plus untagged files
@@ -394,77 +446,31 @@ func uploadHandler(w http.ResponseWriter, r *http.Request) {
 	}
 	defer file.Close()
 
-	finalFilename := header.Filename
-	dstPath := filepath.Join(config.UploadDir, finalFilename)
-
-	// Check before creating anything
-	if _, err := os.Stat(dstPath); err == nil {
-		http.Error(w, "A file with that name already exists", http.StatusConflict)
-		return
-	} else if !os.IsNotExist(err) {
-		http.Error(w, "Failed to check for existing file", http.StatusInternalServerError)
-		return
-	}
-
-	// Save uploaded file to a temporary path
-	tmpPath := dstPath + ".tmp"
-	tmpFile, err := os.Create(tmpPath)
-	if err != nil {
-		http.Error(w, "Failed to save uploaded file", http.StatusInternalServerError)
-		return
-	}
-	_, err = io.Copy(tmpFile, file)
-	tmpFile.Close()
+	// Process the uploaded file
+	id, _, warningMsg, err := processUploadedFile(file, header.Filename)
 	if err != nil {
-		os.Remove(tmpPath)
-		http.Error(w, "Failed to save uploaded file", http.StatusInternalServerError)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	// --- Detect video codec using ffprobe ---
-	ffprobeCmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
-		"-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", tmpPath)
-	out, err := ffprobeCmd.Output()
-	if err != nil {
-		os.Remove(tmpPath)
-		http.Error(w, "Failed to inspect video", http.StatusInternalServerError)
-		return
-	}
-	videoCodec := strings.TrimSpace(string(out))
-
-	// If HEVC, re-encode to H.264 and warn user
-	finalFilename := header.Filename
-	if videoCodec == "hevc" || videoCodec == "h265" {
-		warningMessage := "The uploaded video uses HEVC and has been re-encoded to H.264 for browser compatibility."
-
-		ffmpegCmd := exec.Command("ffmpeg", "-i", tmpPath, "-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23", "-c:a", "aac", "-movflags", "+faststart", dstPath)
-		ffmpegCmd.Stderr = os.Stderr
-		ffmpegCmd.Stdout = os.Stdout
-		if err := ffmpegCmd.Run(); err != nil {
-			os.Remove(tmpPath)
-			http.Error(w, "Failed to re-encode HEVC video", http.StatusInternalServerError)
-			return
-		}
-		os.Remove(tmpPath)
-
-		// Insert into database and redirect with warning
-		res, _ := db.Exec("INSERT INTO files (filename, path) VALUES (?, ?)", finalFilename, dstPath)
-		id, _ := res.LastInsertId()
-		http.Redirect(w, r, fmt.Sprintf("/file/%d?warning=%s", id, url.QueryEscape(warningMessage)), http.StatusSeeOther)
-		return
+	// Redirect with optional warning
+	redirectURL := fmt.Sprintf("/file/%d", id)
+	if warningMsg != "" {
+		redirectURL += "?warning=" + url.QueryEscape(warningMsg)
 	}
+	http.Redirect(w, r, redirectURL, http.StatusSeeOther)
+}
 
-	// If not HEVC, just move temp file to final destination
-	err = os.Rename(tmpPath, dstPath)
-	if err != nil {
-		http.Error(w, "Failed to save file", http.StatusInternalServerError)
-		return
+// checkFileConflictStrict checks if a file already exists in the upload directory.
+// It returns the final filename, full path, or an error if a duplicate exists.
+func checkFileConflictStrict(filename string) (string, string, error) {
+	finalPath := filepath.Join(config.UploadDir, filename)
+	if _, err := os.Stat(finalPath); err == nil {
+		return "", "", fmt.Errorf("a file with that name already exists")
+	} else if !os.IsNotExist(err) {
+		return "", "", fmt.Errorf("failed to check for existing file: %v", err)
 	}
-
-	// Insert into database
-	res, _ := db.Exec("INSERT INTO files (filename, path) VALUES (?, ?)", finalFilename, dstPath)
-	id, _ := res.LastInsertId()
-	http.Redirect(w, r, fmt.Sprintf("/file/%d", id), http.StatusSeeOther)
+	return filename, finalPath, nil
 }
 
 // raw local IP for raw address
@@ -582,9 +588,7 @@ func fileRenameHandler(w http.ResponseWriter, r *http.Request, parts []string) {
 	}
 
 	// Sanitize filename
-	newFilename = strings.ReplaceAll(newFilename, "/", "_")
-	newFilename = strings.ReplaceAll(newFilename, "\\", "_")
-	newFilename = strings.ReplaceAll(newFilename, "..", "_")
+	newFilename = sanitizeFilename(newFilename)
 
 	// Get current file info
 	var currentFile File
@@ -1245,8 +1249,8 @@ func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
 		}
 		recentRows.Close()
 
-		// Validate basic inputs
-		if rangeStr == "" {
+		// Helper function to create error response
+		createErrorResponse := func(errorMsg string) {
 			pageData := PageData{
 				Title: "Bulk Tag Editor",
 				Data: struct {
@@ -1263,7 +1267,7 @@ func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
 				}{
 					Categories:  cats,
 					RecentFiles: recentFiles,
-					Error:       "File range cannot be empty",
+					Error:       errorMsg,
 					Success:     "",
 					FormData: struct {
 						FileRange string
@@ -1279,193 +1283,43 @@ func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
 				},
 			}
 			tmpl.ExecuteTemplate(w, "bulk-tag.html", pageData)
+		}
+
+		// Validate basic inputs
+		if rangeStr == "" {
+			createErrorResponse("File range cannot be empty")
 			return
 		}
 
 		if category == "" {
-			pageData := PageData{
-				Title: "Bulk Tag Editor",
-				Data: struct {
-					Categories  []string
-					RecentFiles []File
-					Error       string
-					Success     string
-					FormData    struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}
-				}{
-					Categories:  cats,
-					RecentFiles: recentFiles,
-					Error:       "Category cannot be empty",
-					Success:     "",
-					FormData: struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}{
-						FileRange: rangeStr,
-						Category:  category,
-						Value:     value,
-						Operation: operation,
-					},
-				},
-			}
-			tmpl.ExecuteTemplate(w, "bulk-tag.html", pageData)
+			createErrorResponse("Category cannot be empty")
 			return
 		}
 
 		// For add operations, value is required. For remove operations, value is optional
 		if operation == "add" && value == "" {
-			pageData := PageData{
-				Title: "Bulk Tag Editor",
-				Data: struct {
-					Categories  []string
-					RecentFiles []File
-					Error       string
-					Success     string
-					FormData    struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}
-				}{
-					Categories:  cats,
-					RecentFiles: recentFiles,
-					Error:       "Value cannot be empty when adding tags",
-					Success:     "",
-					FormData: struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}{
-						FileRange: rangeStr,
-						Category:  category,
-						Value:     value,
-						Operation: operation,
-					},
-				},
-			}
-			tmpl.ExecuteTemplate(w, "bulk-tag.html", pageData)
+			createErrorResponse("Value cannot be empty when adding tags")
 			return
 		}
 
 		// Parse file ID range
 		fileIDs, err := parseFileIDRange(rangeStr)
 		if err != nil {
-			pageData := PageData{
-				Title: "Bulk Tag Editor",
-				Data: struct {
-					Categories  []string
-					RecentFiles []File
-					Error       string
-					Success     string
-					FormData    struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}
-				}{
-					Categories:  cats,
-					RecentFiles: recentFiles,
-					Error:       fmt.Sprintf("Invalid file range: %v", err),
-					Success:     "",
-					FormData: struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}{
-						FileRange: rangeStr,
-						Category:  category,
-						Value:     value,
-						Operation: operation,
-					},
-				},
-			}
-			tmpl.ExecuteTemplate(w, "bulk-tag.html", pageData)
+			createErrorResponse(fmt.Sprintf("Invalid file range: %v", err))
 			return
 		}
 
 		// Validate file IDs exist
 		validFiles, err := validateFileIDs(fileIDs)
 		if err != nil {
-			pageData := PageData{
-				Title: "Bulk Tag Editor",
-				Data: struct {
-					Categories  []string
-					RecentFiles []File
-					Error       string
-					Success     string
-					FormData    struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}
-				}{
-					Categories:  cats,
-					RecentFiles: recentFiles,
-					Error:       fmt.Sprintf("File validation error: %v", err),
-					Success:     "",
-					FormData: struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}{
-						FileRange: rangeStr,
-						Category:  category,
-						Value:     value,
-						Operation: operation,
-					},
-				},
-			}
-			tmpl.ExecuteTemplate(w, "bulk-tag.html", pageData)
+			createErrorResponse(fmt.Sprintf("File validation error: %v", err))
 			return
 		}
 
 		// Apply tag operations
 		err = applyBulkTagOperations(fileIDs, category, value, operation)
 		if err != nil {
-			pageData := PageData{
-				Title: "Bulk Tag Editor",
-				Data: struct {
-					Categories  []string
-					RecentFiles []File
-					Error       string
-					Success     string
-					FormData    struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}
-				}{
-					Categories:  cats,
-					RecentFiles: recentFiles,
-					Error:       fmt.Sprintf("Tag operation failed: %v", err),
-					Success:     "",
-					FormData: struct {
-						FileRange string
-						Category  string
-						Value     string
-						Operation string
-					}{
-						FileRange: rangeStr,
-						Category:  category,
-						Value:     value,
-						Operation: operation,
-					},
-				},
-			}
-			tmpl.ExecuteTemplate(w, "bulk-tag.html", pageData)
+			createErrorResponse(fmt.Sprintf("Tag operation failed: %v", err))
 			return
 		}