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