uploads.go (9839B)
1 package app 2 3 import ( 4 "fmt" 5 "io" 6 "log" 7 "net/http" 8 "net/url" 9 "os" 10 "os/exec" 11 "path/filepath" 12 "strconv" 13 "strings" 14 ) 15 16 func uploadHandler(w http.ResponseWriter, r *http.Request) { 17 if r.Method == http.MethodGet { 18 pageData := buildPageData("Add File", nil) 19 renderTemplate(w, "add.html", pageData) 20 return 21 } 22 23 // Parse the multipart form (with max memory limit, e.g., 32MB) 24 err := r.ParseMultipartForm(32 << 20) 25 if err != nil { 26 renderError(w, "Failed to parse form", http.StatusBadRequest) 27 return 28 } 29 30 files := r.MultipartForm.File["file"] 31 if len(files) == 0 { 32 renderError(w, "No files uploaded", http.StatusBadRequest) 33 return 34 } 35 36 var warnings []string 37 var lastID int64 38 39 // Process each file 40 for _, fileHeader := range files { 41 file, err := fileHeader.Open() 42 if err != nil { 43 renderError(w, "Failed to open uploaded file", http.StatusInternalServerError) 44 return 45 } 46 defer file.Close() 47 48 id, warningMsg, err := processUpload(file, fileHeader.Filename) 49 if err != nil { 50 renderError(w, err.Error(), http.StatusInternalServerError) 51 return 52 } 53 54 lastID = id 55 56 if warningMsg != "" { 57 warnings = append(warnings, warningMsg) 58 } 59 } 60 61 var warningMsg string 62 if len(warnings) > 0 { 63 warningMsg = strings.Join(warnings, "; ") 64 } 65 66 redirectTarget := "/untagged" 67 if len(files) == 1 && lastID != 0 { 68 redirectTarget = "/file/" + strconv.FormatInt(lastID, 10) 69 } 70 71 redirectWithWarning(w, r, redirectTarget, warningMsg) 72 } 73 74 func processUpload(src io.Reader, filename string) (int64, string, error) { 75 finalFilename, finalPath, conflictID, err := checkFileConflictStrict(filename) 76 if err != nil { 77 return 0, "", err 78 } 79 if conflictID != 0 { 80 return conflictID, "", nil 81 } 82 83 tempPath := finalPath + ".tmp" 84 tempFile, err := os.Create(tempPath) 85 if err != nil { 86 return 0, "", fmt.Errorf("failed to create temp file: %v", err) 87 } 88 89 _, err = io.Copy(tempFile, src) 90 tempFile.Close() 91 if err != nil { 92 os.Remove(tempPath) 93 return 0, "", fmt.Errorf("failed to copy file data: %v", err) 94 } 95 96 ext := strings.ToLower(filepath.Ext(filename)) 97 videoExts := map[string]bool{ 98 ".mp4": true, ".mov": true, ".avi": true, 99 ".mkv": true, ".webm": true, ".m4v": true, 100 } 101 102 var processedPath string 103 var warningMsg string 104 105 if videoExts[ext] || ext == ".cbz" { 106 // Process videos and CBZ files 107 processedPath, warningMsg, err = processVideoFile(tempPath, finalPath) 108 if err != nil { 109 os.Remove(tempPath) 110 return 0, "", err 111 } 112 } else { 113 // Non-video, non-CBZ → just rename temp file to final 114 if err := os.Rename(tempPath, finalPath); err != nil { 115 return 0, "", fmt.Errorf("failed to move file: %v", err) 116 } 117 processedPath = finalPath 118 } 119 120 id, err := saveFileToDatabase(finalFilename, processedPath) 121 if err != nil { 122 os.Remove(processedPath) 123 return 0, "", err 124 } 125 126 return id, warningMsg, nil 127 } 128 129 func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) { 130 if r.Method != http.MethodPost { 131 http.Redirect(w, r, "/upload", http.StatusSeeOther) 132 return 133 } 134 135 fileURL := r.FormValue("fileurl") 136 if fileURL == "" { 137 renderError(w, "No URL provided", http.StatusBadRequest) 138 return 139 } 140 141 customFilename := strings.TrimSpace(r.FormValue("filename")) 142 143 parsedURL, err := url.ParseRequestURI(fileURL) 144 if err != nil || !(parsedURL.Scheme == "http" || parsedURL.Scheme == "https") { 145 renderError(w, "Invalid URL", http.StatusBadRequest) 146 return 147 } 148 149 resp, err := http.Get(fileURL) 150 if err != nil || resp.StatusCode != http.StatusOK { 151 renderError(w, "Failed to download file", http.StatusBadRequest) 152 return 153 } 154 defer resp.Body.Close() 155 156 var filename string 157 urlExt := filepath.Ext(parsedURL.Path) 158 if customFilename != "" { 159 filename = customFilename 160 if filepath.Ext(filename) == "" && urlExt != "" { 161 filename += urlExt 162 } 163 } else { 164 filename = filenameFromURLPath(parsedURL.Path) 165 } 166 167 id, warningMsg, err := processUpload(resp.Body, filename) 168 if err != nil { 169 renderError(w, err.Error(), http.StatusInternalServerError) 170 return 171 } 172 173 redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) 174 } 175 176 // Shortest accepted base name, otherwise use previous part of URL 177 const minMeaningfulStemLen = 3 178 func filenameFromURLPath(urlPath string) string { 179 var parts []string 180 for _, p := range strings.Split(urlPath, "/") { 181 if p != "" { 182 parts = append(parts, p) 183 } 184 } 185 if len(parts) == 0 { 186 return "file_from_url" 187 } 188 189 last := parts[len(parts)-1] 190 ext := filepath.Ext(last) 191 stem := strings.TrimSuffix(last, ext) 192 193 if len(stem) < minMeaningfulStemLen && len(parts) >= 2 { 194 prev := parts[len(parts)-2] 195 // Don't duplicate file extensions 196 prev = strings.TrimSuffix(prev, filepath.Ext(prev)) 197 if prev != "" { 198 return prev + ext 199 } 200 } 201 202 return last 203 } 204 205 func ytdlpHandler(w http.ResponseWriter, r *http.Request) { 206 if r.Method != http.MethodPost { 207 http.Redirect(w, r, "/upload", http.StatusSeeOther) 208 return 209 } 210 211 videoURL := r.FormValue("url") 212 if videoURL == "" { 213 renderError(w, "No URL provided", http.StatusBadRequest) 214 return 215 } 216 217 outTemplate := filepath.Join(Cfg.UploadDir, "%(title)s.%(ext)s") 218 filenameCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, "--get-filename", videoURL) 219 filenameBytes, err := filenameCmd.Output() 220 if err != nil { 221 renderError(w, fmt.Sprintf("Failed to get filename: %v", err), http.StatusInternalServerError) 222 return 223 } 224 expectedFullPath := strings.TrimSpace(string(filenameBytes)) 225 expectedFilename := filepath.Base(expectedFullPath) 226 227 finalFilename, finalPath, conflictID, err := checkFileConflictStrict(expectedFilename) 228 if err != nil { 229 renderError(w, err.Error(), http.StatusConflict) 230 return 231 } 232 if conflictID != 0 { 233 redirectWithWarning(w, r, fmt.Sprintf("/file/%d", conflictID), "") 234 return 235 } 236 237 downloadCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, videoURL) 238 downloadCmd.Stdout = os.Stdout 239 downloadCmd.Stderr = os.Stderr 240 if err := downloadCmd.Run(); err != nil { 241 renderError(w, fmt.Sprintf("Failed to download video: %v", err), http.StatusInternalServerError) 242 return 243 } 244 245 if expectedFullPath != finalPath { 246 if err := os.Rename(expectedFullPath, finalPath); err != nil { 247 renderError(w, fmt.Sprintf("Failed to move downloaded file: %v", err), http.StatusInternalServerError) 248 return 249 } 250 } 251 252 tempPath := finalPath + ".tmp" 253 if err := os.Rename(finalPath, tempPath); err != nil { 254 renderError(w, fmt.Sprintf("Failed to create temp file for processing: %v", err), http.StatusInternalServerError) 255 return 256 } 257 258 processedPath, warningMsg, err := processVideoFile(tempPath, finalPath) 259 if err != nil { 260 os.Remove(tempPath) 261 renderError(w, fmt.Sprintf("Failed to process video: %v", err), http.StatusInternalServerError) 262 return 263 } 264 265 id, err := saveFileToDatabase(finalFilename, processedPath) 266 if err != nil { 267 os.Remove(processedPath) 268 renderError(w, err.Error(), http.StatusInternalServerError) 269 return 270 } 271 272 redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) 273 } 274 275 func processVideoFile(tempPath, finalPath string) (string, string, error) { 276 ext := strings.ToLower(filepath.Ext(finalPath)) 277 278 // Handle CBZ files 279 if ext == ".cbz" { 280 if err := os.Rename(tempPath, finalPath); err != nil { 281 return "", "", fmt.Errorf("failed to move file: %v", err) 282 } 283 if err := generateCBZThumbnail(finalPath, Cfg.UploadDir, filepath.Base(finalPath)); err != nil { 284 log.Printf("Warning: could not generate CBZ thumbnail: %v", err) 285 } 286 return finalPath, "", nil 287 } 288 289 // Handle video files 290 codec, err := detectVideoCodec(tempPath) 291 if err != nil { 292 return "", "", err 293 } 294 if codec == "hevc" || codec == "h265" { 295 warningMsg := "The video uses HEVC and has been re-encoded to H.264 for browser compatibility." 296 if err := reencodeHEVCToH264(tempPath, finalPath); err != nil { 297 return "", "", fmt.Errorf("failed to re-encode HEVC video: %v", err) 298 } 299 os.Remove(tempPath) 300 301 if err := generateThumbnail(finalPath, Cfg.UploadDir, filepath.Base(finalPath)); err != nil { 302 log.Printf("Warning: could not generate thumbnail after HEVC re-encode: %v", err) 303 } 304 305 return finalPath, warningMsg, nil 306 } 307 if err := os.Rename(tempPath, finalPath); err != nil { 308 return "", "", fmt.Errorf("failed to move file: %v", err) 309 } 310 311 // Generate thumbnail for video files 312 if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".mkv" || ext == ".webm" || ext == ".m4v" { 313 if err := generateThumbnail(finalPath, Cfg.UploadDir, filepath.Base(finalPath)); err != nil { 314 log.Printf("Warning: could not generate thumbnail: %v", err) 315 } 316 } 317 318 return finalPath, "", nil 319 } 320 321 func saveFileToDatabase(filename, path string) (int64, error) { 322 relPath, err := filepath.Rel(Cfg.UploadDir, path) 323 if err != nil { 324 return 0, fmt.Errorf("failed to compute relative path: %v", err) 325 } 326 327 res, err := DB.Exec("INSERT INTO files (filename, path, description) VALUES (?, ?, '')", filename, relPath) 328 if err != nil { 329 return 0, fmt.Errorf("failed to save file to database: %v", err) 330 } 331 id, err := res.LastInsertId() 332 if err != nil { 333 return 0, fmt.Errorf("failed to get inserted ID: %v", err) 334 } 335 computeProperties(id, path) 336 return id, nil 337 } 338 339 func detectVideoCodec(filePath string) (string, error) { 340 cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", 341 "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", filePath) 342 out, err := cmd.Output() 343 if err != nil { 344 return "", fmt.Errorf("failed to probe video codec: %v", err) 345 } 346 return strings.TrimSpace(string(out)), nil 347 } 348 349 func reencodeHEVCToH264(inputPath, outputPath string) error { 350 cmd := exec.Command("ffmpeg", "-i", inputPath, 351 "-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23", 352 "-c:a", "aac", "-movflags", "+faststart", outputPath) 353 cmd.Stderr = os.Stderr 354 cmd.Stdout = os.Stdout 355 return cmd.Run() 356 }