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