main.go (64965B)
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 "time" 20 21 _ "github.com/mattn/go-sqlite3" 22 ) 23 24 var ( 25 db *sql.DB 26 tmpl *template.Template 27 config Config 28 ) 29 30 type File struct { 31 ID int 32 Filename string 33 EscapedFilename string 34 Path string 35 Description string 36 Tags map[string][]string 37 } 38 39 type Config struct { 40 DatabasePath string `json:"database_path"` 41 UploadDir string `json:"upload_dir"` 42 ServerPort string `json:"server_port"` 43 InstanceName string `json:"instance_name"` 44 GallerySize string `json:"gallery_size"` 45 ItemsPerPage string `json:"items_per_page"` 46 TagAliases []TagAliasGroup `json:"tag_aliases"` 47 } 48 49 type Breadcrumb struct { 50 Name string 51 URL string 52 } 53 54 type TagAliasGroup struct { 55 Category string `json:"category"` 56 Aliases []string `json:"aliases"` 57 } 58 59 type TagDisplay struct { 60 Value string 61 Count int 62 } 63 64 type ListData struct { 65 Tagged []File 66 Untagged []File 67 Breadcrumbs []Breadcrumb 68 } 69 70 type PageData struct { 71 Title string 72 Data interface{} 73 Query string 74 IP string 75 Port string 76 Files []File 77 Tags map[string][]TagDisplay 78 Breadcrumbs []Breadcrumb 79 Pagination *Pagination 80 GallerySize string 81 } 82 83 type Pagination struct { 84 CurrentPage int 85 TotalPages int 86 HasPrev bool 87 HasNext bool 88 PrevPage int 89 NextPage int 90 PerPage int 91 } 92 93 type VideoFile struct { 94 ID int 95 Filename string 96 Path string 97 HasThumbnail bool 98 ThumbnailPath string 99 EscapedFilename string 100 } 101 102 func expandTagWithAliases(category, value string) []string { 103 values := []string{value} 104 105 for _, group := range config.TagAliases { 106 if group.Category != category { 107 continue 108 } 109 110 // Check if the value is in this alias group 111 found := false 112 for _, alias := range group.Aliases { 113 if strings.EqualFold(alias, value) { 114 found = true 115 break 116 } 117 } 118 119 if found { 120 // Add all aliases from this group 121 for _, alias := range group.Aliases { 122 if !strings.EqualFold(alias, value) { 123 values = append(values, alias) 124 } 125 } 126 break 127 } 128 } 129 130 return values 131 } 132 133 func getOrCreateCategoryAndTag(category, value string) (int, int, error) { 134 category = strings.TrimSpace(category) 135 value = strings.TrimSpace(value) 136 var catID int 137 err := db.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID) 138 if err == sql.ErrNoRows { 139 res, err := db.Exec("INSERT INTO categories(name) VALUES(?)", category) 140 if err != nil { 141 return 0, 0, err 142 } 143 cid, _ := res.LastInsertId() 144 catID = int(cid) 145 } else if err != nil { 146 return 0, 0, err 147 } 148 149 var tagID int 150 if value != "" { 151 err = db.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID) 152 if err == sql.ErrNoRows { 153 res, err := db.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value) 154 if err != nil { 155 return 0, 0, err 156 } 157 tid, _ := res.LastInsertId() 158 tagID = int(tid) 159 } else if err != nil { 160 return 0, 0, err 161 } 162 } 163 164 return catID, tagID, nil 165 } 166 167 func queryFilesWithTags(query string, args ...interface{}) ([]File, error) { 168 rows, err := db.Query(query, args...) 169 if err != nil { 170 return nil, err 171 } 172 defer rows.Close() 173 174 var files []File 175 for rows.Next() { 176 var f File 177 if err := rows.Scan(&f.ID, &f.Filename, &f.Path, &f.Description); err != nil { 178 return nil, err 179 } 180 f.EscapedFilename = url.PathEscape(f.Filename) 181 files = append(files, f) 182 } 183 return files, nil 184 } 185 186 func getTaggedFiles() ([]File, error) { 187 return queryFilesWithTags(` 188 SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description 189 FROM files f 190 JOIN file_tags ft ON ft.file_id = f.id 191 ORDER BY f.id DESC 192 `) 193 } 194 195 func getTaggedFilesPaginated(page, perPage int) ([]File, int, error) { 196 // Get total count 197 var total int 198 err := db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f JOIN file_tags ft ON ft.file_id = f.id`).Scan(&total) 199 if err != nil { 200 return nil, 0, err 201 } 202 203 offset := (page - 1) * perPage 204 files, err := queryFilesWithTags(` 205 SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description 206 FROM files f 207 JOIN file_tags ft ON ft.file_id = f.id 208 ORDER BY f.id DESC 209 LIMIT ? OFFSET ? 210 `, perPage, offset) 211 212 return files, total, err 213 } 214 215 func getUntaggedFiles() ([]File, error) { 216 return queryFilesWithTags(` 217 SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description 218 FROM files f 219 LEFT JOIN file_tags ft ON ft.file_id = f.id 220 WHERE ft.file_id IS NULL 221 ORDER BY f.id DESC 222 `) 223 } 224 225 func getUntaggedFilesPaginated(page, perPage int) ([]File, int, error) { 226 // Get total count 227 var total int 228 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) 229 if err != nil { 230 return nil, 0, err 231 } 232 233 offset := (page - 1) * perPage 234 files, err := queryFilesWithTags(` 235 SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description 236 FROM files f 237 LEFT JOIN file_tags ft ON ft.file_id = f.id 238 WHERE ft.file_id IS NULL 239 ORDER BY f.id DESC 240 LIMIT ? OFFSET ? 241 `, perPage, offset) 242 243 return files, total, err 244 } 245 246 func buildPageData(title string, data interface{}) PageData { 247 tagMap, _ := getTagData() 248 return PageData{Title: title, Data: data, Tags: tagMap, GallerySize: config.GallerySize,} 249 } 250 251 func buildPageDataWithPagination(title string, data interface{}, page, total, perPage int) PageData { 252 pd := buildPageData(title, data) 253 pd.Pagination = calculatePagination(page, total, perPage) 254 return pd 255 } 256 257 func calculatePagination(page, total, perPage int) *Pagination { 258 totalPages := (total + perPage - 1) / perPage 259 if totalPages < 1 { 260 totalPages = 1 261 } 262 263 return &Pagination{ 264 CurrentPage: page, 265 TotalPages: totalPages, 266 HasPrev: page > 1, 267 HasNext: page < totalPages, 268 PrevPage: page - 1, 269 NextPage: page + 1, 270 PerPage: perPage, 271 } 272 } 273 274 func buildPageDataWithIP(title string, data interface{}) PageData { 275 pageData := buildPageData(title, data) 276 ip, _ := getLocalIP() 277 pageData.IP = ip 278 pageData.Port = strings.TrimPrefix(config.ServerPort, ":") 279 return pageData 280 } 281 282 func renderError(w http.ResponseWriter, message string, statusCode int) { 283 http.Error(w, message, statusCode) 284 } 285 286 func renderTemplate(w http.ResponseWriter, tmplName string, data PageData) { 287 if err := tmpl.ExecuteTemplate(w, tmplName, data); err != nil { 288 renderError(w, "Template rendering failed", http.StatusInternalServerError) 289 } 290 } 291 292 func getTagData() (map[string][]TagDisplay, error) { 293 rows, err := db.Query(` 294 SELECT c.name, t.value, COUNT(ft.file_id) 295 FROM tags t 296 JOIN categories c ON c.id = t.category_id 297 LEFT JOIN file_tags ft ON ft.tag_id = t.id 298 GROUP BY t.id 299 HAVING COUNT(ft.file_id) > 0 300 ORDER BY c.name, t.value`) 301 if err != nil { 302 return nil, err 303 } 304 defer rows.Close() 305 306 tagMap := make(map[string][]TagDisplay) 307 for rows.Next() { 308 var cat, val string 309 var count int 310 rows.Scan(&cat, &val, &count) 311 tagMap[cat] = append(tagMap[cat], TagDisplay{Value: val, Count: count}) 312 } 313 return tagMap, nil 314 } 315 316 func main() { 317 if err := loadConfig(); err != nil { 318 log.Fatalf("Failed to load config: %v", err) 319 } 320 321 var err error 322 db, err = sql.Open("sqlite3", config.DatabasePath) 323 if err != nil { 324 log.Fatal(err) 325 } 326 defer db.Close() 327 328 _, err = db.Exec(` 329 CREATE TABLE IF NOT EXISTS files ( 330 id INTEGER PRIMARY KEY AUTOINCREMENT, 331 filename TEXT, 332 path TEXT, 333 description TEXT DEFAULT '' 334 ); 335 CREATE TABLE IF NOT EXISTS categories ( 336 id INTEGER PRIMARY KEY AUTOINCREMENT, 337 name TEXT UNIQUE 338 ); 339 CREATE TABLE IF NOT EXISTS tags ( 340 id INTEGER PRIMARY KEY AUTOINCREMENT, 341 category_id INTEGER, 342 value TEXT, 343 UNIQUE(category_id, value) 344 ); 345 CREATE TABLE IF NOT EXISTS file_tags ( 346 file_id INTEGER, 347 tag_id INTEGER, 348 UNIQUE(file_id, tag_id) 349 ); 350 `) 351 if err != nil { 352 log.Fatal(err) 353 } 354 355 os.MkdirAll(config.UploadDir, 0755) 356 os.MkdirAll("static", 0755) 357 358 tmpl = template.Must(template.New("").Funcs(template.FuncMap{ 359 "hasAnySuffix": func(s string, suffixes ...string) bool { 360 for _, suf := range suffixes { 361 if strings.HasSuffix(strings.ToLower(s), suf) { 362 return true 363 } 364 } 365 return false 366 }, 367 "dict": func(values ...interface{}) (map[string]interface{}, error) { 368 if len(values)%2 != 0 { 369 return nil, fmt.Errorf("dict requires an even number of args") 370 } 371 dict := make(map[string]interface{}, len(values)/2) 372 for i := 0; i < len(values); i += 2 { 373 key, ok := values[i].(string) 374 if !ok { 375 return nil, fmt.Errorf("dict keys must be strings") 376 } 377 dict[key] = values[i+1] 378 } 379 return dict, nil 380 }, 381 }).ParseGlob("templates/*.html")) 382 383 http.HandleFunc("/", listFilesHandler) 384 http.HandleFunc("/add", uploadHandler) 385 http.HandleFunc("/add-yt", ytdlpHandler) 386 http.HandleFunc("/upload-url", uploadFromURLHandler) 387 http.HandleFunc("/file/", fileRouter) 388 http.HandleFunc("/tags", tagsHandler) 389 http.HandleFunc("/tag/", tagFilterHandler) 390 http.HandleFunc("/untagged", untaggedFilesHandler) 391 http.HandleFunc("/search", searchHandler) 392 http.HandleFunc("/bulk-tag", bulkTagHandler) 393 http.HandleFunc("/admin", adminHandler) 394 http.HandleFunc("/thumbnails/generate", generateThumbnailHandler) 395 396 http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(config.UploadDir)))) 397 http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) 398 399 log.Printf("Server started at http://localhost%s", config.ServerPort) 400 log.Printf("Database: %s", config.DatabasePath) 401 log.Printf("Upload directory: %s", config.UploadDir) 402 http.ListenAndServe(config.ServerPort, nil) 403 } 404 405 func searchHandler(w http.ResponseWriter, r *http.Request) { 406 query := strings.TrimSpace(r.URL.Query().Get("q")) 407 408 var files []File 409 var searchTitle string 410 411 if query != "" { 412 sqlPattern := "%" + strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(query), "*", "%"), "?", "_") + "%" 413 414 rows, err := db.Query(` 415 SELECT f.id, f.filename, f.path, COALESCE(f.description, '') AS description, 416 c.name AS category, t.value AS tag 417 FROM files f 418 LEFT JOIN file_tags ft ON ft.file_id = f.id 419 LEFT JOIN tags t ON t.id = ft.tag_id 420 LEFT JOIN categories c ON c.id = t.category_id 421 WHERE LOWER(f.filename) LIKE ? OR LOWER(f.description) LIKE ? OR LOWER(t.value) LIKE ? 422 ORDER BY f.filename 423 `, sqlPattern, sqlPattern, sqlPattern) 424 if err != nil { 425 renderError(w, "Search failed: "+err.Error(), http.StatusInternalServerError) 426 return 427 } 428 defer rows.Close() 429 430 fileMap := make(map[int]*File) 431 for rows.Next() { 432 var id int 433 var filename, path, description, category, tag sql.NullString 434 435 if err := rows.Scan(&id, &filename, &path, &description, &category, &tag); err != nil { 436 renderError(w, "Failed to read search results: "+err.Error(), http.StatusInternalServerError) 437 return 438 } 439 440 f, exists := fileMap[id] 441 if !exists { 442 f = &File{ 443 ID: id, 444 Filename: filename.String, 445 Path: path.String, 446 EscapedFilename: url.PathEscape(filename.String), 447 Description: description.String, 448 Tags: make(map[string][]string), 449 } 450 fileMap[id] = f 451 } 452 453 if category.Valid && tag.Valid && tag.String != "" { 454 f.Tags[category.String] = append(f.Tags[category.String], tag.String) 455 } 456 } 457 458 for _, f := range fileMap { 459 files = append(files, *f) 460 } 461 462 searchTitle = fmt.Sprintf("Search Results for: %s", query) 463 } else { 464 searchTitle = "Search Files" 465 } 466 467 pageData := buildPageData(searchTitle, files) 468 pageData.Query = query 469 pageData.Files = files 470 renderTemplate(w, "search.html", pageData) 471 } 472 473 func processUpload(src io.Reader, filename string) (int64, string, error) { 474 finalFilename, finalPath, err := checkFileConflictStrict(filename) 475 if err != nil { 476 return 0, "", err 477 } 478 479 tempPath := finalPath + ".tmp" 480 tempFile, err := os.Create(tempPath) 481 if err != nil { 482 return 0, "", fmt.Errorf("failed to create temp file: %v", err) 483 } 484 485 _, err = io.Copy(tempFile, src) 486 tempFile.Close() 487 if err != nil { 488 os.Remove(tempPath) 489 return 0, "", fmt.Errorf("failed to copy file data: %v", err) 490 } 491 492 ext := strings.ToLower(filepath.Ext(filename)) 493 videoExts := map[string]bool{ 494 ".mp4": true, ".mov": true, ".avi": true, 495 ".mkv": true, ".webm": true, ".m4v": true, 496 } 497 498 var processedPath string 499 var warningMsg string 500 501 if videoExts[ext] { 502 processedPath, warningMsg, err = processVideoFile(tempPath, finalPath) 503 if err != nil { 504 os.Remove(tempPath) 505 return 0, "", err 506 } 507 } else { 508 // Non-video → just rename temp file to final 509 if err := os.Rename(tempPath, finalPath); err != nil { 510 return 0, "", fmt.Errorf("failed to move file: %v", err) 511 } 512 processedPath = finalPath 513 } 514 515 id, err := saveFileToDatabase(finalFilename, processedPath) 516 if err != nil { 517 os.Remove(processedPath) 518 return 0, "", err 519 } 520 521 return id, warningMsg, nil 522 } 523 524 525 func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) { 526 if r.Method != http.MethodPost { 527 http.Redirect(w, r, "/upload", http.StatusSeeOther) 528 return 529 } 530 531 fileURL := r.FormValue("fileurl") 532 if fileURL == "" { 533 renderError(w, "No URL provided", http.StatusBadRequest) 534 return 535 } 536 537 customFilename := strings.TrimSpace(r.FormValue("filename")) 538 539 parsedURL, err := url.ParseRequestURI(fileURL) 540 if err != nil || !(parsedURL.Scheme == "http" || parsedURL.Scheme == "https") { 541 renderError(w, "Invalid URL", http.StatusBadRequest) 542 return 543 } 544 545 resp, err := http.Get(fileURL) 546 if err != nil || resp.StatusCode != http.StatusOK { 547 renderError(w, "Failed to download file", http.StatusBadRequest) 548 return 549 } 550 defer resp.Body.Close() 551 552 var filename string 553 urlExt := filepath.Ext(parsedURL.Path) 554 if customFilename != "" { 555 filename = customFilename 556 if filepath.Ext(filename) == "" && urlExt != "" { 557 filename += urlExt 558 } 559 } else { 560 parts := strings.Split(parsedURL.Path, "/") 561 filename = parts[len(parts)-1] 562 if filename == "" { 563 filename = "file_from_url" 564 } 565 } 566 567 id, warningMsg, err := processUpload(resp.Body, filename) 568 if err != nil { 569 renderError(w, err.Error(), http.StatusInternalServerError) 570 return 571 } 572 573 redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) 574 } 575 576 func listFilesHandler(w http.ResponseWriter, r *http.Request) { 577 // Get page number from query params 578 pageStr := r.URL.Query().Get("page") 579 page := 1 580 if pageStr != "" { 581 if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { 582 page = p 583 } 584 } 585 586 // Get per page from config 587 perPage := 50 588 if config.ItemsPerPage != "" { 589 if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 { 590 perPage = pp 591 } 592 } 593 594 tagged, taggedTotal, _ := getTaggedFilesPaginated(page, perPage) 595 untagged, untaggedTotal, _ := getUntaggedFilesPaginated(page, perPage) 596 597 // Use the larger total for pagination 598 total := taggedTotal 599 if untaggedTotal > total { 600 total = untaggedTotal 601 } 602 603 pageData := buildPageDataWithPagination("File Browser", ListData{ 604 Tagged: tagged, 605 Untagged: untagged, 606 Breadcrumbs: []Breadcrumb{}, 607 }, page, total, perPage) 608 609 renderTemplate(w, "list.html", pageData) 610 } 611 612 func untaggedFilesHandler(w http.ResponseWriter, r *http.Request) { 613 // Get page number from query params 614 pageStr := r.URL.Query().Get("page") 615 page := 1 616 if pageStr != "" { 617 if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { 618 page = p 619 } 620 } 621 622 // Get per page from config 623 perPage := 50 624 if config.ItemsPerPage != "" { 625 if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 { 626 perPage = pp 627 } 628 } 629 630 files, total, _ := getUntaggedFilesPaginated(page, perPage) 631 pageData := buildPageDataWithPagination("Untagged Files", files, page, total, perPage) 632 renderTemplate(w, "untagged.html", pageData) 633 } 634 635 func uploadHandler(w http.ResponseWriter, r *http.Request) { 636 if r.Method == http.MethodGet { 637 pageData := buildPageData("Add File", nil) 638 renderTemplate(w, "add.html", pageData) 639 return 640 } 641 642 // Parse the multipart form (with max memory limit, e.g., 32MB) 643 err := r.ParseMultipartForm(32 << 20) 644 if err != nil { 645 renderError(w, "Failed to parse form", http.StatusBadRequest) 646 return 647 } 648 649 files := r.MultipartForm.File["file"] 650 if len(files) == 0 { 651 renderError(w, "No files uploaded", http.StatusBadRequest) 652 return 653 } 654 655 var warnings []string 656 657 // Process each file 658 for _, fileHeader := range files { 659 file, err := fileHeader.Open() 660 if err != nil { 661 renderError(w, "Failed to open uploaded file", http.StatusInternalServerError) 662 return 663 } 664 defer file.Close() 665 666 _, warningMsg, err := processUpload(file, fileHeader.Filename) 667 if err != nil { 668 renderError(w, err.Error(), http.StatusInternalServerError) 669 return 670 } 671 672 if warningMsg != "" { 673 warnings = append(warnings, warningMsg) 674 } 675 } 676 677 var warningMsg string 678 if len(warnings) > 0 { 679 warningMsg = strings.Join(warnings, "; ") 680 } 681 682 redirectWithWarning(w, r, "/untagged", warningMsg) 683 } 684 685 func redirectWithWarning(w http.ResponseWriter, r *http.Request, baseURL, warningMsg string) { 686 redirectURL := baseURL 687 if warningMsg != "" { 688 redirectURL += "?warning=" + url.QueryEscape(warningMsg) 689 } 690 http.Redirect(w, r, redirectURL, http.StatusSeeOther) 691 } 692 693 func checkFileConflictStrict(filename string) (string, string, error) { 694 finalPath := filepath.Join(config.UploadDir, filename) 695 if _, err := os.Stat(finalPath); err == nil { 696 return "", "", fmt.Errorf("a file with that name already exists") 697 } else if !os.IsNotExist(err) { 698 return "", "", fmt.Errorf("failed to check for existing file: %v", err) 699 } 700 return filename, finalPath, nil 701 } 702 703 func getLocalIP() (string, error) { 704 addrs, err := net.InterfaceAddrs() 705 if err != nil { 706 return "", err 707 } 708 for _, addr := range addrs { 709 if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 710 if ipnet.IP.To4() != nil { 711 return ipnet.IP.String(), nil 712 } 713 } 714 } 715 return "", fmt.Errorf("no connected network interface found") 716 } 717 718 func fileRouter(w http.ResponseWriter, r *http.Request) { 719 parts := strings.Split(r.URL.Path, "/") 720 721 if len(parts) >= 4 && parts[3] == "delete" { 722 fileDeleteHandler(w, r, parts) 723 return 724 } 725 726 if len(parts) >= 4 && parts[3] == "rename" { 727 fileRenameHandler(w, r, parts) 728 return 729 } 730 731 if len(parts) >= 7 && parts[3] == "tag" { 732 tagActionHandler(w, r, parts) 733 return 734 } 735 736 fileHandler(w, r) 737 } 738 739 func fileDeleteHandler(w http.ResponseWriter, r *http.Request, parts []string) { 740 if r.Method != http.MethodPost { 741 http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther) 742 return 743 } 744 745 fileID := parts[2] 746 747 var currentFile File 748 err := db.QueryRow("SELECT id, filename, path FROM files WHERE id=?", fileID).Scan(¤tFile.ID, ¤tFile.Filename, ¤tFile.Path) 749 if err != nil { 750 renderError(w, "File not found", http.StatusNotFound) 751 return 752 } 753 754 tx, err := db.Begin() 755 if err != nil { 756 renderError(w, "Failed to start transaction", http.StatusInternalServerError) 757 return 758 } 759 defer tx.Rollback() 760 761 if _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=?", fileID); err != nil { 762 renderError(w, "Failed to delete file tags", http.StatusInternalServerError) 763 return 764 } 765 766 if _, err = tx.Exec("DELETE FROM files WHERE id=?", fileID); err != nil { 767 renderError(w, "Failed to delete file record", http.StatusInternalServerError) 768 return 769 } 770 771 if err = tx.Commit(); err != nil { 772 renderError(w, "Failed to commit transaction", http.StatusInternalServerError) 773 return 774 } 775 776 if err = os.Remove(currentFile.Path); err != nil { 777 log.Printf("Warning: Failed to delete physical file %s: %v", currentFile.Path, err) 778 } 779 780 // Delete thumbnail if it exists 781 thumbPath := filepath.Join(config.UploadDir, "thumbnails", currentFile.Filename+".jpg") 782 if _, err := os.Stat(thumbPath); err == nil { 783 if err := os.Remove(thumbPath); err != nil { 784 log.Printf("Warning: Failed to delete thumbnail %s: %v", thumbPath, err) 785 } 786 } 787 788 http.Redirect(w, r, "/?deleted="+currentFile.Filename, http.StatusSeeOther) 789 } 790 791 func fileRenameHandler(w http.ResponseWriter, r *http.Request, parts []string) { 792 if r.Method != http.MethodPost { 793 http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther) 794 return 795 } 796 797 fileID := parts[2] 798 newFilename := sanitizeFilename(strings.TrimSpace(r.FormValue("newfilename"))) 799 800 if newFilename == "" { 801 renderError(w, "New filename cannot be empty", http.StatusBadRequest) 802 return 803 } 804 805 var currentFilename, currentPath string 806 err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(¤tFilename, ¤tPath) 807 if err != nil { 808 renderError(w, "File not found", http.StatusNotFound) 809 return 810 } 811 812 if currentFilename == newFilename { 813 http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) 814 return 815 } 816 817 newPath := filepath.Join(config.UploadDir, newFilename) 818 if _, err := os.Stat(newPath); !os.IsNotExist(err) { 819 renderError(w, "A file with that name already exists", http.StatusConflict) 820 return 821 } 822 823 if err := os.Rename(currentPath, newPath); err != nil { 824 renderError(w, "Failed to rename physical file: "+err.Error(), http.StatusInternalServerError) 825 return 826 } 827 828 thumbOld := filepath.Join(config.UploadDir, "thumbnails", currentFilename+".jpg") 829 thumbNew := filepath.Join(config.UploadDir, "thumbnails", newFilename+".jpg") 830 831 if _, err := os.Stat(thumbOld); err == nil { 832 if err := os.Rename(thumbOld, thumbNew); err != nil { 833 os.Rename(newPath, currentPath) 834 renderError(w, "Failed to rename thumbnail: "+err.Error(), http.StatusInternalServerError) 835 return 836 } 837 } 838 839 _, err = db.Exec("UPDATE files SET filename=?, path=? WHERE id=?", newFilename, newPath, fileID) 840 if err != nil { 841 os.Rename(newPath, currentPath) 842 if _, err := os.Stat(thumbNew); err == nil { 843 os.Rename(thumbNew, thumbOld) 844 } 845 renderError(w, "Failed to update database", http.StatusInternalServerError) 846 return 847 } 848 849 http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) 850 } 851 852 func getPreviousTagValue(category string, excludeFileID int) (string, error) { 853 var value string 854 err := db.QueryRow(` 855 SELECT t.value 856 FROM tags t 857 JOIN categories c ON c.id = t.category_id 858 JOIN file_tags ft ON ft.tag_id = t.id 859 JOIN files f ON f.id = ft.file_id 860 WHERE c.name = ? AND ft.file_id != ? 861 ORDER BY ft.rowid DESC 862 LIMIT 1 863 `, category, excludeFileID).Scan(&value) 864 865 if err == sql.ErrNoRows { 866 return "", fmt.Errorf("no previous tag found for category: %s", category) 867 } 868 if err != nil { 869 return "", err 870 } 871 872 return value, nil 873 } 874 875 func fileHandler(w http.ResponseWriter, r *http.Request) { 876 idStr := strings.TrimPrefix(r.URL.Path, "/file/") 877 if strings.Contains(idStr, "/") { 878 idStr = strings.SplitN(idStr, "/", 2)[0] 879 } 880 881 var f File 882 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) 883 if err != nil { 884 renderError(w, "File not found", http.StatusNotFound) 885 return 886 } 887 888 f.Tags = make(map[string][]string) 889 rows, _ := db.Query(` 890 SELECT c.name, t.value 891 FROM tags t 892 JOIN categories c ON c.id = t.category_id 893 JOIN file_tags ft ON ft.tag_id = t.id 894 WHERE ft.file_id=?`, f.ID) 895 for rows.Next() { 896 var cat, val string 897 rows.Scan(&cat, &val) 898 f.Tags[cat] = append(f.Tags[cat], val) 899 } 900 rows.Close() 901 902 if r.Method == http.MethodPost { 903 if r.FormValue("action") == "update_description" { 904 description := r.FormValue("description") 905 if len(description) > 2048 { 906 description = description[:2048] 907 } 908 909 if _, err := db.Exec("UPDATE files SET description = ? WHERE id = ?", description, f.ID); err != nil { 910 renderError(w, "Failed to update description", http.StatusInternalServerError) 911 return 912 } 913 http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) 914 return 915 } 916 cat := strings.TrimSpace(r.FormValue("category")) 917 val := strings.TrimSpace(r.FormValue("value")) 918 if cat != "" && val != "" { 919 originalVal := val 920 if val == "!" { 921 previousVal, err := getPreviousTagValue(cat, f.ID) 922 if err != nil { 923 http.Redirect(w, r, "/file/"+idStr+"?error="+url.QueryEscape("No previous tag found for category: "+cat), http.StatusSeeOther) 924 return 925 } 926 val = previousVal 927 } 928 _, tagID, err := getOrCreateCategoryAndTag(cat, val) 929 if err != nil { 930 http.Redirect(w, r, "/file/"+idStr+"?error="+url.QueryEscape("Failed to create tag: "+err.Error()), http.StatusSeeOther) 931 return 932 } 933 _, err = db.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", f.ID, tagID) 934 if err != nil { 935 http.Redirect(w, r, "/file/"+idStr+"?error="+url.QueryEscape("Failed to add tag: "+err.Error()), http.StatusSeeOther) 936 return 937 } 938 if originalVal == "!" { 939 http.Redirect(w, r, "/file/"+idStr+"?success="+url.QueryEscape("Tag '"+cat+": "+val+"' copied from previous file"), http.StatusSeeOther) 940 return 941 } 942 } 943 http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) 944 return 945 } 946 947 catRows, _ := db.Query(` 948 SELECT DISTINCT c.name 949 FROM categories c 950 JOIN tags t ON t.category_id = c.id 951 JOIN file_tags ft ON ft.tag_id = t.id 952 ORDER BY c.name 953 `) 954 var cats []string 955 for catRows.Next() { 956 var c string 957 catRows.Scan(&c) 958 cats = append(cats, c) 959 } 960 catRows.Close() 961 962 pageData := buildPageDataWithIP(f.Filename, struct { 963 File File 964 Categories []string 965 EscapedFilename string 966 }{f, cats, url.PathEscape(f.Filename)}) 967 968 renderTemplate(w, "file.html", pageData) 969 } 970 971 func tagActionHandler(w http.ResponseWriter, r *http.Request, parts []string) { 972 fileID := parts[2] 973 cat := parts[4] 974 val := parts[5] 975 action := parts[6] 976 977 if action == "delete" && r.Method == http.MethodPost { 978 var tagID int 979 db.QueryRow(` 980 SELECT t.id 981 FROM tags t 982 JOIN categories c ON c.id=t.category_id 983 WHERE c.name=? AND t.value=?`, cat, val).Scan(&tagID) 984 if tagID != 0 { 985 db.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID) 986 } 987 } 988 http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) 989 } 990 991 func tagsHandler(w http.ResponseWriter, r *http.Request) { 992 pageData := buildPageData("All Tags", nil) 993 pageData.Data = pageData.Tags 994 renderTemplate(w, "tags.html", pageData) 995 } 996 997 func tagFilterHandler(w http.ResponseWriter, r *http.Request) { 998 pageStr := r.URL.Query().Get("page") 999 page := 1 1000 if pageStr != "" { 1001 if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { 1002 page = p 1003 } 1004 } 1005 1006 perPage := 50 1007 if config.ItemsPerPage != "" { 1008 if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 { 1009 perPage = pp 1010 } 1011 } 1012 1013 fullPath := strings.TrimPrefix(r.URL.Path, "/tag/") 1014 tagPairs := strings.Split(fullPath, "/and/tag/") 1015 1016 type filter struct { 1017 Category string 1018 Value string 1019 Values []string // Expanded values including aliases 1020 } 1021 1022 breadcrumbs := []Breadcrumb{ 1023 {Name: "Home", URL: "/"}, 1024 {Name: "Tags", URL: "/tags"}, // or wherever your tags overview page is 1025 } 1026 1027 var filters []filter 1028 currentPath := "/tag" 1029 1030 for i, pair := range tagPairs { 1031 parts := strings.Split(pair, "/") 1032 if len(parts) != 2 { 1033 renderError(w, "Invalid tag filter path", http.StatusBadRequest) 1034 return 1035 } 1036 1037 f := filter{ 1038 Category: parts[0], 1039 Value: parts[1], 1040 } 1041 1042 // Expand with aliases 1043 if parts[1] != "unassigned" { 1044 f.Values = expandTagWithAliases(parts[0], parts[1]) 1045 } 1046 1047 filters = append(filters, f) 1048 1049 // Build breadcrumb path incrementally 1050 if i == 0 { 1051 currentPath += "/" + parts[0] + "/" + parts[1] 1052 } else { 1053 currentPath += "/and/tag/" + parts[0] + "/" + parts[1] 1054 } 1055 1056 // Add category breadcrumb (only if it's the first occurrence) 1057 categoryExists := false 1058 for _, bc := range breadcrumbs { 1059 if bc.Name == parts[0] { 1060 categoryExists = true 1061 break 1062 } 1063 } 1064 if !categoryExists { 1065 breadcrumbs = append(breadcrumbs, Breadcrumb{ 1066 Name: strings.Title(parts[0]), 1067 URL: "/tags#tag-" + parts[0], 1068 }) 1069 } 1070 1071 // Add value breadcrumb 1072 breadcrumbs = append(breadcrumbs, Breadcrumb{ 1073 Name: strings.Title(parts[1]), 1074 URL: currentPath, 1075 }) 1076 } 1077 1078 // Build count query 1079 countQuery := `SELECT COUNT(DISTINCT f.id) FROM files f WHERE 1=1` 1080 countArgs := []interface{}{} 1081 1082 for _, f := range filters { 1083 if f.Value == "unassigned" { 1084 countQuery += ` 1085 AND NOT EXISTS ( 1086 SELECT 1 1087 FROM file_tags ft 1088 JOIN tags t ON ft.tag_id = t.id 1089 JOIN categories c ON c.id = t.category_id 1090 WHERE ft.file_id = f.id AND c.name = ? 1091 )` 1092 countArgs = append(countArgs, f.Category) 1093 } else { 1094 // Build OR clause for aliases 1095 placeholders := make([]string, len(f.Values)) 1096 for i := range f.Values { 1097 placeholders[i] = "?" 1098 } 1099 1100 countQuery += fmt.Sprintf(` 1101 AND EXISTS ( 1102 SELECT 1 1103 FROM file_tags ft 1104 JOIN tags t ON ft.tag_id = t.id 1105 JOIN categories c ON c.id = t.category_id 1106 WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s) 1107 )`, strings.Join(placeholders, ",")) 1108 1109 countArgs = append(countArgs, f.Category) 1110 for _, v := range f.Values { 1111 countArgs = append(countArgs, v) 1112 } 1113 } 1114 } 1115 1116 var total int 1117 err := db.QueryRow(countQuery, countArgs...).Scan(&total) 1118 if err != nil { 1119 renderError(w, "Failed to count files", http.StatusInternalServerError) 1120 return 1121 } 1122 1123 // Build main query with pagination 1124 query := `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f WHERE 1=1` 1125 args := []interface{}{} 1126 1127 for _, f := range filters { 1128 if f.Value == "unassigned" { 1129 query += ` 1130 AND NOT EXISTS ( 1131 SELECT 1 1132 FROM file_tags ft 1133 JOIN tags t ON ft.tag_id = t.id 1134 JOIN categories c ON c.id = t.category_id 1135 WHERE ft.file_id = f.id AND c.name = ? 1136 )` 1137 args = append(args, f.Category) 1138 } else { 1139 // Build OR clause for aliases 1140 placeholders := make([]string, len(f.Values)) 1141 for i := range f.Values { 1142 placeholders[i] = "?" 1143 } 1144 1145 query += fmt.Sprintf(` 1146 AND EXISTS ( 1147 SELECT 1 1148 FROM file_tags ft 1149 JOIN tags t ON ft.tag_id = t.id 1150 JOIN categories c ON c.id = t.category_id 1151 WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s) 1152 )`, strings.Join(placeholders, ",")) 1153 1154 args = append(args, f.Category) 1155 for _, v := range f.Values { 1156 args = append(args, v) 1157 } 1158 } 1159 } 1160 1161 offset := (page - 1) * perPage 1162 query += ` ORDER BY f.id DESC LIMIT ? OFFSET ?` 1163 args = append(args, perPage, offset) 1164 1165 files, err := queryFilesWithTags(query, args...) 1166 if err != nil { 1167 renderError(w, "Failed to fetch files", http.StatusInternalServerError) 1168 return 1169 } 1170 1171 var titleParts []string 1172 for _, f := range filters { 1173 titleParts = append(titleParts, fmt.Sprintf("%s: %s", f.Category, f.Value)) 1174 } 1175 title := "Tagged: " + strings.Join(titleParts, ", ") 1176 1177 pageData := buildPageDataWithPagination(title, ListData{ 1178 Tagged: files, 1179 Untagged: nil, 1180 Breadcrumbs: []Breadcrumb{}, // Empty here 1181 }, page, total, perPage) 1182 pageData.Breadcrumbs = breadcrumbs // Set at PageData level 1183 1184 renderTemplate(w, "list.html", pageData) 1185 } 1186 1187 func loadConfig() error { 1188 config = Config{ 1189 DatabasePath: "./database.db", 1190 UploadDir: "uploads", 1191 ServerPort: ":8080", 1192 InstanceName: "Taggart", 1193 GallerySize: "400px", 1194 ItemsPerPage: "100", 1195 TagAliases: []TagAliasGroup{}, 1196 } 1197 1198 if data, err := ioutil.ReadFile("config.json"); err == nil { 1199 if err := json.Unmarshal(data, &config); err != nil { 1200 return err 1201 } 1202 } 1203 1204 return os.MkdirAll(config.UploadDir, 0755) 1205 } 1206 1207 func saveConfig() error { 1208 data, err := json.MarshalIndent(config, "", " ") 1209 if err != nil { 1210 return err 1211 } 1212 return ioutil.WriteFile("config.json", data, 0644) 1213 } 1214 1215 func validateConfig(newConfig Config) error { 1216 if newConfig.DatabasePath == "" { 1217 return fmt.Errorf("database path cannot be empty") 1218 } 1219 1220 if newConfig.UploadDir == "" { 1221 return fmt.Errorf("upload directory cannot be empty") 1222 } 1223 1224 if newConfig.ServerPort == "" || !strings.HasPrefix(newConfig.ServerPort, ":") { 1225 return fmt.Errorf("server port must be in format ':8080'") 1226 } 1227 1228 if err := os.MkdirAll(newConfig.UploadDir, 0755); err != nil { 1229 return fmt.Errorf("cannot create upload directory: %v", err) 1230 } 1231 1232 return nil 1233 } 1234 1235 func adminHandler(w http.ResponseWriter, r *http.Request) { 1236 // Get orphaned files 1237 orphans, _ := getOrphanedFiles(config.UploadDir) 1238 1239 // Get video files for thumbnails 1240 missingThumbnails, _ := getMissingThumbnailVideos() 1241 1242 switch r.Method { 1243 case http.MethodPost: 1244 action := r.FormValue("action") 1245 1246 switch action { 1247 case "save", "": 1248 handleSaveSettings(w, r, orphans, missingThumbnails) 1249 return 1250 1251 case "backup": 1252 err := backupDatabase(config.DatabasePath) 1253 pageData := buildPageData("Admin", struct { 1254 Config Config 1255 Error string 1256 Success string 1257 Orphans []string 1258 MissingThumbnails []VideoFile 1259 }{ 1260 Config: config, 1261 Error: errorString(err), 1262 Success: successString(err, "Database backup created successfully!"), 1263 Orphans: orphans, 1264 MissingThumbnails: missingThumbnails, 1265 }) 1266 renderTemplate(w, "admin.html", pageData) 1267 return 1268 1269 case "vacuum": 1270 err := vacuumDatabase(config.DatabasePath) 1271 pageData := buildPageData("Admin", struct { 1272 Config Config 1273 Error string 1274 Success string 1275 Orphans []string 1276 MissingThumbnails []VideoFile 1277 }{ 1278 Config: config, 1279 Error: errorString(err), 1280 Success: successString(err, "Database vacuum completed successfully!"), 1281 Orphans: orphans, 1282 MissingThumbnails: missingThumbnails, 1283 }) 1284 renderTemplate(w, "admin.html", pageData) 1285 return 1286 1287 case "save_aliases": 1288 handleSaveAliases(w, r, orphans, missingThumbnails) 1289 return 1290 } 1291 1292 default: 1293 pageData := buildPageData("Admin", struct { 1294 Config Config 1295 Error string 1296 Success string 1297 Orphans []string 1298 MissingThumbnails []VideoFile 1299 }{ 1300 Config: config, 1301 Error: "", 1302 Success: "", 1303 Orphans: orphans, 1304 MissingThumbnails: missingThumbnails, 1305 }) 1306 renderTemplate(w, "admin.html", pageData) 1307 } 1308 } 1309 1310 func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphans []string, missingThumbnails []VideoFile) { 1311 aliasesJSON := r.FormValue("aliases_json") 1312 1313 var aliases []TagAliasGroup 1314 if aliasesJSON != "" { 1315 if err := json.Unmarshal([]byte(aliasesJSON), &aliases); err != nil { 1316 pageData := buildPageData("Admin", struct { 1317 Config Config 1318 Error string 1319 Success string 1320 Orphans []string 1321 MissingThumbnails []VideoFile 1322 }{ 1323 Config: config, 1324 Error: "Invalid aliases JSON: " + err.Error(), 1325 Success: "", 1326 Orphans: orphans, 1327 MissingThumbnails: missingThumbnails, 1328 }) 1329 renderTemplate(w, "admin.html", pageData) 1330 return 1331 } 1332 } 1333 1334 config.TagAliases = aliases 1335 1336 if err := saveConfig(); err != nil { 1337 pageData := buildPageData("Admin", struct { 1338 Config Config 1339 Error string 1340 Success string 1341 Orphans []string 1342 MissingThumbnails []VideoFile 1343 }{ 1344 Config: config, 1345 Error: "Failed to save configuration: " + err.Error(), 1346 Success: "", 1347 Orphans: orphans, 1348 MissingThumbnails: missingThumbnails, 1349 }) 1350 renderTemplate(w, "admin.html", pageData) 1351 return 1352 } 1353 1354 pageData := buildPageData("Admin", struct { 1355 Config Config 1356 Error string 1357 Success string 1358 Orphans []string 1359 MissingThumbnails []VideoFile 1360 }{ 1361 Config: config, 1362 Error: "", 1363 Success: "Tag aliases saved successfully!", 1364 Orphans: orphans, 1365 MissingThumbnails: missingThumbnails, 1366 }) 1367 renderTemplate(w, "admin.html", pageData) 1368 } 1369 1370 func handleSaveSettings(w http.ResponseWriter, r *http.Request, orphans []string, missingThumbnails []VideoFile) { 1371 newConfig := Config{ 1372 DatabasePath: strings.TrimSpace(r.FormValue("database_path")), 1373 UploadDir: strings.TrimSpace(r.FormValue("upload_dir")), 1374 ServerPort: strings.TrimSpace(r.FormValue("server_port")), 1375 InstanceName: strings.TrimSpace(r.FormValue("instance_name")), 1376 GallerySize: strings.TrimSpace(r.FormValue("gallery_size")), 1377 ItemsPerPage: strings.TrimSpace(r.FormValue("items_per_page")), 1378 TagAliases: config.TagAliases, // Preserve existing aliases 1379 } 1380 1381 if err := validateConfig(newConfig); err != nil { 1382 pageData := buildPageData("Admin", struct { 1383 Config Config 1384 Error string 1385 Success string 1386 Orphans []string 1387 MissingThumbnails []VideoFile 1388 }{ 1389 Config: config, 1390 Error: err.Error(), 1391 Success: "", 1392 Orphans: orphans, 1393 MissingThumbnails: missingThumbnails, 1394 }) 1395 renderTemplate(w, "admin.html", pageData) 1396 return 1397 } 1398 1399 needsRestart := (newConfig.DatabasePath != config.DatabasePath || 1400 newConfig.ServerPort != config.ServerPort) 1401 1402 config = newConfig 1403 if err := saveConfig(); err != nil { 1404 pageData := buildPageData("Admin", struct { 1405 Config Config 1406 Error string 1407 Success string 1408 Orphans []string 1409 MissingThumbnails []VideoFile 1410 }{ 1411 Config: config, 1412 Error: "Failed to save configuration: " + err.Error(), 1413 Success: "", 1414 Orphans: orphans, 1415 MissingThumbnails: missingThumbnails, 1416 }) 1417 renderTemplate(w, "admin.html", pageData) 1418 return 1419 } 1420 1421 var message string 1422 if needsRestart { 1423 message = "Settings saved successfully! Please restart the server for database/port changes to take effect." 1424 } else { 1425 message = "Settings saved successfully!" 1426 } 1427 1428 pageData := buildPageData("Admin", struct { 1429 Config Config 1430 Error string 1431 Success string 1432 Orphans []string 1433 MissingThumbnails []VideoFile 1434 }{ 1435 Config: config, 1436 Error: "", 1437 Success: message, 1438 Orphans: orphans, 1439 MissingThumbnails: missingThumbnails, 1440 }) 1441 renderTemplate(w, "admin.html", pageData) 1442 } 1443 1444 1445 func errorString(err error) string { 1446 if err != nil { 1447 return err.Error() 1448 } 1449 return "" 1450 } 1451 1452 func successString(err error, msg string) string { 1453 if err == nil { 1454 return msg 1455 } 1456 return "" 1457 } 1458 1459 func backupDatabase(dbPath string) error { 1460 if dbPath == "" { 1461 return fmt.Errorf("database path not configured") 1462 } 1463 1464 timestamp := time.Now().Format("20060102_150405") 1465 backupPath := fmt.Sprintf("%s_backup_%s.db", strings.TrimSuffix(dbPath, filepath.Ext(dbPath)), timestamp) 1466 1467 input, err := os.Open(dbPath) 1468 if err != nil { 1469 return fmt.Errorf("failed to open database: %w", err) 1470 } 1471 defer input.Close() 1472 1473 output, err := os.Create(backupPath) 1474 if err != nil { 1475 return fmt.Errorf("failed to create backup file: %w", err) 1476 } 1477 defer output.Close() 1478 1479 if _, err := io.Copy(output, input); err != nil { 1480 return fmt.Errorf("failed to copy database: %w", err) 1481 } 1482 1483 return nil 1484 } 1485 1486 func vacuumDatabase(dbPath string) error { 1487 db, err := sql.Open("sqlite3", dbPath) 1488 if err != nil { 1489 return fmt.Errorf("failed to open database: %w", err) 1490 } 1491 defer db.Close() 1492 1493 _, err = db.Exec("VACUUM;") 1494 if err != nil { 1495 return fmt.Errorf("VACUUM failed: %w", err) 1496 } 1497 1498 return nil 1499 } 1500 1501 func ytdlpHandler(w http.ResponseWriter, r *http.Request) { 1502 if r.Method != http.MethodPost { 1503 http.Redirect(w, r, "/upload", http.StatusSeeOther) 1504 return 1505 } 1506 1507 videoURL := r.FormValue("url") 1508 if videoURL == "" { 1509 renderError(w, "No URL provided", http.StatusBadRequest) 1510 return 1511 } 1512 1513 outTemplate := filepath.Join(config.UploadDir, "%(title)s.%(ext)s") 1514 filenameCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, "--get-filename", videoURL) 1515 filenameBytes, err := filenameCmd.Output() 1516 if err != nil { 1517 renderError(w, fmt.Sprintf("Failed to get filename: %v", err), http.StatusInternalServerError) 1518 return 1519 } 1520 expectedFullPath := strings.TrimSpace(string(filenameBytes)) 1521 expectedFilename := filepath.Base(expectedFullPath) 1522 1523 finalFilename, finalPath, err := checkFileConflictStrict(expectedFilename) 1524 if err != nil { 1525 renderError(w, err.Error(), http.StatusConflict) 1526 return 1527 } 1528 1529 downloadCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, videoURL) 1530 downloadCmd.Stdout = os.Stdout 1531 downloadCmd.Stderr = os.Stderr 1532 if err := downloadCmd.Run(); err != nil { 1533 renderError(w, fmt.Sprintf("Failed to download video: %v", err), http.StatusInternalServerError) 1534 return 1535 } 1536 1537 if expectedFullPath != finalPath { 1538 if err := os.Rename(expectedFullPath, finalPath); err != nil { 1539 renderError(w, fmt.Sprintf("Failed to move downloaded file: %v", err), http.StatusInternalServerError) 1540 return 1541 } 1542 } 1543 1544 tempPath := finalPath + ".tmp" 1545 if err := os.Rename(finalPath, tempPath); err != nil { 1546 renderError(w, fmt.Sprintf("Failed to create temp file for processing: %v", err), http.StatusInternalServerError) 1547 return 1548 } 1549 1550 processedPath, warningMsg, err := processVideoFile(tempPath, finalPath) 1551 if err != nil { 1552 os.Remove(tempPath) 1553 renderError(w, fmt.Sprintf("Failed to process video: %v", err), http.StatusInternalServerError) 1554 return 1555 } 1556 1557 id, err := saveFileToDatabase(finalFilename, processedPath) 1558 if err != nil { 1559 os.Remove(processedPath) 1560 renderError(w, err.Error(), http.StatusInternalServerError) 1561 return 1562 } 1563 1564 redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) 1565 } 1566 1567 func parseFileIDRange(rangeStr string) ([]int, error) { 1568 var fileIDs []int 1569 parts := strings.Split(rangeStr, ",") 1570 1571 for _, part := range parts { 1572 part = strings.TrimSpace(part) 1573 if part == "" { 1574 continue 1575 } 1576 1577 if strings.Contains(part, "-") { 1578 rangeParts := strings.Split(part, "-") 1579 if len(rangeParts) != 2 { 1580 return nil, fmt.Errorf("invalid range format: %s", part) 1581 } 1582 1583 start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0])) 1584 if err != nil { 1585 return nil, fmt.Errorf("invalid start ID in range %s: %v", part, err) 1586 } 1587 1588 end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1])) 1589 if err != nil { 1590 return nil, fmt.Errorf("invalid end ID in range %s: %v", part, err) 1591 } 1592 1593 if start > end { 1594 return nil, fmt.Errorf("invalid range %s: start must be <= end", part) 1595 } 1596 1597 for i := start; i <= end; i++ { 1598 fileIDs = append(fileIDs, i) 1599 } 1600 } else { 1601 id, err := strconv.Atoi(part) 1602 if err != nil { 1603 return nil, fmt.Errorf("invalid file ID: %s", part) 1604 } 1605 fileIDs = append(fileIDs, id) 1606 } 1607 } 1608 1609 uniqueIDs := make(map[int]bool) 1610 var result []int 1611 for _, id := range fileIDs { 1612 if !uniqueIDs[id] { 1613 uniqueIDs[id] = true 1614 result = append(result, id) 1615 } 1616 } 1617 1618 return result, nil 1619 } 1620 1621 func validateFileIDs(fileIDs []int) ([]File, error) { 1622 if len(fileIDs) == 0 { 1623 return nil, fmt.Errorf("no file IDs provided") 1624 } 1625 1626 placeholders := make([]string, len(fileIDs)) 1627 args := make([]interface{}, len(fileIDs)) 1628 for i, id := range fileIDs { 1629 placeholders[i] = "?" 1630 args[i] = id 1631 } 1632 1633 query := fmt.Sprintf("SELECT id, filename, path FROM files WHERE id IN (%s) ORDER BY id", 1634 strings.Join(placeholders, ",")) 1635 1636 rows, err := db.Query(query, args...) 1637 if err != nil { 1638 return nil, fmt.Errorf("database error: %v", err) 1639 } 1640 defer rows.Close() 1641 1642 var files []File 1643 foundIDs := make(map[int]bool) 1644 1645 for rows.Next() { 1646 var f File 1647 err := rows.Scan(&f.ID, &f.Filename, &f.Path) 1648 if err != nil { 1649 return nil, fmt.Errorf("error scanning file: %v", err) 1650 } 1651 files = append(files, f) 1652 foundIDs[f.ID] = true 1653 } 1654 1655 var missingIDs []int 1656 for _, id := range fileIDs { 1657 if !foundIDs[id] { 1658 missingIDs = append(missingIDs, id) 1659 } 1660 } 1661 1662 if len(missingIDs) > 0 { 1663 return files, fmt.Errorf("file IDs not found: %v", missingIDs) 1664 } 1665 1666 return files, nil 1667 } 1668 1669 func applyBulkTagOperations(fileIDs []int, category, value, operation string) error { 1670 category = strings.TrimSpace(category) 1671 value = strings.TrimSpace(value) 1672 if category == "" { 1673 return fmt.Errorf("category cannot be empty") 1674 } 1675 1676 if operation == "add" && value == "" { 1677 return fmt.Errorf("value cannot be empty when adding tags") 1678 } 1679 1680 tx, err := db.Begin() 1681 if err != nil { 1682 return fmt.Errorf("failed to start transaction: %v", err) 1683 } 1684 defer tx.Rollback() 1685 1686 var catID int 1687 err = tx.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID) 1688 if err != nil && err != sql.ErrNoRows { 1689 return fmt.Errorf("failed to query category: %v", err) 1690 } 1691 1692 if catID == 0 { 1693 if operation == "remove" { 1694 return fmt.Errorf("cannot remove non-existent category: %s", category) 1695 } 1696 res, err := tx.Exec("INSERT INTO categories(name) VALUES(?)", category) 1697 if err != nil { 1698 return fmt.Errorf("failed to create category: %v", err) 1699 } 1700 cid, _ := res.LastInsertId() 1701 catID = int(cid) 1702 } 1703 1704 var tagID int 1705 if value != "" { 1706 err = tx.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID) 1707 if err != nil && err != sql.ErrNoRows { 1708 return fmt.Errorf("failed to query tag: %v", err) 1709 } 1710 1711 if tagID == 0 { 1712 if operation == "remove" { 1713 return fmt.Errorf("cannot remove non-existent tag: %s=%s", category, value) 1714 } 1715 res, err := tx.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value) 1716 if err != nil { 1717 return fmt.Errorf("failed to create tag: %v", err) 1718 } 1719 tid, _ := res.LastInsertId() 1720 tagID = int(tid) 1721 } 1722 } 1723 1724 for _, fileID := range fileIDs { 1725 if operation == "add" { 1726 _, err = tx.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", fileID, tagID) 1727 } else if operation == "remove" { 1728 if value != "" { 1729 _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID) 1730 } else { 1731 _, 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) 1732 } 1733 } else { 1734 return fmt.Errorf("invalid operation: %s (must be 'add' or 'remove')", operation) 1735 } 1736 if err != nil { 1737 return fmt.Errorf("failed to %s tag for file %d: %v", operation, fileID, err) 1738 } 1739 } 1740 1741 return tx.Commit() 1742 } 1743 1744 type BulkTagFormData struct { 1745 Categories []string 1746 RecentFiles []File 1747 Error string 1748 Success string 1749 FormData struct { 1750 FileRange string 1751 Category string 1752 Value string 1753 Operation string 1754 TagQuery string 1755 SelectionMode string 1756 } 1757 } 1758 1759 func getBulkTagFormData() BulkTagFormData { 1760 catRows, _ := db.Query("SELECT name FROM categories ORDER BY name") 1761 var cats []string 1762 for catRows.Next() { 1763 var c string 1764 catRows.Scan(&c) 1765 cats = append(cats, c) 1766 } 1767 catRows.Close() 1768 1769 recentRows, _ := db.Query("SELECT id, filename FROM files ORDER BY id DESC LIMIT 20") 1770 var recentFiles []File 1771 for recentRows.Next() { 1772 var f File 1773 recentRows.Scan(&f.ID, &f.Filename) 1774 recentFiles = append(recentFiles, f) 1775 } 1776 recentRows.Close() 1777 1778 return BulkTagFormData{ 1779 Categories: cats, 1780 RecentFiles: recentFiles, 1781 FormData: struct { 1782 FileRange string 1783 Category string 1784 Value string 1785 Operation string 1786 TagQuery string 1787 SelectionMode string 1788 }{Operation: "add"}, 1789 } 1790 } 1791 1792 func bulkTagHandler(w http.ResponseWriter, r *http.Request) { 1793 if r.Method == http.MethodGet { 1794 formData := getBulkTagFormData() 1795 pageData := buildPageData("Bulk Tag Editor", formData) 1796 renderTemplate(w, "bulk-tag.html", pageData) 1797 return 1798 } 1799 if r.Method == http.MethodPost { 1800 rangeStr := strings.TrimSpace(r.FormValue("file_range")) 1801 tagQuery := strings.TrimSpace(r.FormValue("tag_query")) 1802 selectionMode := r.FormValue("selection_mode") 1803 category := strings.TrimSpace(r.FormValue("category")) 1804 value := strings.TrimSpace(r.FormValue("value")) 1805 operation := r.FormValue("operation") 1806 1807 formData := getBulkTagFormData() 1808 formData.FormData.FileRange = rangeStr 1809 formData.FormData.TagQuery = tagQuery 1810 formData.FormData.SelectionMode = selectionMode 1811 formData.FormData.Category = category 1812 formData.FormData.Value = value 1813 formData.FormData.Operation = operation 1814 1815 createErrorResponse := func(errorMsg string) { 1816 formData.Error = errorMsg 1817 pageData := buildPageData("Bulk Tag Editor", formData) 1818 renderTemplate(w, "bulk-tag.html", pageData) 1819 } 1820 1821 // Validate selection mode 1822 if selectionMode == "" { 1823 selectionMode = "range" // default 1824 } 1825 1826 // Validate inputs based on selection mode 1827 if selectionMode == "range" && rangeStr == "" { 1828 createErrorResponse("File range cannot be empty") 1829 return 1830 } 1831 if selectionMode == "tags" && tagQuery == "" { 1832 createErrorResponse("Tag query cannot be empty") 1833 return 1834 } 1835 if category == "" { 1836 createErrorResponse("Category cannot be empty") 1837 return 1838 } 1839 if operation == "add" && value == "" { 1840 createErrorResponse("Value cannot be empty when adding tags") 1841 return 1842 } 1843 1844 // Get file IDs based on selection mode 1845 var fileIDs []int 1846 var err error 1847 1848 if selectionMode == "range" { 1849 fileIDs, err = parseFileIDRange(rangeStr) 1850 if err != nil { 1851 createErrorResponse(fmt.Sprintf("Invalid file range: %v", err)) 1852 return 1853 } 1854 } else if selectionMode == "tags" { 1855 fileIDs, err = getFileIDsFromTagQuery(tagQuery) 1856 if err != nil { 1857 createErrorResponse(fmt.Sprintf("Tag query error: %v", err)) 1858 return 1859 } 1860 if len(fileIDs) == 0 { 1861 createErrorResponse("No files match the tag query") 1862 return 1863 } 1864 } else { 1865 createErrorResponse("Invalid selection mode") 1866 return 1867 } 1868 1869 validFiles, err := validateFileIDs(fileIDs) 1870 if err != nil { 1871 createErrorResponse(fmt.Sprintf("File validation error: %v", err)) 1872 return 1873 } 1874 1875 err = applyBulkTagOperations(fileIDs, category, value, operation) 1876 if err != nil { 1877 createErrorResponse(fmt.Sprintf("Tag operation failed: %v", err)) 1878 return 1879 } 1880 1881 // Build success message 1882 var successMsg string 1883 var selectionDesc string 1884 if selectionMode == "range" { 1885 selectionDesc = fmt.Sprintf("file range '%s'", rangeStr) 1886 } else { 1887 selectionDesc = fmt.Sprintf("tag query '%s'", tagQuery) 1888 } 1889 1890 if operation == "add" { 1891 successMsg = fmt.Sprintf("Tag '%s: %s' added to %d files matching %s", 1892 category, value, len(validFiles), selectionDesc) 1893 } else { 1894 if value != "" { 1895 successMsg = fmt.Sprintf("Tag '%s: %s' removed from %d files matching %s", 1896 category, value, len(validFiles), selectionDesc) 1897 } else { 1898 successMsg = fmt.Sprintf("All '%s' category tags removed from %d files matching %s", 1899 category, len(validFiles), selectionDesc) 1900 } 1901 } 1902 1903 // Add file list 1904 var filenames []string 1905 for _, f := range validFiles { 1906 filenames = append(filenames, f.Filename) 1907 } 1908 if len(filenames) <= 5 { 1909 successMsg += fmt.Sprintf(": %s", strings.Join(filenames, ", ")) 1910 } else { 1911 successMsg += fmt.Sprintf(": %s and %d more", strings.Join(filenames[:5], ", "), len(filenames)-5) 1912 } 1913 1914 formData.Success = successMsg 1915 pageData := buildPageData("Bulk Tag Editor", formData) 1916 renderTemplate(w, "bulk-tag.html", pageData) 1917 return 1918 } 1919 renderError(w, "Method not allowed", http.StatusMethodNotAllowed) 1920 } 1921 1922 // getFileIDsFromTagQuery parses a tag query and returns matching file IDs 1923 // Supports queries like: 1924 // - "colour:blue" (single tag) 1925 // - "colour:blue,size:large" (multiple tags - AND logic) 1926 // - "colour:blue OR colour:red" (OR logic) 1927 func getFileIDsFromTagQuery(query string) ([]int, error) { 1928 query = strings.TrimSpace(query) 1929 if query == "" { 1930 return nil, fmt.Errorf("empty query") 1931 } 1932 1933 // Check if query contains OR operator 1934 if strings.Contains(strings.ToUpper(query), " OR ") { 1935 return getFileIDsFromORQuery(query) 1936 } 1937 1938 // Otherwise treat as AND query (comma-separated or single tag) 1939 return getFileIDsFromANDQuery(query) 1940 } 1941 1942 // getFileIDsFromANDQuery handles comma-separated tags (AND logic) 1943 func getFileIDsFromANDQuery(query string) ([]int, error) { 1944 tagPairs := strings.Split(query, ",") 1945 var tags []TagPair 1946 1947 for _, pair := range tagPairs { 1948 pair = strings.TrimSpace(pair) 1949 if pair == "" { 1950 continue 1951 } 1952 1953 parts := strings.SplitN(pair, ":", 2) 1954 if len(parts) != 2 { 1955 return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair) 1956 } 1957 1958 tags = append(tags, TagPair{ 1959 Category: strings.TrimSpace(parts[0]), 1960 Value: strings.TrimSpace(parts[1]), 1961 }) 1962 } 1963 1964 if len(tags) == 0 { 1965 return nil, fmt.Errorf("no valid tags found in query") 1966 } 1967 1968 // Query database for files matching ALL tags 1969 return findFilesWithAllTags(tags) 1970 } 1971 1972 // getFileIDsFromORQuery handles OR-separated tags 1973 func getFileIDsFromORQuery(query string) ([]int, error) { 1974 tagPairs := strings.Split(strings.ToUpper(query), " OR ") 1975 var tags []TagPair 1976 1977 for _, pair := range tagPairs { 1978 pair = strings.TrimSpace(pair) 1979 if pair == "" { 1980 continue 1981 } 1982 1983 parts := strings.SplitN(pair, ":", 2) 1984 if len(parts) != 2 { 1985 return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair) 1986 } 1987 1988 tags = append(tags, TagPair{ 1989 Category: strings.TrimSpace(parts[0]), 1990 Value: strings.TrimSpace(parts[1]), 1991 }) 1992 } 1993 1994 if len(tags) == 0 { 1995 return nil, fmt.Errorf("no valid tags found in query") 1996 } 1997 1998 // Query database for files matching ANY tag 1999 return findFilesWithAnyTag(tags) 2000 } 2001 2002 // TagPair represents a category-value pair 2003 type TagPair struct { 2004 Category string 2005 Value string 2006 } 2007 2008 // findFilesWithAllTags returns file IDs that have ALL the specified tags 2009 func findFilesWithAllTags(tags []TagPair) ([]int, error) { 2010 if len(tags) == 0 { 2011 return nil, fmt.Errorf("no tags specified") 2012 } 2013 2014 // Build query with subqueries for each tag 2015 query := ` 2016 SELECT f.id 2017 FROM files f 2018 WHERE ` 2019 2020 var conditions []string 2021 var args []interface{} 2022 argIndex := 1 2023 2024 for _, tag := range tags { 2025 conditions = append(conditions, fmt.Sprintf(` 2026 EXISTS ( 2027 SELECT 1 FROM file_tags ft 2028 JOIN tags t ON ft.tag_id = t.id 2029 JOIN categories c ON t.category_id = c.id 2030 WHERE ft.file_id = f.id 2031 AND c.name = $%d 2032 AND t.value = $%d 2033 )`, argIndex, argIndex+1)) 2034 args = append(args, tag.Category, tag.Value) 2035 argIndex += 2 2036 } 2037 2038 query += strings.Join(conditions, " AND ") 2039 query += " ORDER BY f.id" 2040 2041 rows, err := db.Query(query, args...) 2042 if err != nil { 2043 return nil, fmt.Errorf("database query failed: %w", err) 2044 } 2045 defer rows.Close() 2046 2047 var fileIDs []int 2048 for rows.Next() { 2049 var id int 2050 if err := rows.Scan(&id); err != nil { 2051 return nil, fmt.Errorf("scan error: %w", err) 2052 } 2053 fileIDs = append(fileIDs, id) 2054 } 2055 2056 return fileIDs, rows.Err() 2057 } 2058 2059 // findFilesWithAnyTag returns file IDs that have ANY of the specified tags 2060 func findFilesWithAnyTag(tags []TagPair) ([]int, error) { 2061 if len(tags) == 0 { 2062 return nil, fmt.Errorf("no tags specified") 2063 } 2064 2065 // Build query with OR conditions 2066 query := ` 2067 SELECT DISTINCT f.id 2068 FROM files f 2069 INNER JOIN file_tags ft ON f.id = ft.file_id 2070 INNER JOIN tags t ON ft.tag_id = t.id 2071 INNER JOIN categories c ON t.category_id = c.id 2072 WHERE ` 2073 2074 var conditions []string 2075 var args []interface{} 2076 argIndex := 1 2077 2078 for _, tag := range tags { 2079 conditions = append(conditions, fmt.Sprintf( 2080 "(c.name = $%d AND t.value = $%d)", 2081 argIndex, argIndex+1)) 2082 args = append(args, tag.Category, tag.Value) 2083 argIndex += 2 2084 } 2085 2086 query += strings.Join(conditions, " OR ") 2087 query += " ORDER BY f.id" 2088 2089 rows, err := db.Query(query, args...) 2090 if err != nil { 2091 return nil, fmt.Errorf("database query failed: %w", err) 2092 } 2093 defer rows.Close() 2094 2095 var fileIDs []int 2096 for rows.Next() { 2097 var id int 2098 if err := rows.Scan(&id); err != nil { 2099 return nil, fmt.Errorf("scan error: %w", err) 2100 } 2101 fileIDs = append(fileIDs, id) 2102 } 2103 2104 return fileIDs, rows.Err() 2105 } 2106 2107 func sanitizeFilename(filename string) string { 2108 if filename == "" { 2109 return "file" 2110 } 2111 filename = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(filename, "/", "_"), "\\", "_"), "..", "_") 2112 if filename == "" { 2113 return "file" 2114 } 2115 return filename 2116 } 2117 2118 func detectVideoCodec(filePath string) (string, error) { 2119 cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", 2120 "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", filePath) 2121 out, err := cmd.Output() 2122 if err != nil { 2123 return "", fmt.Errorf("failed to probe video codec: %v", err) 2124 } 2125 return strings.TrimSpace(string(out)), nil 2126 } 2127 2128 func reencodeHEVCToH264(inputPath, outputPath string) error { 2129 cmd := exec.Command("ffmpeg", "-i", inputPath, 2130 "-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23", 2131 "-c:a", "aac", "-movflags", "+faststart", outputPath) 2132 cmd.Stderr = os.Stderr 2133 cmd.Stdout = os.Stdout 2134 return cmd.Run() 2135 } 2136 2137 func processVideoFile(tempPath, finalPath string) (string, string, error) { 2138 codec, err := detectVideoCodec(tempPath) 2139 if err != nil { 2140 return "", "", err 2141 } 2142 2143 if codec == "hevc" || codec == "h265" { 2144 warningMsg := "The video uses HEVC and has been re-encoded to H.264 for browser compatibility." 2145 if err := reencodeHEVCToH264(tempPath, finalPath); err != nil { 2146 return "", "", fmt.Errorf("failed to re-encode HEVC video: %v", err) 2147 } 2148 os.Remove(tempPath) 2149 return finalPath, warningMsg, nil 2150 } 2151 2152 if err := os.Rename(tempPath, finalPath); err != nil { 2153 return "", "", fmt.Errorf("failed to move file: %v", err) 2154 } 2155 2156 ext := strings.ToLower(filepath.Ext(finalPath)) 2157 if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".mkv" || ext == ".webm" || ext == ".m4v" { 2158 if err := generateThumbnail(finalPath, config.UploadDir, filepath.Base(finalPath)); err != nil { 2159 log.Printf("Warning: could not generate thumbnail: %v", err) 2160 } 2161 } 2162 2163 return finalPath, "", nil 2164 } 2165 2166 func saveFileToDatabase(filename, path string) (int64, error) { 2167 res, err := db.Exec("INSERT INTO files (filename, path, description) VALUES (?, ?, '')", filename, path) 2168 if err != nil { 2169 return 0, fmt.Errorf("failed to save file to database: %v", err) 2170 } 2171 id, err := res.LastInsertId() 2172 if err != nil { 2173 return 0, fmt.Errorf("failed to get inserted ID: %v", err) 2174 } 2175 return id, nil 2176 } 2177 2178 func getFilesOnDisk(uploadDir string) ([]string, error) { 2179 entries, err := os.ReadDir(uploadDir) 2180 if err != nil { 2181 return nil, err 2182 } 2183 var files []string 2184 for _, e := range entries { 2185 if !e.IsDir() { 2186 files = append(files, e.Name()) 2187 } 2188 } 2189 return files, nil 2190 } 2191 2192 func getFilesInDB() (map[string]bool, error) { 2193 rows, err := db.Query(`SELECT filename FROM files`) 2194 if err != nil { 2195 return nil, err 2196 } 2197 defer rows.Close() 2198 2199 fileMap := make(map[string]bool) 2200 for rows.Next() { 2201 var name string 2202 rows.Scan(&name) 2203 fileMap[name] = true 2204 } 2205 return fileMap, nil 2206 } 2207 2208 func getOrphanedFiles(uploadDir string) ([]string, error) { 2209 diskFiles, err := getFilesOnDisk(uploadDir) 2210 if err != nil { 2211 return nil, err 2212 } 2213 2214 dbFiles, err := getFilesInDB() 2215 if err != nil { 2216 return nil, err 2217 } 2218 2219 var orphans []string 2220 for _, f := range diskFiles { 2221 if !dbFiles[f] { 2222 orphans = append(orphans, f) 2223 } 2224 } 2225 return orphans, nil 2226 } 2227 2228 func orphansHandler(w http.ResponseWriter, r *http.Request) { 2229 orphans, err := getOrphanedFiles(config.UploadDir) 2230 if err != nil { 2231 renderError(w, "Error reading orphaned files", http.StatusInternalServerError) 2232 return 2233 } 2234 2235 pageData := buildPageData("Orphaned Files", orphans) 2236 renderTemplate(w, "orphans.html", pageData) 2237 } 2238 2239 func generateThumbnailAtTime(videoPath, uploadDir, filename, timestamp string) error { 2240 thumbDir := filepath.Join(uploadDir, "thumbnails") 2241 if err := os.MkdirAll(thumbDir, 0755); err != nil { 2242 return fmt.Errorf("failed to create thumbnails directory: %v", err) 2243 } 2244 2245 thumbPath := filepath.Join(thumbDir, filename+".jpg") 2246 2247 cmd := exec.Command("ffmpeg", "-y", "-ss", timestamp, "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath) 2248 cmd.Stdout = os.Stdout 2249 cmd.Stderr = os.Stderr 2250 2251 if err := cmd.Run(); err != nil { 2252 return fmt.Errorf("failed to generate thumbnail at %s: %v", timestamp, err) 2253 } 2254 2255 return nil 2256 } 2257 2258 func getVideoFiles() ([]VideoFile, error) { 2259 videoExts := []string{".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"} 2260 2261 rows, err := db.Query(`SELECT id, filename, path FROM files ORDER BY id DESC`) 2262 if err != nil { 2263 return nil, err 2264 } 2265 defer rows.Close() 2266 2267 var videos []VideoFile 2268 for rows.Next() { 2269 var v VideoFile 2270 if err := rows.Scan(&v.ID, &v.Filename, &v.Path); err != nil { 2271 continue 2272 } 2273 2274 // Check if it's a video file 2275 isVideo := false 2276 ext := strings.ToLower(filepath.Ext(v.Filename)) 2277 for _, vidExt := range videoExts { 2278 if ext == vidExt { 2279 isVideo = true 2280 break 2281 } 2282 } 2283 2284 if !isVideo { 2285 continue 2286 } 2287 2288 v.EscapedFilename = url.PathEscape(v.Filename) 2289 thumbPath := filepath.Join(config.UploadDir, "thumbnails", v.Filename+".jpg") 2290 v.ThumbnailPath = "/uploads/thumbnails/" + v.EscapedFilename + ".jpg" 2291 2292 if _, err := os.Stat(thumbPath); err == nil { 2293 v.HasThumbnail = true 2294 } 2295 2296 videos = append(videos, v) 2297 } 2298 2299 return videos, nil 2300 } 2301 2302 func getMissingThumbnailVideos() ([]VideoFile, error) { 2303 allVideos, err := getVideoFiles() 2304 if err != nil { 2305 return nil, err 2306 } 2307 2308 var missing []VideoFile 2309 for _, v := range allVideos { 2310 if !v.HasThumbnail { 2311 missing = append(missing, v) 2312 } 2313 } 2314 2315 return missing, nil 2316 } 2317 2318 func thumbnailsHandler(w http.ResponseWriter, r *http.Request) { 2319 allVideos, err := getVideoFiles() 2320 if err != nil { 2321 renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError) 2322 return 2323 } 2324 2325 missing, err := getMissingThumbnailVideos() 2326 if err != nil { 2327 renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError) 2328 return 2329 } 2330 2331 pageData := buildPageData("Thumbnail Management", struct { 2332 AllVideos []VideoFile 2333 MissingThumbnails []VideoFile 2334 Error string 2335 Success string 2336 }{ 2337 AllVideos: allVideos, 2338 MissingThumbnails: missing, 2339 Error: r.URL.Query().Get("error"), 2340 Success: r.URL.Query().Get("success"), 2341 }) 2342 2343 renderTemplate(w, "thumbnails.html", pageData) 2344 } 2345 2346 func generateThumbnailHandler(w http.ResponseWriter, r *http.Request) { 2347 if r.Method != http.MethodPost { 2348 http.Redirect(w, r, "/admin", http.StatusSeeOther) 2349 return 2350 } 2351 2352 action := r.FormValue("action") 2353 redirectTo := r.FormValue("redirect") 2354 if redirectTo == "" { 2355 redirectTo = "thumbnails" 2356 } 2357 2358 redirectBase := "/" + redirectTo 2359 2360 switch action { 2361 case "generate_all": 2362 missing, err := getMissingThumbnailVideos() 2363 if err != nil { 2364 http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to get videos: "+err.Error()), http.StatusSeeOther) 2365 return 2366 } 2367 2368 successCount := 0 2369 var errors []string 2370 2371 for _, v := range missing { 2372 err := generateThumbnail(v.Path, config.UploadDir, v.Filename) 2373 if err != nil { 2374 errors = append(errors, fmt.Sprintf("%s: %v", v.Filename, err)) 2375 } else { 2376 successCount++ 2377 } 2378 } 2379 2380 if len(errors) > 0 { 2381 http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Generated %d thumbnails", successCount))+"&error="+url.QueryEscape(fmt.Sprintf("Failed: %s", strings.Join(errors, "; "))), http.StatusSeeOther) 2382 } else { 2383 http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Successfully generated %d thumbnails", successCount)), http.StatusSeeOther) 2384 } 2385 2386 case "generate_single": 2387 fileID := r.FormValue("file_id") 2388 timestamp := strings.TrimSpace(r.FormValue("timestamp")) 2389 2390 if timestamp == "" { 2391 timestamp = "00:00:05" 2392 } 2393 2394 var filename, path string 2395 err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(&filename, &path) 2396 if err != nil { 2397 http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("File not found"), http.StatusSeeOther) 2398 return 2399 } 2400 2401 err = generateThumbnailAtTime(path, config.UploadDir, filename, timestamp) 2402 if err != nil { 2403 http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to generate thumbnail: "+err.Error()), http.StatusSeeOther) 2404 return 2405 } 2406 2407 if redirectTo == "admin" { 2408 http.Redirect(w, r, "/admin?success="+url.QueryEscape(fmt.Sprintf("Thumbnail generated for file %s at %s", fileID, timestamp)), http.StatusSeeOther) 2409 } else { 2410 http.Redirect(w, r, fmt.Sprintf("/file/%s?success=%s", fileID, url.QueryEscape(fmt.Sprintf("Thumbnail generated at %s", timestamp))), http.StatusSeeOther) 2411 } 2412 2413 default: 2414 http.Redirect(w, r, redirectBase, http.StatusSeeOther) 2415 } 2416 } 2417 2418 func generateThumbnail(videoPath, uploadDir, filename string) error { 2419 thumbDir := filepath.Join(uploadDir, "thumbnails") 2420 if err := os.MkdirAll(thumbDir, 0755); err != nil { 2421 return fmt.Errorf("failed to create thumbnails directory: %v", err) 2422 } 2423 2424 thumbPath := filepath.Join(thumbDir, filename+".jpg") 2425 2426 cmd := exec.Command("ffmpeg", "-y", "-ss", "00:00:05", "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath) 2427 cmd.Stdout = os.Stdout 2428 cmd.Stderr = os.Stderr 2429 2430 if err := cmd.Run(); err != nil { 2431 cmd := exec.Command("ffmpeg", "-y", "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath) 2432 cmd.Stdout = os.Stdout 2433 cmd.Stderr = os.Stderr 2434 if err2 := cmd.Run(); err2 != nil { 2435 return fmt.Errorf("failed to generate thumbnail: %v", err2) 2436 } 2437 } 2438 2439 return nil 2440 }