main.go (43697B)
1 package main 2 3 import ( 4 "database/sql" 5 "encoding/json" 6 "fmt" 7 "html/template" 8 "io" 9 "io/ioutil" 10 "log" 11 "net" 12 "net/http" 13 "net/url" 14 "os" 15 "os/exec" 16 "path/filepath" 17 "strconv" 18 "strings" 19 20 _ "github.com/mattn/go-sqlite3" 21 ) 22 23 var ( 24 db *sql.DB 25 tmpl *template.Template 26 config Config 27 ) 28 29 type File struct { 30 ID int 31 Filename string 32 EscapedFilename string 33 Path string 34 Description string 35 Tags map[string][]string 36 } 37 38 type Config struct { 39 DatabasePath string `json:"database_path"` 40 UploadDir string `json:"upload_dir"` 41 ServerPort string `json:"server_port"` 42 InstanceName string `json:"instance_name"` 43 GallerySize string `json:"gallery_size"` 44 ItemsPerPage string `json:"items_per_page"` 45 } 46 47 type TagDisplay struct { 48 Value string 49 Count int 50 } 51 52 type PageData struct { 53 Title string 54 Data interface{} 55 Query string 56 IP string 57 Port string 58 Files []File 59 Tags map[string][]TagDisplay 60 Pagination *Pagination 61 } 62 63 type Pagination struct { 64 CurrentPage int 65 TotalPages int 66 HasPrev bool 67 HasNext bool 68 PrevPage int 69 NextPage int 70 PerPage int 71 } 72 73 func getOrCreateCategoryAndTag(category, value string) (int, int, error) { 74 var catID int 75 err := db.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID) 76 if err == sql.ErrNoRows { 77 res, err := db.Exec("INSERT INTO categories(name) VALUES(?)", category) 78 if err != nil { 79 return 0, 0, err 80 } 81 cid, _ := res.LastInsertId() 82 catID = int(cid) 83 } else if err != nil { 84 return 0, 0, err 85 } 86 87 var tagID int 88 if value != "" { 89 err = db.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID) 90 if err == sql.ErrNoRows { 91 res, err := db.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value) 92 if err != nil { 93 return 0, 0, err 94 } 95 tid, _ := res.LastInsertId() 96 tagID = int(tid) 97 } else if err != nil { 98 return 0, 0, err 99 } 100 } 101 102 return catID, tagID, nil 103 } 104 105 func queryFilesWithTags(query string, args ...interface{}) ([]File, error) { 106 rows, err := db.Query(query, args...) 107 if err != nil { 108 return nil, err 109 } 110 defer rows.Close() 111 112 var files []File 113 for rows.Next() { 114 var f File 115 if err := rows.Scan(&f.ID, &f.Filename, &f.Path, &f.Description); err != nil { 116 return nil, err 117 } 118 f.EscapedFilename = url.PathEscape(f.Filename) 119 files = append(files, f) 120 } 121 return files, nil 122 } 123 124 func getTaggedFiles() ([]File, error) { 125 return queryFilesWithTags(` 126 SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description 127 FROM files f 128 JOIN file_tags ft ON ft.file_id = f.id 129 ORDER BY f.id DESC 130 `) 131 } 132 133 func getTaggedFilesPaginated(page, perPage int) ([]File, int, error) { 134 // Get total count 135 var total int 136 err := db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f JOIN file_tags ft ON ft.file_id = f.id`).Scan(&total) 137 if err != nil { 138 return nil, 0, err 139 } 140 141 offset := (page - 1) * perPage 142 files, err := queryFilesWithTags(` 143 SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description 144 FROM files f 145 JOIN file_tags ft ON ft.file_id = f.id 146 ORDER BY f.id DESC 147 LIMIT ? OFFSET ? 148 `, perPage, offset) 149 150 return files, total, err 151 } 152 153 func getUntaggedFiles() ([]File, error) { 154 return queryFilesWithTags(` 155 SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description 156 FROM files f 157 LEFT JOIN file_tags ft ON ft.file_id = f.id 158 WHERE ft.file_id IS NULL 159 ORDER BY f.id DESC 160 `) 161 } 162 163 func getUntaggedFilesPaginated(page, perPage int) ([]File, int, error) { 164 // Get total count 165 var total int 166 err := db.QueryRow(`SELECT COUNT(*) FROM files f LEFT JOIN file_tags ft ON ft.file_id = f.id WHERE ft.file_id IS NULL`).Scan(&total) 167 if err != nil { 168 return nil, 0, err 169 } 170 171 offset := (page - 1) * perPage 172 files, err := queryFilesWithTags(` 173 SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description 174 FROM files f 175 LEFT JOIN file_tags ft ON ft.file_id = f.id 176 WHERE ft.file_id IS NULL 177 ORDER BY f.id DESC 178 LIMIT ? OFFSET ? 179 `, perPage, offset) 180 181 return files, total, err 182 } 183 184 func buildPageData(title string, data interface{}) PageData { 185 tagMap, _ := getTagData() 186 return PageData{Title: title, Data: data, Tags: tagMap} 187 } 188 189 func buildPageDataWithPagination(title string, data interface{}, page, total, perPage int) PageData { 190 pd := buildPageData(title, data) 191 pd.Pagination = calculatePagination(page, total, perPage) 192 return pd 193 } 194 195 func calculatePagination(page, total, perPage int) *Pagination { 196 totalPages := (total + perPage - 1) / perPage 197 if totalPages < 1 { 198 totalPages = 1 199 } 200 201 return &Pagination{ 202 CurrentPage: page, 203 TotalPages: totalPages, 204 HasPrev: page > 1, 205 HasNext: page < totalPages, 206 PrevPage: page - 1, 207 NextPage: page + 1, 208 PerPage: perPage, 209 } 210 } 211 212 func buildPageDataWithIP(title string, data interface{}) PageData { 213 pageData := buildPageData(title, data) 214 ip, _ := getLocalIP() 215 pageData.IP = ip 216 pageData.Port = strings.TrimPrefix(config.ServerPort, ":") 217 return pageData 218 } 219 220 func renderError(w http.ResponseWriter, message string, statusCode int) { 221 http.Error(w, message, statusCode) 222 } 223 224 func renderTemplate(w http.ResponseWriter, tmplName string, data PageData) { 225 if err := tmpl.ExecuteTemplate(w, tmplName, data); err != nil { 226 renderError(w, "Template rendering failed", http.StatusInternalServerError) 227 } 228 } 229 230 func getTagData() (map[string][]TagDisplay, error) { 231 rows, err := db.Query(` 232 SELECT c.name, t.value, COUNT(ft.file_id) 233 FROM tags t 234 JOIN categories c ON c.id = t.category_id 235 LEFT JOIN file_tags ft ON ft.tag_id = t.id 236 GROUP BY t.id 237 HAVING COUNT(ft.file_id) > 0 238 ORDER BY c.name, t.value`) 239 if err != nil { 240 return nil, err 241 } 242 defer rows.Close() 243 244 tagMap := make(map[string][]TagDisplay) 245 for rows.Next() { 246 var cat, val string 247 var count int 248 rows.Scan(&cat, &val, &count) 249 tagMap[cat] = append(tagMap[cat], TagDisplay{Value: val, Count: count}) 250 } 251 return tagMap, nil 252 } 253 254 func main() { 255 if err := loadConfig(); err != nil { 256 log.Fatalf("Failed to load config: %v", err) 257 } 258 259 var err error 260 db, err = sql.Open("sqlite3", config.DatabasePath) 261 if err != nil { 262 log.Fatal(err) 263 } 264 defer db.Close() 265 266 _, err = db.Exec(` 267 CREATE TABLE IF NOT EXISTS files ( 268 id INTEGER PRIMARY KEY AUTOINCREMENT, 269 filename TEXT, 270 path TEXT, 271 description TEXT DEFAULT '' 272 ); 273 CREATE TABLE IF NOT EXISTS categories ( 274 id INTEGER PRIMARY KEY AUTOINCREMENT, 275 name TEXT UNIQUE 276 ); 277 CREATE TABLE IF NOT EXISTS tags ( 278 id INTEGER PRIMARY KEY AUTOINCREMENT, 279 category_id INTEGER, 280 value TEXT, 281 UNIQUE(category_id, value) 282 ); 283 CREATE TABLE IF NOT EXISTS file_tags ( 284 file_id INTEGER, 285 tag_id INTEGER, 286 UNIQUE(file_id, tag_id) 287 ); 288 `) 289 if err != nil { 290 log.Fatal(err) 291 } 292 293 os.MkdirAll(config.UploadDir, 0755) 294 os.MkdirAll("static", 0755) 295 296 tmpl = template.Must(template.New("").Funcs(template.FuncMap{ 297 "hasAnySuffix": func(s string, suffixes ...string) bool { 298 for _, suf := range suffixes { 299 if strings.HasSuffix(strings.ToLower(s), suf) { 300 return true 301 } 302 } 303 return false 304 }, 305 }).ParseGlob("templates/*.html")) 306 307 http.HandleFunc("/", listFilesHandler) 308 http.HandleFunc("/add", uploadHandler) 309 http.HandleFunc("/add-yt", ytdlpHandler) 310 http.HandleFunc("/upload-url", uploadFromURLHandler) 311 http.HandleFunc("/file/", fileRouter) 312 http.HandleFunc("/tags", tagsHandler) 313 http.HandleFunc("/tag/", tagFilterHandler) 314 http.HandleFunc("/untagged", untaggedFilesHandler) 315 http.HandleFunc("/search", searchHandler) 316 http.HandleFunc("/bulk-tag", bulkTagHandler) 317 http.HandleFunc("/settings", settingsHandler) 318 http.HandleFunc("/orphans", orphansHandler) 319 320 http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(config.UploadDir)))) 321 http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) 322 323 log.Printf("Server started at http://localhost%s", config.ServerPort) 324 log.Printf("Database: %s", config.DatabasePath) 325 log.Printf("Upload directory: %s", config.UploadDir) 326 http.ListenAndServe(config.ServerPort, nil) 327 } 328 329 func searchHandler(w http.ResponseWriter, r *http.Request) { 330 query := strings.TrimSpace(r.URL.Query().Get("q")) 331 332 var files []File 333 var searchTitle string 334 335 if query != "" { 336 sqlPattern := "%" + strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(query), "*", "%"), "?", "_") + "%" 337 338 rows, err := db.Query(` 339 SELECT f.id, f.filename, f.path, COALESCE(f.description, '') AS description, 340 c.name AS category, t.value AS tag 341 FROM files f 342 LEFT JOIN file_tags ft ON ft.file_id = f.id 343 LEFT JOIN tags t ON t.id = ft.tag_id 344 LEFT JOIN categories c ON c.id = t.category_id 345 WHERE LOWER(f.filename) LIKE ? OR LOWER(f.description) LIKE ? OR LOWER(t.value) LIKE ? 346 ORDER BY f.filename 347 `, sqlPattern, sqlPattern, sqlPattern) 348 if err != nil { 349 renderError(w, "Search failed: "+err.Error(), http.StatusInternalServerError) 350 return 351 } 352 defer rows.Close() 353 354 fileMap := make(map[int]*File) 355 for rows.Next() { 356 var id int 357 var filename, path, description, category, tag sql.NullString 358 359 if err := rows.Scan(&id, &filename, &path, &description, &category, &tag); err != nil { 360 renderError(w, "Failed to read search results: "+err.Error(), http.StatusInternalServerError) 361 return 362 } 363 364 f, exists := fileMap[id] 365 if !exists { 366 f = &File{ 367 ID: id, 368 Filename: filename.String, 369 Path: path.String, 370 EscapedFilename: url.PathEscape(filename.String), 371 Description: description.String, 372 Tags: make(map[string][]string), 373 } 374 fileMap[id] = f 375 } 376 377 if category.Valid && tag.Valid && tag.String != "" { 378 f.Tags[category.String] = append(f.Tags[category.String], tag.String) 379 } 380 } 381 382 for _, f := range fileMap { 383 files = append(files, *f) 384 } 385 386 searchTitle = fmt.Sprintf("Search Results for: %s", query) 387 } else { 388 searchTitle = "Search Files" 389 } 390 391 pageData := buildPageData(searchTitle, files) 392 pageData.Query = query 393 pageData.Files = files 394 renderTemplate(w, "search.html", pageData) 395 } 396 397 func processUpload(src io.Reader, filename string) (int64, string, error) { 398 finalFilename, finalPath, err := checkFileConflictStrict(filename) 399 if err != nil { 400 return 0, "", err 401 } 402 403 tempPath := finalPath + ".tmp" 404 tempFile, err := os.Create(tempPath) 405 if err != nil { 406 return 0, "", fmt.Errorf("failed to create temp file: %v", err) 407 } 408 409 _, err = io.Copy(tempFile, src) 410 tempFile.Close() 411 if err != nil { 412 os.Remove(tempPath) 413 return 0, "", fmt.Errorf("failed to copy file data: %v", err) 414 } 415 416 ext := strings.ToLower(filepath.Ext(filename)) 417 videoExts := map[string]bool{ 418 ".mp4": true, ".mov": true, ".avi": true, 419 ".mkv": true, ".webm": true, ".m4v": true, 420 } 421 422 var processedPath string 423 var warningMsg string 424 425 if videoExts[ext] { 426 processedPath, warningMsg, err = processVideoFile(tempPath, finalPath) 427 if err != nil { 428 os.Remove(tempPath) 429 return 0, "", err 430 } 431 } else { 432 // Non-video → just rename temp file to final 433 if err := os.Rename(tempPath, finalPath); err != nil { 434 return 0, "", fmt.Errorf("failed to move file: %v", err) 435 } 436 processedPath = finalPath 437 } 438 439 id, err := saveFileToDatabase(finalFilename, processedPath) 440 if err != nil { 441 os.Remove(processedPath) 442 return 0, "", err 443 } 444 445 return id, warningMsg, nil 446 } 447 448 449 func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) { 450 if r.Method != http.MethodPost { 451 http.Redirect(w, r, "/upload", http.StatusSeeOther) 452 return 453 } 454 455 fileURL := r.FormValue("fileurl") 456 if fileURL == "" { 457 renderError(w, "No URL provided", http.StatusBadRequest) 458 return 459 } 460 461 customFilename := strings.TrimSpace(r.FormValue("filename")) 462 463 parsedURL, err := url.ParseRequestURI(fileURL) 464 if err != nil || !(parsedURL.Scheme == "http" || parsedURL.Scheme == "https") { 465 renderError(w, "Invalid URL", http.StatusBadRequest) 466 return 467 } 468 469 resp, err := http.Get(fileURL) 470 if err != nil || resp.StatusCode != http.StatusOK { 471 renderError(w, "Failed to download file", http.StatusBadRequest) 472 return 473 } 474 defer resp.Body.Close() 475 476 var filename string 477 urlExt := filepath.Ext(parsedURL.Path) 478 if customFilename != "" { 479 filename = customFilename 480 if filepath.Ext(filename) == "" && urlExt != "" { 481 filename += urlExt 482 } 483 } else { 484 parts := strings.Split(parsedURL.Path, "/") 485 filename = parts[len(parts)-1] 486 if filename == "" { 487 filename = "file_from_url" 488 } 489 } 490 491 id, warningMsg, err := processUpload(resp.Body, filename) 492 if err != nil { 493 renderError(w, err.Error(), http.StatusInternalServerError) 494 return 495 } 496 497 redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) 498 } 499 500 func listFilesHandler(w http.ResponseWriter, r *http.Request) { 501 // Get page number from query params 502 pageStr := r.URL.Query().Get("page") 503 page := 1 504 if pageStr != "" { 505 if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { 506 page = p 507 } 508 } 509 510 // Get per page from config 511 perPage := 50 512 if config.ItemsPerPage != "" { 513 if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 { 514 perPage = pp 515 } 516 } 517 518 tagged, taggedTotal, _ := getTaggedFilesPaginated(page, perPage) 519 untagged, untaggedTotal, _ := getUntaggedFilesPaginated(page, perPage) 520 521 // Use the larger total for pagination 522 total := taggedTotal 523 if untaggedTotal > total { 524 total = untaggedTotal 525 } 526 527 pageData := buildPageDataWithPagination("Home", struct { 528 Tagged []File 529 Untagged []File 530 }{tagged, untagged}, page, total, perPage) 531 532 renderTemplate(w, "list.html", pageData) 533 } 534 535 func untaggedFilesHandler(w http.ResponseWriter, r *http.Request) { 536 // Get page number from query params 537 pageStr := r.URL.Query().Get("page") 538 page := 1 539 if pageStr != "" { 540 if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { 541 page = p 542 } 543 } 544 545 // Get per page from config 546 perPage := 50 547 if config.ItemsPerPage != "" { 548 if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 { 549 perPage = pp 550 } 551 } 552 553 files, total, _ := getUntaggedFilesPaginated(page, perPage) 554 pageData := buildPageDataWithPagination("Untagged Files", files, page, total, perPage) 555 renderTemplate(w, "untagged.html", pageData) 556 } 557 558 func uploadHandler(w http.ResponseWriter, r *http.Request) { 559 if r.Method == http.MethodGet { 560 pageData := buildPageData("Add File", nil) 561 renderTemplate(w, "add.html", pageData) 562 return 563 } 564 565 // Parse the multipart form (with max memory limit, e.g., 32MB) 566 err := r.ParseMultipartForm(32 << 20) 567 if err != nil { 568 renderError(w, "Failed to parse form", http.StatusBadRequest) 569 return 570 } 571 572 files := r.MultipartForm.File["file"] 573 if len(files) == 0 { 574 renderError(w, "No files uploaded", http.StatusBadRequest) 575 return 576 } 577 578 var warnings []string 579 580 // Process each file 581 for _, fileHeader := range files { 582 file, err := fileHeader.Open() 583 if err != nil { 584 renderError(w, "Failed to open uploaded file", http.StatusInternalServerError) 585 return 586 } 587 defer file.Close() 588 589 _, warningMsg, err := processUpload(file, fileHeader.Filename) 590 if err != nil { 591 renderError(w, err.Error(), http.StatusInternalServerError) 592 return 593 } 594 595 if warningMsg != "" { 596 warnings = append(warnings, warningMsg) 597 } 598 } 599 600 var warningMsg string 601 if len(warnings) > 0 { 602 warningMsg = strings.Join(warnings, "; ") 603 } 604 605 redirectWithWarning(w, r, "/untagged", warningMsg) 606 } 607 608 func redirectWithWarning(w http.ResponseWriter, r *http.Request, baseURL, warningMsg string) { 609 redirectURL := baseURL 610 if warningMsg != "" { 611 redirectURL += "?warning=" + url.QueryEscape(warningMsg) 612 } 613 http.Redirect(w, r, redirectURL, http.StatusSeeOther) 614 } 615 616 func checkFileConflictStrict(filename string) (string, string, error) { 617 finalPath := filepath.Join(config.UploadDir, filename) 618 if _, err := os.Stat(finalPath); err == nil { 619 return "", "", fmt.Errorf("a file with that name already exists") 620 } else if !os.IsNotExist(err) { 621 return "", "", fmt.Errorf("failed to check for existing file: %v", err) 622 } 623 return filename, finalPath, nil 624 } 625 626 func getLocalIP() (string, error) { 627 addrs, err := net.InterfaceAddrs() 628 if err != nil { 629 return "", err 630 } 631 for _, addr := range addrs { 632 if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 633 if ipnet.IP.To4() != nil { 634 return ipnet.IP.String(), nil 635 } 636 } 637 } 638 return "", fmt.Errorf("no connected network interface found") 639 } 640 641 func fileRouter(w http.ResponseWriter, r *http.Request) { 642 parts := strings.Split(r.URL.Path, "/") 643 644 if len(parts) >= 4 && parts[3] == "delete" { 645 fileDeleteHandler(w, r, parts) 646 return 647 } 648 649 if len(parts) >= 4 && parts[3] == "rename" { 650 fileRenameHandler(w, r, parts) 651 return 652 } 653 654 if len(parts) >= 7 && parts[3] == "tag" { 655 tagActionHandler(w, r, parts) 656 return 657 } 658 659 fileHandler(w, r) 660 } 661 662 func fileDeleteHandler(w http.ResponseWriter, r *http.Request, parts []string) { 663 if r.Method != http.MethodPost { 664 http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther) 665 return 666 } 667 668 fileID := parts[2] 669 670 var currentFile File 671 err := db.QueryRow("SELECT id, filename, path FROM files WHERE id=?", fileID).Scan(¤tFile.ID, ¤tFile.Filename, ¤tFile.Path) 672 if err != nil { 673 renderError(w, "File not found", http.StatusNotFound) 674 return 675 } 676 677 tx, err := db.Begin() 678 if err != nil { 679 renderError(w, "Failed to start transaction", http.StatusInternalServerError) 680 return 681 } 682 defer tx.Rollback() 683 684 if _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=?", fileID); err != nil { 685 renderError(w, "Failed to delete file tags", http.StatusInternalServerError) 686 return 687 } 688 689 if _, err = tx.Exec("DELETE FROM files WHERE id=?", fileID); err != nil { 690 renderError(w, "Failed to delete file record", http.StatusInternalServerError) 691 return 692 } 693 694 if err = tx.Commit(); err != nil { 695 renderError(w, "Failed to commit transaction", http.StatusInternalServerError) 696 return 697 } 698 699 if err = os.Remove(currentFile.Path); err != nil { 700 log.Printf("Warning: Failed to delete physical file %s: %v", currentFile.Path, err) 701 } 702 703 // Delete thumbnail if it exists 704 thumbPath := filepath.Join(config.UploadDir, "thumbnails", currentFile.Filename+".jpg") 705 if _, err := os.Stat(thumbPath); err == nil { 706 if err := os.Remove(thumbPath); err != nil { 707 log.Printf("Warning: Failed to delete thumbnail %s: %v", thumbPath, err) 708 } 709 } 710 711 http.Redirect(w, r, "/?deleted="+currentFile.Filename, http.StatusSeeOther) 712 } 713 714 func fileRenameHandler(w http.ResponseWriter, r *http.Request, parts []string) { 715 if r.Method != http.MethodPost { 716 http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther) 717 return 718 } 719 720 fileID := parts[2] 721 newFilename := sanitizeFilename(strings.TrimSpace(r.FormValue("newfilename"))) 722 723 if newFilename == "" { 724 renderError(w, "New filename cannot be empty", http.StatusBadRequest) 725 return 726 } 727 728 var currentFilename, currentPath string 729 err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(¤tFilename, ¤tPath) 730 if err != nil { 731 renderError(w, "File not found", http.StatusNotFound) 732 return 733 } 734 735 if currentFilename == newFilename { 736 http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) 737 return 738 } 739 740 newPath := filepath.Join(config.UploadDir, newFilename) 741 if _, err := os.Stat(newPath); !os.IsNotExist(err) { 742 renderError(w, "A file with that name already exists", http.StatusConflict) 743 return 744 } 745 746 if err := os.Rename(currentPath, newPath); err != nil { 747 renderError(w, "Failed to rename physical file: "+err.Error(), http.StatusInternalServerError) 748 return 749 } 750 751 thumbOld := filepath.Join(config.UploadDir, "thumbnails", currentFilename+".jpg") 752 thumbNew := filepath.Join(config.UploadDir, "thumbnails", newFilename+".jpg") 753 754 if _, err := os.Stat(thumbOld); err == nil { 755 if err := os.Rename(thumbOld, thumbNew); err != nil { 756 os.Rename(newPath, currentPath) 757 renderError(w, "Failed to rename thumbnail: "+err.Error(), http.StatusInternalServerError) 758 return 759 } 760 } 761 762 _, err = db.Exec("UPDATE files SET filename=?, path=? WHERE id=?", newFilename, newPath, fileID) 763 if err != nil { 764 os.Rename(newPath, currentPath) 765 if _, err := os.Stat(thumbNew); err == nil { 766 os.Rename(thumbNew, thumbOld) 767 } 768 renderError(w, "Failed to update database", http.StatusInternalServerError) 769 return 770 } 771 772 http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) 773 } 774 775 func fileHandler(w http.ResponseWriter, r *http.Request) { 776 idStr := strings.TrimPrefix(r.URL.Path, "/file/") 777 if strings.Contains(idStr, "/") { 778 idStr = strings.SplitN(idStr, "/", 2)[0] 779 } 780 781 var f File 782 err := db.QueryRow("SELECT id, filename, path, COALESCE(description, '') as description FROM files WHERE id=?", idStr).Scan(&f.ID, &f.Filename, &f.Path, &f.Description) 783 if err != nil { 784 renderError(w, "File not found", http.StatusNotFound) 785 return 786 } 787 788 f.Tags = make(map[string][]string) 789 rows, _ := db.Query(` 790 SELECT c.name, t.value 791 FROM tags t 792 JOIN categories c ON c.id = t.category_id 793 JOIN file_tags ft ON ft.tag_id = t.id 794 WHERE ft.file_id=?`, f.ID) 795 for rows.Next() { 796 var cat, val string 797 rows.Scan(&cat, &val) 798 f.Tags[cat] = append(f.Tags[cat], val) 799 } 800 rows.Close() 801 802 if r.Method == http.MethodPost { 803 if r.FormValue("action") == "update_description" { 804 description := r.FormValue("description") 805 if len(description) > 2048 { 806 description = description[:2048] 807 } 808 809 if _, err := db.Exec("UPDATE files SET description = ? WHERE id = ?", description, f.ID); err != nil { 810 renderError(w, "Failed to update description", http.StatusInternalServerError) 811 return 812 } 813 http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) 814 return 815 } 816 817 cat := r.FormValue("category") 818 val := r.FormValue("value") 819 if cat != "" && val != "" { 820 _, tagID, _ := getOrCreateCategoryAndTag(cat, val) 821 db.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", f.ID, tagID) 822 } 823 http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) 824 return 825 } 826 827 catRows, _ := db.Query("SELECT name FROM categories ORDER BY name") 828 var cats []string 829 for catRows.Next() { 830 var c string 831 catRows.Scan(&c) 832 cats = append(cats, c) 833 } 834 catRows.Close() 835 836 pageData := buildPageDataWithIP(f.Filename, struct { 837 File File 838 Categories []string 839 EscapedFilename string 840 }{f, cats, url.PathEscape(f.Filename)}) 841 842 renderTemplate(w, "file.html", pageData) 843 } 844 845 func tagActionHandler(w http.ResponseWriter, r *http.Request, parts []string) { 846 fileID := parts[2] 847 cat := parts[4] 848 val := parts[5] 849 action := parts[6] 850 851 if action == "delete" && r.Method == http.MethodPost { 852 var tagID int 853 db.QueryRow(` 854 SELECT t.id 855 FROM tags t 856 JOIN categories c ON c.id=t.category_id 857 WHERE c.name=? AND t.value=?`, cat, val).Scan(&tagID) 858 if tagID != 0 { 859 db.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID) 860 } 861 } 862 http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) 863 } 864 865 func tagsHandler(w http.ResponseWriter, r *http.Request) { 866 pageData := buildPageData("All Tags", nil) 867 pageData.Data = pageData.Tags 868 renderTemplate(w, "tags.html", pageData) 869 } 870 871 func tagFilterHandler(w http.ResponseWriter, r *http.Request) { 872 // Get page number from query params 873 pageStr := r.URL.Query().Get("page") 874 page := 1 875 if pageStr != "" { 876 if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { 877 page = p 878 } 879 } 880 881 // Get per page from config 882 perPage := 50 883 if config.ItemsPerPage != "" { 884 if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 { 885 perPage = pp 886 } 887 } 888 889 // Split by /and/tag/ to get individual tag pairs 890 fullPath := strings.TrimPrefix(r.URL.Path, "/tag/") 891 tagPairs := strings.Split(fullPath, "/and/tag/") 892 893 type filter struct { 894 Category string 895 Value string 896 } 897 898 var filters []filter 899 for _, pair := range tagPairs { 900 parts := strings.Split(pair, "/") 901 if len(parts) != 2 { 902 renderError(w, "Invalid tag filter path", http.StatusBadRequest) 903 return 904 } 905 filters = append(filters, filter{parts[0], parts[1]}) 906 } 907 908 // Build count query first 909 countQuery := `SELECT COUNT(DISTINCT f.id) FROM files f WHERE 1=1` 910 countArgs := []interface{}{} 911 for _, f := range filters { 912 if f.Value == "unassigned" { 913 countQuery += ` 914 AND NOT EXISTS ( 915 SELECT 1 916 FROM file_tags ft 917 JOIN tags t ON ft.tag_id = t.id 918 JOIN categories c ON c.id = t.category_id 919 WHERE ft.file_id = f.id AND c.name = ? 920 )` 921 countArgs = append(countArgs, f.Category) 922 } else { 923 countQuery += ` 924 AND EXISTS ( 925 SELECT 1 926 FROM file_tags ft 927 JOIN tags t ON ft.tag_id = t.id 928 JOIN categories c ON c.id = t.category_id 929 WHERE ft.file_id = f.id AND c.name = ? AND t.value = ? 930 )` 931 countArgs = append(countArgs, f.Category, f.Value) 932 } 933 } 934 935 // Get total count 936 var total int 937 err := db.QueryRow(countQuery, countArgs...).Scan(&total) 938 if err != nil { 939 renderError(w, "Failed to count files", http.StatusInternalServerError) 940 return 941 } 942 943 // Build main query with pagination 944 query := `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f WHERE 1=1` 945 args := []interface{}{} 946 for _, f := range filters { 947 if f.Value == "unassigned" { 948 query += ` 949 AND NOT EXISTS ( 950 SELECT 1 951 FROM file_tags ft 952 JOIN tags t ON ft.tag_id = t.id 953 JOIN categories c ON c.id = t.category_id 954 WHERE ft.file_id = f.id AND c.name = ? 955 )` 956 args = append(args, f.Category) 957 } else { 958 query += ` 959 AND EXISTS ( 960 SELECT 1 961 FROM file_tags ft 962 JOIN tags t ON ft.tag_id = t.id 963 JOIN categories c ON c.id = t.category_id 964 WHERE ft.file_id = f.id AND c.name = ? AND t.value = ? 965 )` 966 args = append(args, f.Category, f.Value) 967 } 968 } 969 970 // Add pagination 971 offset := (page - 1) * perPage 972 query += ` ORDER BY f.id DESC LIMIT ? OFFSET ?` 973 args = append(args, perPage, offset) 974 975 files, err := queryFilesWithTags(query, args...) 976 if err != nil { 977 renderError(w, "Failed to fetch files", http.StatusInternalServerError) 978 return 979 } 980 981 var titleParts []string 982 for _, f := range filters { 983 titleParts = append(titleParts, fmt.Sprintf("%s: %s", f.Category, f.Value)) 984 } 985 title := "Tagged: " + strings.Join(titleParts, ", ") 986 987 pageData := buildPageDataWithPagination(title, struct { 988 Tagged []File 989 Untagged []File 990 }{files, nil}, page, total, perPage) 991 992 renderTemplate(w, "list.html", pageData) 993 } 994 995 func loadConfig() error { 996 config = Config{ 997 DatabasePath: "./database.db", 998 UploadDir: "uploads", 999 ServerPort: ":8080", 1000 InstanceName: "Taggart", 1001 GallerySize: "400px", 1002 ItemsPerPage: "100", 1003 } 1004 1005 if data, err := ioutil.ReadFile("config.json"); err == nil { 1006 if err := json.Unmarshal(data, &config); err != nil { 1007 return err 1008 } 1009 } 1010 1011 return os.MkdirAll(config.UploadDir, 0755) 1012 } 1013 1014 func saveConfig() error { 1015 data, err := json.MarshalIndent(config, "", " ") 1016 if err != nil { 1017 return err 1018 } 1019 return ioutil.WriteFile("config.json", data, 0644) 1020 } 1021 1022 func validateConfig(newConfig Config) error { 1023 if newConfig.DatabasePath == "" { 1024 return fmt.Errorf("database path cannot be empty") 1025 } 1026 1027 if newConfig.UploadDir == "" { 1028 return fmt.Errorf("upload directory cannot be empty") 1029 } 1030 1031 if newConfig.ServerPort == "" || !strings.HasPrefix(newConfig.ServerPort, ":") { 1032 return fmt.Errorf("server port must be in format ':8080'") 1033 } 1034 1035 if err := os.MkdirAll(newConfig.UploadDir, 0755); err != nil { 1036 return fmt.Errorf("cannot create upload directory: %v", err) 1037 } 1038 1039 return nil 1040 } 1041 1042 func settingsHandler(w http.ResponseWriter, r *http.Request) { 1043 if r.Method == http.MethodPost { 1044 newConfig := Config{ 1045 DatabasePath: strings.TrimSpace(r.FormValue("database_path")), 1046 UploadDir: strings.TrimSpace(r.FormValue("upload_dir")), 1047 ServerPort: strings.TrimSpace(r.FormValue("server_port")), 1048 InstanceName: strings.TrimSpace(r.FormValue("instance_name")), 1049 GallerySize: strings.TrimSpace(r.FormValue("gallery_size")), 1050 ItemsPerPage: strings.TrimSpace(r.FormValue("items_per_page")), 1051 } 1052 1053 if err := validateConfig(newConfig); err != nil { 1054 pageData := buildPageData("Settings", struct { 1055 Config Config 1056 Error string 1057 }{config, err.Error()}) 1058 renderTemplate(w, "settings.html", pageData) 1059 return 1060 } 1061 1062 needsRestart := (newConfig.DatabasePath != config.DatabasePath || 1063 newConfig.ServerPort != config.ServerPort) 1064 1065 config = newConfig 1066 if err := saveConfig(); err != nil { 1067 pageData := buildPageData("Settings", struct { 1068 Config Config 1069 Error string 1070 }{config, "Failed to save configuration: " + err.Error()}) 1071 renderTemplate(w, "settings.html", pageData) 1072 return 1073 } 1074 1075 var message string 1076 if needsRestart { 1077 message = "Settings saved successfully! Please restart the server for database/port changes to take effect." 1078 } else { 1079 message = "Settings saved successfully!" 1080 } 1081 1082 pageData := buildPageData("Settings", struct { 1083 Config Config 1084 Error string 1085 Success string 1086 }{config, "", message}) 1087 renderTemplate(w, "settings.html", pageData) 1088 return 1089 } 1090 1091 pageData := buildPageData("Settings", struct { 1092 Config Config 1093 Error string 1094 Success string 1095 }{config, "", ""}) 1096 renderTemplate(w, "settings.html", pageData) 1097 } 1098 1099 func ytdlpHandler(w http.ResponseWriter, r *http.Request) { 1100 if r.Method != http.MethodPost { 1101 http.Redirect(w, r, "/upload", http.StatusSeeOther) 1102 return 1103 } 1104 1105 videoURL := r.FormValue("url") 1106 if videoURL == "" { 1107 renderError(w, "No URL provided", http.StatusBadRequest) 1108 return 1109 } 1110 1111 outTemplate := filepath.Join(config.UploadDir, "%(title)s.%(ext)s") 1112 filenameCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, "--get-filename", videoURL) 1113 filenameBytes, err := filenameCmd.Output() 1114 if err != nil { 1115 renderError(w, fmt.Sprintf("Failed to get filename: %v", err), http.StatusInternalServerError) 1116 return 1117 } 1118 expectedFullPath := strings.TrimSpace(string(filenameBytes)) 1119 expectedFilename := filepath.Base(expectedFullPath) 1120 1121 finalFilename, finalPath, err := checkFileConflictStrict(expectedFilename) 1122 if err != nil { 1123 renderError(w, err.Error(), http.StatusConflict) 1124 return 1125 } 1126 1127 downloadCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, videoURL) 1128 downloadCmd.Stdout = os.Stdout 1129 downloadCmd.Stderr = os.Stderr 1130 if err := downloadCmd.Run(); err != nil { 1131 renderError(w, fmt.Sprintf("Failed to download video: %v", err), http.StatusInternalServerError) 1132 return 1133 } 1134 1135 if expectedFullPath != finalPath { 1136 if err := os.Rename(expectedFullPath, finalPath); err != nil { 1137 renderError(w, fmt.Sprintf("Failed to move downloaded file: %v", err), http.StatusInternalServerError) 1138 return 1139 } 1140 } 1141 1142 tempPath := finalPath + ".tmp" 1143 if err := os.Rename(finalPath, tempPath); err != nil { 1144 renderError(w, fmt.Sprintf("Failed to create temp file for processing: %v", err), http.StatusInternalServerError) 1145 return 1146 } 1147 1148 processedPath, warningMsg, err := processVideoFile(tempPath, finalPath) 1149 if err != nil { 1150 os.Remove(tempPath) 1151 renderError(w, fmt.Sprintf("Failed to process video: %v", err), http.StatusInternalServerError) 1152 return 1153 } 1154 1155 id, err := saveFileToDatabase(finalFilename, processedPath) 1156 if err != nil { 1157 os.Remove(processedPath) 1158 renderError(w, err.Error(), http.StatusInternalServerError) 1159 return 1160 } 1161 1162 redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) 1163 } 1164 1165 func parseFileIDRange(rangeStr string) ([]int, error) { 1166 var fileIDs []int 1167 parts := strings.Split(rangeStr, ",") 1168 1169 for _, part := range parts { 1170 part = strings.TrimSpace(part) 1171 if part == "" { 1172 continue 1173 } 1174 1175 if strings.Contains(part, "-") { 1176 rangeParts := strings.Split(part, "-") 1177 if len(rangeParts) != 2 { 1178 return nil, fmt.Errorf("invalid range format: %s", part) 1179 } 1180 1181 start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0])) 1182 if err != nil { 1183 return nil, fmt.Errorf("invalid start ID in range %s: %v", part, err) 1184 } 1185 1186 end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1])) 1187 if err != nil { 1188 return nil, fmt.Errorf("invalid end ID in range %s: %v", part, err) 1189 } 1190 1191 if start > end { 1192 return nil, fmt.Errorf("invalid range %s: start must be <= end", part) 1193 } 1194 1195 for i := start; i <= end; i++ { 1196 fileIDs = append(fileIDs, i) 1197 } 1198 } else { 1199 id, err := strconv.Atoi(part) 1200 if err != nil { 1201 return nil, fmt.Errorf("invalid file ID: %s", part) 1202 } 1203 fileIDs = append(fileIDs, id) 1204 } 1205 } 1206 1207 uniqueIDs := make(map[int]bool) 1208 var result []int 1209 for _, id := range fileIDs { 1210 if !uniqueIDs[id] { 1211 uniqueIDs[id] = true 1212 result = append(result, id) 1213 } 1214 } 1215 1216 return result, nil 1217 } 1218 1219 func validateFileIDs(fileIDs []int) ([]File, error) { 1220 if len(fileIDs) == 0 { 1221 return nil, fmt.Errorf("no file IDs provided") 1222 } 1223 1224 placeholders := make([]string, len(fileIDs)) 1225 args := make([]interface{}, len(fileIDs)) 1226 for i, id := range fileIDs { 1227 placeholders[i] = "?" 1228 args[i] = id 1229 } 1230 1231 query := fmt.Sprintf("SELECT id, filename, path FROM files WHERE id IN (%s) ORDER BY id", 1232 strings.Join(placeholders, ",")) 1233 1234 rows, err := db.Query(query, args...) 1235 if err != nil { 1236 return nil, fmt.Errorf("database error: %v", err) 1237 } 1238 defer rows.Close() 1239 1240 var files []File 1241 foundIDs := make(map[int]bool) 1242 1243 for rows.Next() { 1244 var f File 1245 err := rows.Scan(&f.ID, &f.Filename, &f.Path) 1246 if err != nil { 1247 return nil, fmt.Errorf("error scanning file: %v", err) 1248 } 1249 files = append(files, f) 1250 foundIDs[f.ID] = true 1251 } 1252 1253 var missingIDs []int 1254 for _, id := range fileIDs { 1255 if !foundIDs[id] { 1256 missingIDs = append(missingIDs, id) 1257 } 1258 } 1259 1260 if len(missingIDs) > 0 { 1261 return files, fmt.Errorf("file IDs not found: %v", missingIDs) 1262 } 1263 1264 return files, nil 1265 } 1266 1267 func applyBulkTagOperations(fileIDs []int, category, value, operation string) error { 1268 if category == "" { 1269 return fmt.Errorf("category cannot be empty") 1270 } 1271 1272 if operation == "add" && value == "" { 1273 return fmt.Errorf("value cannot be empty when adding tags") 1274 } 1275 1276 tx, err := db.Begin() 1277 if err != nil { 1278 return fmt.Errorf("failed to start transaction: %v", err) 1279 } 1280 defer tx.Rollback() 1281 1282 var catID int 1283 err = tx.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID) 1284 if err != nil && err != sql.ErrNoRows { 1285 return fmt.Errorf("failed to query category: %v", err) 1286 } 1287 1288 if catID == 0 { 1289 if operation == "remove" { 1290 return fmt.Errorf("cannot remove non-existent category: %s", category) 1291 } 1292 res, err := tx.Exec("INSERT INTO categories(name) VALUES(?)", category) 1293 if err != nil { 1294 return fmt.Errorf("failed to create category: %v", err) 1295 } 1296 cid, _ := res.LastInsertId() 1297 catID = int(cid) 1298 } 1299 1300 var tagID int 1301 if value != "" { 1302 err = tx.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID) 1303 if err != nil && err != sql.ErrNoRows { 1304 return fmt.Errorf("failed to query tag: %v", err) 1305 } 1306 1307 if tagID == 0 { 1308 if operation == "remove" { 1309 return fmt.Errorf("cannot remove non-existent tag: %s=%s", category, value) 1310 } 1311 res, err := tx.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value) 1312 if err != nil { 1313 return fmt.Errorf("failed to create tag: %v", err) 1314 } 1315 tid, _ := res.LastInsertId() 1316 tagID = int(tid) 1317 } 1318 } 1319 1320 for _, fileID := range fileIDs { 1321 if operation == "add" { 1322 _, err = tx.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", fileID, tagID) 1323 } else if operation == "remove" { 1324 if value != "" { 1325 _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID) 1326 } else { 1327 _, err = tx.Exec(`DELETE FROM file_tags WHERE file_id=? AND tag_id IN (SELECT t.id FROM tags t WHERE t.category_id=?)`, fileID, catID) 1328 } 1329 } else { 1330 return fmt.Errorf("invalid operation: %s (must be 'add' or 'remove')", operation) 1331 } 1332 if err != nil { 1333 return fmt.Errorf("failed to %s tag for file %d: %v", operation, fileID, err) 1334 } 1335 } 1336 1337 return tx.Commit() 1338 } 1339 1340 type BulkTagFormData struct { 1341 Categories []string 1342 RecentFiles []File 1343 Error string 1344 Success string 1345 FormData struct { 1346 FileRange string 1347 Category string 1348 Value string 1349 Operation string 1350 } 1351 } 1352 1353 func getBulkTagFormData() BulkTagFormData { 1354 catRows, _ := db.Query("SELECT name FROM categories ORDER BY name") 1355 var cats []string 1356 for catRows.Next() { 1357 var c string 1358 catRows.Scan(&c) 1359 cats = append(cats, c) 1360 } 1361 catRows.Close() 1362 1363 recentRows, _ := db.Query("SELECT id, filename FROM files ORDER BY id DESC LIMIT 20") 1364 var recentFiles []File 1365 for recentRows.Next() { 1366 var f File 1367 recentRows.Scan(&f.ID, &f.Filename) 1368 recentFiles = append(recentFiles, f) 1369 } 1370 recentRows.Close() 1371 1372 return BulkTagFormData{ 1373 Categories: cats, 1374 RecentFiles: recentFiles, 1375 FormData: struct { 1376 FileRange string 1377 Category string 1378 Value string 1379 Operation string 1380 }{Operation: "add"}, 1381 } 1382 } 1383 1384 func bulkTagHandler(w http.ResponseWriter, r *http.Request) { 1385 if r.Method == http.MethodGet { 1386 formData := getBulkTagFormData() 1387 pageData := buildPageData("Bulk Tag Editor", formData) 1388 renderTemplate(w, "bulk-tag.html", pageData) 1389 return 1390 } 1391 1392 if r.Method == http.MethodPost { 1393 rangeStr := strings.TrimSpace(r.FormValue("file_range")) 1394 category := strings.TrimSpace(r.FormValue("category")) 1395 value := strings.TrimSpace(r.FormValue("value")) 1396 operation := r.FormValue("operation") 1397 1398 formData := getBulkTagFormData() 1399 formData.FormData.FileRange = rangeStr 1400 formData.FormData.Category = category 1401 formData.FormData.Value = value 1402 formData.FormData.Operation = operation 1403 1404 createErrorResponse := func(errorMsg string) { 1405 formData.Error = errorMsg 1406 pageData := buildPageData("Bulk Tag Editor", formData) 1407 renderTemplate(w, "bulk-tag.html", pageData) 1408 } 1409 1410 if rangeStr == "" { 1411 createErrorResponse("File range cannot be empty") 1412 return 1413 } 1414 1415 if category == "" { 1416 createErrorResponse("Category cannot be empty") 1417 return 1418 } 1419 1420 if operation == "add" && value == "" { 1421 createErrorResponse("Value cannot be empty when adding tags") 1422 return 1423 } 1424 1425 fileIDs, err := parseFileIDRange(rangeStr) 1426 if err != nil { 1427 createErrorResponse(fmt.Sprintf("Invalid file range: %v", err)) 1428 return 1429 } 1430 1431 validFiles, err := validateFileIDs(fileIDs) 1432 if err != nil { 1433 createErrorResponse(fmt.Sprintf("File validation error: %v", err)) 1434 return 1435 } 1436 1437 err = applyBulkTagOperations(fileIDs, category, value, operation) 1438 if err != nil { 1439 createErrorResponse(fmt.Sprintf("Tag operation failed: %v", err)) 1440 return 1441 } 1442 1443 var successMsg string 1444 if operation == "add" { 1445 successMsg = fmt.Sprintf("Tag '%s: %s' added to %d files", category, value, len(validFiles)) 1446 } else { 1447 if value != "" { 1448 successMsg = fmt.Sprintf("Tag '%s: %s' removed from %d files", category, value, len(validFiles)) 1449 } else { 1450 successMsg = fmt.Sprintf("All '%s' category tags removed from %d files", category, len(validFiles)) 1451 } 1452 } 1453 1454 var filenames []string 1455 for _, f := range validFiles { 1456 filenames = append(filenames, f.Filename) 1457 } 1458 1459 if len(filenames) <= 5 { 1460 successMsg += fmt.Sprintf(": %s", strings.Join(filenames, ", ")) 1461 } else { 1462 successMsg += fmt.Sprintf(": %s and %d more", strings.Join(filenames[:5], ", "), len(filenames)-5) 1463 } 1464 1465 formData.Success = successMsg 1466 pageData := buildPageData("Bulk Tag Editor", formData) 1467 renderTemplate(w, "bulk-tag.html", pageData) 1468 return 1469 } 1470 1471 renderError(w, "Method not allowed", http.StatusMethodNotAllowed) 1472 } 1473 1474 func sanitizeFilename(filename string) string { 1475 if filename == "" { 1476 return "file" 1477 } 1478 filename = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(filename, "/", "_"), "\\", "_"), "..", "_") 1479 if filename == "" { 1480 return "file" 1481 } 1482 return filename 1483 } 1484 1485 func detectVideoCodec(filePath string) (string, error) { 1486 cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", 1487 "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", filePath) 1488 out, err := cmd.Output() 1489 if err != nil { 1490 return "", fmt.Errorf("failed to probe video codec: %v", err) 1491 } 1492 return strings.TrimSpace(string(out)), nil 1493 } 1494 1495 func reencodeHEVCToH264(inputPath, outputPath string) error { 1496 cmd := exec.Command("ffmpeg", "-i", inputPath, 1497 "-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23", 1498 "-c:a", "aac", "-movflags", "+faststart", outputPath) 1499 cmd.Stderr = os.Stderr 1500 cmd.Stdout = os.Stdout 1501 return cmd.Run() 1502 } 1503 1504 func processVideoFile(tempPath, finalPath string) (string, string, error) { 1505 codec, err := detectVideoCodec(tempPath) 1506 if err != nil { 1507 return "", "", err 1508 } 1509 1510 if codec == "hevc" || codec == "h265" { 1511 warningMsg := "The video uses HEVC and has been re-encoded to H.264 for browser compatibility." 1512 if err := reencodeHEVCToH264(tempPath, finalPath); err != nil { 1513 return "", "", fmt.Errorf("failed to re-encode HEVC video: %v", err) 1514 } 1515 os.Remove(tempPath) 1516 return finalPath, warningMsg, nil 1517 } 1518 1519 if err := os.Rename(tempPath, finalPath); err != nil { 1520 return "", "", fmt.Errorf("failed to move file: %v", err) 1521 } 1522 1523 ext := strings.ToLower(filepath.Ext(finalPath)) 1524 if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".mkv" || ext == ".webm" || ext == ".m4v" { 1525 if err := generateThumbnail(finalPath, config.UploadDir, filepath.Base(finalPath)); err != nil { 1526 log.Printf("Warning: could not generate thumbnail: %v", err) 1527 } 1528 } 1529 1530 return finalPath, "", nil 1531 } 1532 1533 func saveFileToDatabase(filename, path string) (int64, error) { 1534 res, err := db.Exec("INSERT INTO files (filename, path, description) VALUES (?, ?, '')", filename, path) 1535 if err != nil { 1536 return 0, fmt.Errorf("failed to save file to database: %v", err) 1537 } 1538 id, err := res.LastInsertId() 1539 if err != nil { 1540 return 0, fmt.Errorf("failed to get inserted ID: %v", err) 1541 } 1542 return id, nil 1543 } 1544 1545 func getFilesOnDisk(uploadDir string) ([]string, error) { 1546 entries, err := os.ReadDir(uploadDir) 1547 if err != nil { 1548 return nil, err 1549 } 1550 var files []string 1551 for _, e := range entries { 1552 if !e.IsDir() { 1553 files = append(files, e.Name()) 1554 } 1555 } 1556 return files, nil 1557 } 1558 1559 func getFilesInDB() (map[string]bool, error) { 1560 rows, err := db.Query(`SELECT filename FROM files`) 1561 if err != nil { 1562 return nil, err 1563 } 1564 defer rows.Close() 1565 1566 fileMap := make(map[string]bool) 1567 for rows.Next() { 1568 var name string 1569 rows.Scan(&name) 1570 fileMap[name] = true 1571 } 1572 return fileMap, nil 1573 } 1574 1575 func getOrphanedFiles(uploadDir string) ([]string, error) { 1576 diskFiles, err := getFilesOnDisk(uploadDir) 1577 if err != nil { 1578 return nil, err 1579 } 1580 1581 dbFiles, err := getFilesInDB() 1582 if err != nil { 1583 return nil, err 1584 } 1585 1586 var orphans []string 1587 for _, f := range diskFiles { 1588 if !dbFiles[f] { 1589 orphans = append(orphans, f) 1590 } 1591 } 1592 return orphans, nil 1593 } 1594 1595 func orphansHandler(w http.ResponseWriter, r *http.Request) { 1596 orphans, err := getOrphanedFiles(config.UploadDir) 1597 if err != nil { 1598 renderError(w, "Error reading orphaned files", http.StatusInternalServerError) 1599 return 1600 } 1601 1602 pageData := buildPageData("Orphaned Files", orphans) 1603 renderTemplate(w, "orphans.html", pageData) 1604 } 1605 1606 func generateThumbnail(videoPath, uploadDir, filename string) error { 1607 thumbDir := filepath.Join(uploadDir, "thumbnails") 1608 if err := os.MkdirAll(thumbDir, 0755); err != nil { 1609 return fmt.Errorf("failed to create thumbnails directory: %v", err) 1610 } 1611 1612 thumbPath := filepath.Join(thumbDir, filename+".jpg") 1613 1614 cmd := exec.Command("ffmpeg", "-y", "-ss", "00:00:05", "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath) 1615 cmd.Stdout = os.Stdout 1616 cmd.Stderr = os.Stderr 1617 1618 if err := cmd.Run(); err != nil { 1619 cmd := exec.Command("ffmpeg", "-y", "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath) 1620 cmd.Stdout = os.Stdout 1621 cmd.Stderr = os.Stderr 1622 if err2 := cmd.Run(); err2 != nil { 1623 return fmt.Errorf("failed to generate thumbnail: %v", err2) 1624 } 1625 } 1626 1627 return nil 1628 }