commit 17d3362751d1481c3c614dd91982f76f7de137ef
parent 5b67042c695aa9c1b94e87b89283ac0d9014579d
Author: breadcat <breadcat@users.noreply.github.com>
Date: Fri, 6 Feb 2026 15:22:25 +0000
Refactor code to use separate include files
Diffstat:
| A | include-admin-orphans.go | | | 52 | ++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-admin-thumbnails.go | | | 217 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-admin.go | | | 329 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-bulk.go | | | 524 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-files.go | | | 155 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-filters.go | | | 321 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-general.go | | | 86 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-pagination.go | | | 66 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-previews.go | | | 121 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-search.go | | | 78 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-types.go | | | 100 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-uploads.go | | | 306 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | include-viewer.go | | | 244 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | main.go | | | 2485 | +------------------------------------------------------------------------------ |
14 files changed, 2602 insertions(+), 2482 deletions(-)
diff --git a/include-admin-orphans.go b/include-admin-orphans.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "net/http"
+ "os"
+)
+
+func getOrphanedFiles(uploadDir string) ([]string, error) {
+ diskFiles, err := getFilesOnDisk(uploadDir)
+ if err != nil {
+ return nil, err
+ }
+
+ dbFiles, err := getFilesInDB()
+ if err != nil {
+ return nil, err
+ }
+
+ var orphans []string
+ for _, f := range diskFiles {
+ if !dbFiles[f] {
+ orphans = append(orphans, f)
+ }
+ }
+ return orphans, nil
+}
+
+func orphansHandler(w http.ResponseWriter, r *http.Request) {
+ orphans, err := getOrphanedFiles(config.UploadDir)
+ if err != nil {
+ renderError(w, "Error reading orphaned files", http.StatusInternalServerError)
+ return
+ }
+
+ pageData := buildPageData("Orphaned Files", orphans)
+ renderTemplate(w, "orphans.html", pageData)
+}
+
+func getFilesOnDisk(uploadDir string) ([]string, error) {
+ entries, err := os.ReadDir(uploadDir)
+ if err != nil {
+ return nil, err
+ }
+ var files []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ files = append(files, e.Name())
+ }
+ }
+ return files, nil
+}
+\ No newline at end of file
diff --git a/include-admin-thumbnails.go b/include-admin-thumbnails.go
@@ -0,0 +1,216 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+func generateThumbnailAtTime(videoPath, uploadDir, filename, timestamp string) error {
+ thumbDir := filepath.Join(uploadDir, "thumbnails")
+ if err := os.MkdirAll(thumbDir, 0755); err != nil {
+ return fmt.Errorf("failed to create thumbnails directory: %v", err)
+ }
+
+ thumbPath := filepath.Join(thumbDir, filename+".jpg")
+
+ cmd := exec.Command("ffmpeg", "-y", "-ss", timestamp, "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to generate thumbnail at %s: %v", timestamp, err)
+ }
+
+ return nil
+}
+
+func getVideoFiles() ([]VideoFile, error) {
+ videoExts := []string{".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"}
+
+ rows, err := db.Query(`SELECT id, filename, path FROM files ORDER BY id DESC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var videos []VideoFile
+ for rows.Next() {
+ var v VideoFile
+ if err := rows.Scan(&v.ID, &v.Filename, &v.Path); err != nil {
+ continue
+ }
+
+ // Check if it's a video file
+ isVideo := false
+ ext := strings.ToLower(filepath.Ext(v.Filename))
+ for _, vidExt := range videoExts {
+ if ext == vidExt {
+ isVideo = true
+ break
+ }
+ }
+
+ if !isVideo {
+ continue
+ }
+
+ v.EscapedFilename = url.PathEscape(v.Filename)
+ thumbPath := filepath.Join(config.UploadDir, "thumbnails", v.Filename+".jpg")
+ v.ThumbnailPath = "/uploads/thumbnails/" + v.EscapedFilename + ".jpg"
+
+ if _, err := os.Stat(thumbPath); err == nil {
+ v.HasThumbnail = true
+ }
+
+ videos = append(videos, v)
+ }
+
+ return videos, nil
+}
+
+
+
+func thumbnailsHandler(w http.ResponseWriter, r *http.Request) {
+ allVideos, err := getVideoFiles()
+ if err != nil {
+ renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ missing, err := getMissingThumbnailVideos()
+ if err != nil {
+ renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ pageData := buildPageData("Thumbnail Management", struct {
+ AllVideos []VideoFile
+ MissingThumbnails []VideoFile
+ Error string
+ Success string
+ }{
+ AllVideos: allVideos,
+ MissingThumbnails: missing,
+ Error: r.URL.Query().Get("error"),
+ Success: r.URL.Query().Get("success"),
+ })
+
+ renderTemplate(w, "thumbnails.html", pageData)
+}
+
+func generateThumbnailHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Redirect(w, r, "/admin", http.StatusSeeOther)
+ return
+ }
+
+ action := r.FormValue("action")
+ redirectTo := r.FormValue("redirect")
+ if redirectTo == "" {
+ redirectTo = "thumbnails"
+ }
+
+ redirectBase := "/" + redirectTo
+
+ switch action {
+ case "generate_all":
+ missing, err := getMissingThumbnailVideos()
+ if err != nil {
+ http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to get videos: "+err.Error()), http.StatusSeeOther)
+ return
+ }
+
+ successCount := 0
+ var errors []string
+
+ for _, v := range missing {
+ err := generateThumbnail(v.Path, config.UploadDir, v.Filename)
+ if err != nil {
+ errors = append(errors, fmt.Sprintf("%s: %v", v.Filename, err))
+ } else {
+ successCount++
+ }
+ }
+
+ if len(errors) > 0 {
+ 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)
+ } else {
+ http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Successfully generated %d thumbnails", successCount)), http.StatusSeeOther)
+ }
+
+ case "generate_single":
+ fileID := r.FormValue("file_id")
+ timestamp := strings.TrimSpace(r.FormValue("timestamp"))
+
+ if timestamp == "" {
+ timestamp = "00:00:05"
+ }
+
+ var filename, path string
+ err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(&filename, &path)
+ if err != nil {
+ http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("File not found"), http.StatusSeeOther)
+ return
+ }
+
+ err = generateThumbnailAtTime(path, config.UploadDir, filename, timestamp)
+ if err != nil {
+ http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to generate thumbnail: "+err.Error()), http.StatusSeeOther)
+ return
+ }
+
+ if redirectTo == "admin" {
+ http.Redirect(w, r, "/admin?success="+url.QueryEscape(fmt.Sprintf("Thumbnail generated for file %s at %s", fileID, timestamp)), http.StatusSeeOther)
+ } else {
+ http.Redirect(w, r, fmt.Sprintf("/file/%s?success=%s", fileID, url.QueryEscape(fmt.Sprintf("Thumbnail generated at %s", timestamp))), http.StatusSeeOther)
+ }
+
+ default:
+ http.Redirect(w, r, redirectBase, http.StatusSeeOther)
+ }
+}
+
+func generateThumbnail(videoPath, uploadDir, filename string) error {
+ thumbDir := filepath.Join(uploadDir, "thumbnails")
+ if err := os.MkdirAll(thumbDir, 0755); err != nil {
+ return fmt.Errorf("failed to create thumbnails directory: %v", err)
+ }
+
+ thumbPath := filepath.Join(thumbDir, filename+".jpg")
+
+ cmd := exec.Command("ffmpeg", "-y", "-ss", "00:00:05", "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ if err := cmd.Run(); err != nil {
+ cmd := exec.Command("ffmpeg", "-y", "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err2 := cmd.Run(); err2 != nil {
+ return fmt.Errorf("failed to generate thumbnail: %v", err2)
+ }
+ }
+
+ return nil
+}
+
+func getMissingThumbnailVideos() ([]VideoFile, error) {
+ allVideos, err := getVideoFiles()
+ if err != nil {
+ return nil, err
+ }
+
+ var missing []VideoFile
+ for _, v := range allVideos {
+ if !v.HasThumbnail {
+ missing = append(missing, v)
+ }
+ }
+
+ return missing, nil
+}
+\ No newline at end of file
diff --git a/include-admin.go b/include-admin.go
@@ -0,0 +1,329 @@
+package main
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+func loadConfig() error {
+ config = Config{
+ DatabasePath: "./database.db",
+ UploadDir: "uploads",
+ ServerPort: ":8080",
+ InstanceName: "Taggart",
+ GallerySize: "400px",
+ ItemsPerPage: "100",
+ TagAliases: []TagAliasGroup{},
+ }
+
+ if data, err := ioutil.ReadFile("config.json"); err == nil {
+ if err := json.Unmarshal(data, &config); err != nil {
+ return err
+ }
+ }
+
+ return os.MkdirAll(config.UploadDir, 0755)
+}
+
+func saveConfig() error {
+ data, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return err
+ }
+ return ioutil.WriteFile("config.json", data, 0644)
+}
+
+func validateConfig(newConfig Config) error {
+ if newConfig.DatabasePath == "" {
+ return fmt.Errorf("database path cannot be empty")
+ }
+
+ if newConfig.UploadDir == "" {
+ return fmt.Errorf("upload directory cannot be empty")
+ }
+
+ if newConfig.ServerPort == "" || !strings.HasPrefix(newConfig.ServerPort, ":") {
+ return fmt.Errorf("server port must be in format ':8080'")
+ }
+
+ if err := os.MkdirAll(newConfig.UploadDir, 0755); err != nil {
+ return fmt.Errorf("cannot create upload directory: %v", err)
+ }
+
+ return nil
+}
+
+func adminHandler(w http.ResponseWriter, r *http.Request) {
+ // Get orphaned files
+ orphans, _ := getOrphanedFiles(config.UploadDir)
+
+ // Get video files for thumbnails
+ missingThumbnails, _ := getMissingThumbnailVideos()
+
+ switch r.Method {
+ case http.MethodPost:
+ action := r.FormValue("action")
+
+ switch action {
+ case "save", "":
+ handleSaveSettings(w, r, orphans, missingThumbnails)
+ return
+
+ case "backup":
+ err := backupDatabase(config.DatabasePath)
+ pageData := buildPageData("Admin", struct {
+ Config Config
+ Error string
+ Success string
+ Orphans []string
+ MissingThumbnails []VideoFile
+ }{
+ Config: config,
+ Error: errorString(err),
+ Success: successString(err, "Database backup created successfully!"),
+ Orphans: orphans,
+ MissingThumbnails: missingThumbnails,
+ })
+ renderTemplate(w, "admin.html", pageData)
+ return
+
+ case "vacuum":
+ err := vacuumDatabase(config.DatabasePath)
+ pageData := buildPageData("Admin", struct {
+ Config Config
+ Error string
+ Success string
+ Orphans []string
+ MissingThumbnails []VideoFile
+ }{
+ Config: config,
+ Error: errorString(err),
+ Success: successString(err, "Database vacuum completed successfully!"),
+ Orphans: orphans,
+ MissingThumbnails: missingThumbnails,
+ })
+ renderTemplate(w, "admin.html", pageData)
+ return
+
+ case "save_aliases":
+ handleSaveAliases(w, r, orphans, missingThumbnails)
+ return
+ }
+
+ default:
+ pageData := buildPageData("Admin", struct {
+ Config Config
+ Error string
+ Success string
+ Orphans []string
+ MissingThumbnails []VideoFile
+ }{
+ Config: config,
+ Error: "",
+ Success: "",
+ Orphans: orphans,
+ MissingThumbnails: missingThumbnails,
+ })
+ renderTemplate(w, "admin.html", pageData)
+ }
+}
+
+func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphans []string, missingThumbnails []VideoFile) {
+ aliasesJSON := r.FormValue("aliases_json")
+
+ var aliases []TagAliasGroup
+ if aliasesJSON != "" {
+ if err := json.Unmarshal([]byte(aliasesJSON), &aliases); err != nil {
+ pageData := buildPageData("Admin", struct {
+ Config Config
+ Error string
+ Success string
+ Orphans []string
+ MissingThumbnails []VideoFile
+ }{
+ Config: config,
+ Error: "Invalid aliases JSON: " + err.Error(),
+ Success: "",
+ Orphans: orphans,
+ MissingThumbnails: missingThumbnails,
+ })
+ renderTemplate(w, "admin.html", pageData)
+ return
+ }
+ }
+
+ config.TagAliases = aliases
+
+ if err := saveConfig(); err != nil {
+ pageData := buildPageData("Admin", struct {
+ Config Config
+ Error string
+ Success string
+ Orphans []string
+ MissingThumbnails []VideoFile
+ }{
+ Config: config,
+ Error: "Failed to save configuration: " + err.Error(),
+ Success: "",
+ Orphans: orphans,
+ MissingThumbnails: missingThumbnails,
+ })
+ renderTemplate(w, "admin.html", pageData)
+ return
+ }
+
+ pageData := buildPageData("Admin", struct {
+ Config Config
+ Error string
+ Success string
+ Orphans []string
+ MissingThumbnails []VideoFile
+ }{
+ Config: config,
+ Error: "",
+ Success: "Tag aliases saved successfully!",
+ Orphans: orphans,
+ MissingThumbnails: missingThumbnails,
+ })
+ renderTemplate(w, "admin.html", pageData)
+}
+
+func handleSaveSettings(w http.ResponseWriter, r *http.Request, orphans []string, missingThumbnails []VideoFile) {
+ newConfig := Config{
+ DatabasePath: strings.TrimSpace(r.FormValue("database_path")),
+ UploadDir: strings.TrimSpace(r.FormValue("upload_dir")),
+ ServerPort: strings.TrimSpace(r.FormValue("server_port")),
+ InstanceName: strings.TrimSpace(r.FormValue("instance_name")),
+ GallerySize: strings.TrimSpace(r.FormValue("gallery_size")),
+ ItemsPerPage: strings.TrimSpace(r.FormValue("items_per_page")),
+ TagAliases: config.TagAliases, // Preserve existing aliases
+ }
+
+ if err := validateConfig(newConfig); err != nil {
+ pageData := buildPageData("Admin", struct {
+ Config Config
+ Error string
+ Success string
+ Orphans []string
+ MissingThumbnails []VideoFile
+ }{
+ Config: config,
+ Error: err.Error(),
+ Success: "",
+ Orphans: orphans,
+ MissingThumbnails: missingThumbnails,
+ })
+ renderTemplate(w, "admin.html", pageData)
+ return
+ }
+
+ needsRestart := (newConfig.DatabasePath != config.DatabasePath ||
+ newConfig.ServerPort != config.ServerPort)
+
+ config = newConfig
+ if err := saveConfig(); err != nil {
+ pageData := buildPageData("Admin", struct {
+ Config Config
+ Error string
+ Success string
+ Orphans []string
+ MissingThumbnails []VideoFile
+ }{
+ Config: config,
+ Error: "Failed to save configuration: " + err.Error(),
+ Success: "",
+ Orphans: orphans,
+ MissingThumbnails: missingThumbnails,
+ })
+ renderTemplate(w, "admin.html", pageData)
+ return
+ }
+
+ var message string
+ if needsRestart {
+ message = "Settings saved successfully! Please restart the server for database/port changes to take effect."
+ } else {
+ message = "Settings saved successfully!"
+ }
+
+ pageData := buildPageData("Admin", struct {
+ Config Config
+ Error string
+ Success string
+ Orphans []string
+ MissingThumbnails []VideoFile
+ }{
+ Config: config,
+ Error: "",
+ Success: message,
+ Orphans: orphans,
+ MissingThumbnails: missingThumbnails,
+ })
+ renderTemplate(w, "admin.html", pageData)
+}
+
+func backupDatabase(dbPath string) error {
+ if dbPath == "" {
+ return fmt.Errorf("database path not configured")
+ }
+
+ timestamp := time.Now().Format("20060102_150405")
+ backupPath := fmt.Sprintf("%s_backup_%s.db", strings.TrimSuffix(dbPath, filepath.Ext(dbPath)), timestamp)
+
+ input, err := os.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("failed to open database: %w", err)
+ }
+ defer input.Close()
+
+ output, err := os.Create(backupPath)
+ if err != nil {
+ return fmt.Errorf("failed to create backup file: %w", err)
+ }
+ defer output.Close()
+
+ if _, err := io.Copy(output, input); err != nil {
+ return fmt.Errorf("failed to copy database: %w", err)
+ }
+
+ return nil
+}
+
+func vacuumDatabase(dbPath string) error {
+ db, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ return fmt.Errorf("failed to open database: %w", err)
+ }
+ defer db.Close()
+
+ _, err = db.Exec("VACUUM;")
+ if err != nil {
+ return fmt.Errorf("VACUUM failed: %w", err)
+ }
+
+ return nil
+}
+
+func getFilesInDB() (map[string]bool, error) {
+ rows, err := db.Query(`SELECT filename FROM files`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ fileMap := make(map[string]bool)
+ for rows.Next() {
+ var name string
+ rows.Scan(&name)
+ fileMap[name] = true
+ }
+ return fileMap, nil
+}
diff --git a/include-bulk.go b/include-bulk.go
@@ -0,0 +1,523 @@
+package main
+
+import (
+ "database/sql"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+func applyBulkTagOperations(fileIDs []int, category, value, operation string) error {
+ category = strings.TrimSpace(category)
+ value = strings.TrimSpace(value)
+ if category == "" {
+ return fmt.Errorf("category cannot be empty")
+ }
+
+ if operation == "add" && value == "" {
+ return fmt.Errorf("value cannot be empty when adding tags")
+ }
+
+ tx, err := db.Begin()
+ if err != nil {
+ return fmt.Errorf("failed to start transaction: %v", err)
+ }
+ defer tx.Rollback()
+
+ var catID int
+ err = tx.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID)
+ if err != nil && err != sql.ErrNoRows {
+ return fmt.Errorf("failed to query category: %v", err)
+ }
+
+ if catID == 0 {
+ if operation == "remove" {
+ return fmt.Errorf("cannot remove non-existent category: %s", category)
+ }
+ res, err := tx.Exec("INSERT INTO categories(name) VALUES(?)", category)
+ if err != nil {
+ return fmt.Errorf("failed to create category: %v", err)
+ }
+ cid, _ := res.LastInsertId()
+ catID = int(cid)
+ }
+
+ var tagID int
+ if value != "" {
+ err = tx.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID)
+ if err != nil && err != sql.ErrNoRows {
+ return fmt.Errorf("failed to query tag: %v", err)
+ }
+
+ if tagID == 0 {
+ if operation == "remove" {
+ return fmt.Errorf("cannot remove non-existent tag: %s=%s", category, value)
+ }
+ res, err := tx.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value)
+ if err != nil {
+ return fmt.Errorf("failed to create tag: %v", err)
+ }
+ tid, _ := res.LastInsertId()
+ tagID = int(tid)
+ }
+ }
+
+ for _, fileID := range fileIDs {
+ if operation == "add" {
+ _, err = tx.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", fileID, tagID)
+ } else if operation == "remove" {
+ if value != "" {
+ _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID)
+ } else {
+ _, 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)
+ }
+ } else {
+ return fmt.Errorf("invalid operation: %s (must be 'add' or 'remove')", operation)
+ }
+ if err != nil {
+ return fmt.Errorf("failed to %s tag for file %d: %v", operation, fileID, err)
+ }
+ }
+
+ return tx.Commit()
+}
+
+func getBulkTagFormData() BulkTagFormData {
+ catRows, _ := db.Query("SELECT name FROM categories ORDER BY name")
+ var cats []string
+ for catRows.Next() {
+ var c string
+ catRows.Scan(&c)
+ cats = append(cats, c)
+ }
+ catRows.Close()
+
+ recentRows, _ := db.Query("SELECT id, filename FROM files ORDER BY id DESC LIMIT 20")
+ var recentFiles []File
+ for recentRows.Next() {
+ var f File
+ recentRows.Scan(&f.ID, &f.Filename)
+ recentFiles = append(recentFiles, f)
+ }
+ recentRows.Close()
+
+ return BulkTagFormData{
+ Categories: cats,
+ RecentFiles: recentFiles,
+ FormData: struct {
+ FileRange string
+ Category string
+ Value string
+ Operation string
+ TagQuery string
+ SelectionMode string
+ }{Operation: "add"},
+ }
+}
+
+func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ formData := getBulkTagFormData()
+ pageData := buildPageData("Bulk Tag Editor", formData)
+ renderTemplate(w, "bulk-tag.html", pageData)
+ return
+ }
+ if r.Method == http.MethodPost {
+ rangeStr := strings.TrimSpace(r.FormValue("file_range"))
+ tagQuery := strings.TrimSpace(r.FormValue("tag_query"))
+ selectionMode := r.FormValue("selection_mode")
+ category := strings.TrimSpace(r.FormValue("category"))
+ value := strings.TrimSpace(r.FormValue("value"))
+ operation := r.FormValue("operation")
+
+ formData := getBulkTagFormData()
+ formData.FormData.FileRange = rangeStr
+ formData.FormData.TagQuery = tagQuery
+ formData.FormData.SelectionMode = selectionMode
+ formData.FormData.Category = category
+ formData.FormData.Value = value
+ formData.FormData.Operation = operation
+
+ createErrorResponse := func(errorMsg string) {
+ formData.Error = errorMsg
+ pageData := buildPageData("Bulk Tag Editor", formData)
+ renderTemplate(w, "bulk-tag.html", pageData)
+ }
+
+ // Validate selection mode
+ if selectionMode == "" {
+ selectionMode = "range" // default
+ }
+
+ // Validate inputs based on selection mode
+ if selectionMode == "range" && rangeStr == "" {
+ createErrorResponse("File range cannot be empty")
+ return
+ }
+ if selectionMode == "tags" && tagQuery == "" {
+ createErrorResponse("Tag query cannot be empty")
+ return
+ }
+ if category == "" {
+ createErrorResponse("Category cannot be empty")
+ return
+ }
+ if operation == "add" && value == "" {
+ createErrorResponse("Value cannot be empty when adding tags")
+ return
+ }
+
+ // Get file IDs based on selection mode
+ var fileIDs []int
+ var err error
+
+ if selectionMode == "range" {
+ fileIDs, err = parseFileIDRange(rangeStr)
+ if err != nil {
+ createErrorResponse(fmt.Sprintf("Invalid file range: %v", err))
+ return
+ }
+ } else if selectionMode == "tags" {
+ fileIDs, err = getFileIDsFromTagQuery(tagQuery)
+ if err != nil {
+ createErrorResponse(fmt.Sprintf("Tag query error: %v", err))
+ return
+ }
+ if len(fileIDs) == 0 {
+ createErrorResponse("No files match the tag query")
+ return
+ }
+ } else {
+ createErrorResponse("Invalid selection mode")
+ return
+ }
+
+ validFiles, err := validateFileIDs(fileIDs)
+ if err != nil {
+ createErrorResponse(fmt.Sprintf("File validation error: %v", err))
+ return
+ }
+
+ err = applyBulkTagOperations(fileIDs, category, value, operation)
+ if err != nil {
+ createErrorResponse(fmt.Sprintf("Tag operation failed: %v", err))
+ return
+ }
+
+ // Build success message
+ var successMsg string
+ var selectionDesc string
+ if selectionMode == "range" {
+ selectionDesc = fmt.Sprintf("file range '%s'", rangeStr)
+ } else {
+ selectionDesc = fmt.Sprintf("tag query '%s'", tagQuery)
+ }
+
+ if operation == "add" {
+ successMsg = fmt.Sprintf("Tag '%s: %s' added to %d files matching %s",
+ category, value, len(validFiles), selectionDesc)
+ } else {
+ if value != "" {
+ successMsg = fmt.Sprintf("Tag '%s: %s' removed from %d files matching %s",
+ category, value, len(validFiles), selectionDesc)
+ } else {
+ successMsg = fmt.Sprintf("All '%s' category tags removed from %d files matching %s",
+ category, len(validFiles), selectionDesc)
+ }
+ }
+
+ // Add file list
+ var filenames []string
+ for _, f := range validFiles {
+ filenames = append(filenames, f.Filename)
+ }
+ if len(filenames) <= 5 {
+ successMsg += fmt.Sprintf(": %s", strings.Join(filenames, ", "))
+ } else {
+ successMsg += fmt.Sprintf(": %s and %d more", strings.Join(filenames[:5], ", "), len(filenames)-5)
+ }
+
+ formData.Success = successMsg
+ pageData := buildPageData("Bulk Tag Editor", formData)
+ renderTemplate(w, "bulk-tag.html", pageData)
+ return
+ }
+ renderError(w, "Method not allowed", http.StatusMethodNotAllowed)
+}
+
+
+func parseFileIDRange(rangeStr string) ([]int, error) {
+ var fileIDs []int
+ parts := strings.Split(rangeStr, ",")
+
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+
+ if strings.Contains(part, "-") {
+ rangeParts := strings.Split(part, "-")
+ if len(rangeParts) != 2 {
+ return nil, fmt.Errorf("invalid range format: %s", part)
+ }
+
+ start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
+ if err != nil {
+ return nil, fmt.Errorf("invalid start ID in range %s: %v", part, err)
+ }
+
+ end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
+ if err != nil {
+ return nil, fmt.Errorf("invalid end ID in range %s: %v", part, err)
+ }
+
+ if start > end {
+ return nil, fmt.Errorf("invalid range %s: start must be <= end", part)
+ }
+
+ for i := start; i <= end; i++ {
+ fileIDs = append(fileIDs, i)
+ }
+ } else {
+ id, err := strconv.Atoi(part)
+ if err != nil {
+ return nil, fmt.Errorf("invalid file ID: %s", part)
+ }
+ fileIDs = append(fileIDs, id)
+ }
+ }
+
+ uniqueIDs := make(map[int]bool)
+ var result []int
+ for _, id := range fileIDs {
+ if !uniqueIDs[id] {
+ uniqueIDs[id] = true
+ result = append(result, id)
+ }
+ }
+
+ return result, nil
+}
+
+func getFileIDsFromTagQuery(query string) ([]int, error) {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return nil, fmt.Errorf("empty query")
+ }
+
+ // Check if query contains OR operator
+ if strings.Contains(strings.ToUpper(query), " OR ") {
+ return getFileIDsFromORQuery(query)
+ }
+
+ // Otherwise treat as AND query (comma-separated or single tag)
+ return getFileIDsFromANDQuery(query)
+}
+
+// getFileIDsFromANDQuery handles comma-separated tags (AND logic)
+func getFileIDsFromANDQuery(query string) ([]int, error) {
+ tagPairs := strings.Split(query, ",")
+ var tags []TagPair
+
+ for _, pair := range tagPairs {
+ pair = strings.TrimSpace(pair)
+ if pair == "" {
+ continue
+ }
+
+ parts := strings.SplitN(pair, ":", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
+ }
+
+ tags = append(tags, TagPair{
+ Category: strings.TrimSpace(parts[0]),
+ Value: strings.TrimSpace(parts[1]),
+ })
+ }
+
+ if len(tags) == 0 {
+ return nil, fmt.Errorf("no valid tags found in query")
+ }
+
+ // Query database for files matching ALL tags
+ return findFilesWithAllTags(tags)
+}
+
+// getFileIDsFromORQuery handles OR-separated tags
+func getFileIDsFromORQuery(query string) ([]int, error) {
+ tagPairs := strings.Split(strings.ToUpper(query), " OR ")
+ var tags []TagPair
+
+ for _, pair := range tagPairs {
+ pair = strings.TrimSpace(pair)
+ if pair == "" {
+ continue
+ }
+
+ parts := strings.SplitN(pair, ":", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
+ }
+
+ tags = append(tags, TagPair{
+ Category: strings.TrimSpace(parts[0]),
+ Value: strings.TrimSpace(parts[1]),
+ })
+ }
+
+ if len(tags) == 0 {
+ return nil, fmt.Errorf("no valid tags found in query")
+ }
+
+ // Query database for files matching ANY tag
+ return findFilesWithAnyTag(tags)
+}
+
+func validateFileIDs(fileIDs []int) ([]File, error) {
+ if len(fileIDs) == 0 {
+ return nil, fmt.Errorf("no file IDs provided")
+ }
+
+ placeholders := make([]string, len(fileIDs))
+ args := make([]interface{}, len(fileIDs))
+ for i, id := range fileIDs {
+ placeholders[i] = "?"
+ args[i] = id
+ }
+
+ query := fmt.Sprintf("SELECT id, filename, path FROM files WHERE id IN (%s) ORDER BY id",
+ strings.Join(placeholders, ","))
+
+ rows, err := db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("database error: %v", err)
+ }
+ defer rows.Close()
+
+ var files []File
+ foundIDs := make(map[int]bool)
+
+ for rows.Next() {
+ var f File
+ err := rows.Scan(&f.ID, &f.Filename, &f.Path)
+ if err != nil {
+ return nil, fmt.Errorf("error scanning file: %v", err)
+ }
+ files = append(files, f)
+ foundIDs[f.ID] = true
+ }
+
+ var missingIDs []int
+ for _, id := range fileIDs {
+ if !foundIDs[id] {
+ missingIDs = append(missingIDs, id)
+ }
+ }
+
+ if len(missingIDs) > 0 {
+ return files, fmt.Errorf("file IDs not found: %v", missingIDs)
+ }
+
+ return files, nil
+}
+
+func findFilesWithAnyTag(tags []TagPair) ([]int, error) {
+ if len(tags) == 0 {
+ return nil, fmt.Errorf("no tags specified")
+ }
+
+ // Build query with OR conditions
+ query := `
+ SELECT DISTINCT f.id
+ FROM files f
+ INNER JOIN file_tags ft ON f.id = ft.file_id
+ INNER JOIN tags t ON ft.tag_id = t.id
+ INNER JOIN categories c ON t.category_id = c.id
+ WHERE `
+
+ var conditions []string
+ var args []interface{}
+ argIndex := 1
+
+ for _, tag := range tags {
+ conditions = append(conditions, fmt.Sprintf(
+ "(c.name = $%d AND t.value = $%d)",
+ argIndex, argIndex+1))
+ args = append(args, tag.Category, tag.Value)
+ argIndex += 2
+ }
+
+ query += strings.Join(conditions, " OR ")
+ query += " ORDER BY f.id"
+
+ rows, err := db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("database query failed: %w", err)
+ }
+ defer rows.Close()
+
+ var fileIDs []int
+ for rows.Next() {
+ var id int
+ if err := rows.Scan(&id); err != nil {
+ return nil, fmt.Errorf("scan error: %w", err)
+ }
+ fileIDs = append(fileIDs, id)
+ }
+
+ return fileIDs, rows.Err()
+}
+
+
+func findFilesWithAllTags(tags []TagPair) ([]int, error) {
+ if len(tags) == 0 {
+ return nil, fmt.Errorf("no tags specified")
+ }
+
+ // Build query with subqueries for each tag
+ query := `
+ SELECT f.id
+ FROM files f
+ WHERE `
+
+ var conditions []string
+ var args []interface{}
+ argIndex := 1
+
+ for _, tag := range tags {
+ conditions = append(conditions, fmt.Sprintf(`
+ EXISTS (
+ SELECT 1 FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ JOIN categories c ON t.category_id = c.id
+ WHERE ft.file_id = f.id
+ AND c.name = $%d
+ AND t.value = $%d
+ )`, argIndex, argIndex+1))
+ args = append(args, tag.Category, tag.Value)
+ argIndex += 2
+ }
+
+ query += strings.Join(conditions, " AND ")
+ query += " ORDER BY f.id"
+
+ rows, err := db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("database query failed: %w", err)
+ }
+ defer rows.Close()
+
+ var fileIDs []int
+ for rows.Next() {
+ var id int
+ if err := rows.Scan(&id); err != nil {
+ return nil, fmt.Errorf("scan error: %w", err)
+ }
+ fileIDs = append(fileIDs, id)
+ }
+
+ return fileIDs, rows.Err()
+}
+\ No newline at end of file
diff --git a/include-files.go b/include-files.go
@@ -0,0 +1,154 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func fileRouter(w http.ResponseWriter, r *http.Request) {
+ parts := strings.Split(r.URL.Path, "/")
+
+ if len(parts) >= 4 && parts[3] == "delete" {
+ fileDeleteHandler(w, r, parts)
+ return
+ }
+
+ if len(parts) >= 4 && parts[3] == "rename" {
+ fileRenameHandler(w, r, parts)
+ return
+ }
+
+ if len(parts) >= 7 && parts[3] == "tag" {
+ tagActionHandler(w, r, parts)
+ return
+ }
+
+ fileHandler(w, r)
+}
+
+func fileDeleteHandler(w http.ResponseWriter, r *http.Request, parts []string) {
+ if r.Method != http.MethodPost {
+ http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther)
+ return
+ }
+
+ fileID := parts[2]
+
+ var currentFile File
+ err := db.QueryRow("SELECT id, filename, path FROM files WHERE id=?", fileID).Scan(¤tFile.ID, ¤tFile.Filename, ¤tFile.Path)
+ if err != nil {
+ renderError(w, "File not found", http.StatusNotFound)
+ return
+ }
+
+ tx, err := db.Begin()
+ if err != nil {
+ renderError(w, "Failed to start transaction", http.StatusInternalServerError)
+ return
+ }
+ defer tx.Rollback()
+
+ if _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=?", fileID); err != nil {
+ renderError(w, "Failed to delete file tags", http.StatusInternalServerError)
+ return
+ }
+
+ if _, err = tx.Exec("DELETE FROM files WHERE id=?", fileID); err != nil {
+ renderError(w, "Failed to delete file record", http.StatusInternalServerError)
+ return
+ }
+
+ if err = tx.Commit(); err != nil {
+ renderError(w, "Failed to commit transaction", http.StatusInternalServerError)
+ return
+ }
+
+ if err = os.Remove(currentFile.Path); err != nil {
+ log.Printf("Warning: Failed to delete physical file %s: %v", currentFile.Path, err)
+ }
+
+ // Delete thumbnail if it exists
+ thumbPath := filepath.Join(config.UploadDir, "thumbnails", currentFile.Filename+".jpg")
+ if _, err := os.Stat(thumbPath); err == nil {
+ if err := os.Remove(thumbPath); err != nil {
+ log.Printf("Warning: Failed to delete thumbnail %s: %v", thumbPath, err)
+ }
+ }
+
+ http.Redirect(w, r, "/?deleted="+currentFile.Filename, http.StatusSeeOther)
+}
+
+func fileRenameHandler(w http.ResponseWriter, r *http.Request, parts []string) {
+ if r.Method != http.MethodPost {
+ http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther)
+ return
+ }
+
+ fileID := parts[2]
+ newFilename := sanitizeFilename(strings.TrimSpace(r.FormValue("newfilename")))
+
+ if newFilename == "" {
+ renderError(w, "New filename cannot be empty", http.StatusBadRequest)
+ return
+ }
+
+ var currentFilename, currentPath string
+ err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(¤tFilename, ¤tPath)
+ if err != nil {
+ renderError(w, "File not found", http.StatusNotFound)
+ return
+ }
+
+ if currentFilename == newFilename {
+ http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther)
+ return
+ }
+
+ newPath := filepath.Join(config.UploadDir, newFilename)
+ if _, err := os.Stat(newPath); !os.IsNotExist(err) {
+ renderError(w, "A file with that name already exists", http.StatusConflict)
+ return
+ }
+
+ if err := os.Rename(currentPath, newPath); err != nil {
+ renderError(w, "Failed to rename physical file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ thumbOld := filepath.Join(config.UploadDir, "thumbnails", currentFilename+".jpg")
+ thumbNew := filepath.Join(config.UploadDir, "thumbnails", newFilename+".jpg")
+
+ if _, err := os.Stat(thumbOld); err == nil {
+ if err := os.Rename(thumbOld, thumbNew); err != nil {
+ os.Rename(newPath, currentPath)
+ renderError(w, "Failed to rename thumbnail: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ _, err = db.Exec("UPDATE files SET filename=?, path=? WHERE id=?", newFilename, newPath, fileID)
+ if err != nil {
+ os.Rename(newPath, currentPath)
+ if _, err := os.Stat(thumbNew); err == nil {
+ os.Rename(thumbNew, thumbOld)
+ }
+ renderError(w, "Failed to update database", http.StatusInternalServerError)
+ return
+ }
+
+ http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther)
+}
+
+func checkFileConflictStrict(filename string) (string, string, error) {
+ finalPath := filepath.Join(config.UploadDir, filename)
+ if _, err := os.Stat(finalPath); err == nil {
+ return "", "", fmt.Errorf("a file with that name already exists")
+ } else if !os.IsNotExist(err) {
+ return "", "", fmt.Errorf("failed to check for existing file: %v", err)
+ }
+ return filename, finalPath, nil
+}
+\ No newline at end of file
diff --git a/include-filters.go b/include-filters.go
@@ -0,0 +1,320 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+func getTaggedFiles() ([]File, error) {
+ return queryFilesWithTags(`
+ SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description
+ FROM files f
+ JOIN file_tags ft ON ft.file_id = f.id
+ ORDER BY f.id DESC
+ `)
+}
+
+func getUntaggedFiles() ([]File, error) {
+ return queryFilesWithTags(`
+ SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description
+ FROM files f
+ LEFT JOIN file_tags ft ON ft.file_id = f.id
+ WHERE ft.file_id IS NULL
+ ORDER BY f.id DESC
+ `)
+}
+
+func untaggedFilesHandler(w http.ResponseWriter, r *http.Request) {
+ // Get page number from query params
+ pageStr := r.URL.Query().Get("page")
+ page := 1
+ if pageStr != "" {
+ if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
+ page = p
+ }
+ }
+
+ // Get per page from config
+ perPage := 50
+ if config.ItemsPerPage != "" {
+ if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 {
+ perPage = pp
+ }
+ }
+
+ files, total, _ := getUntaggedFilesPaginated(page, perPage)
+ pageData := buildPageDataWithPagination("Untagged Files", files, page, total, perPage)
+ renderTemplate(w, "untagged.html", pageData)
+}
+
+func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
+ pageStr := r.URL.Query().Get("page")
+ page := 1
+ if pageStr != "" {
+ if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
+ page = p
+ }
+ }
+
+ perPage := 50
+ if config.ItemsPerPage != "" {
+ if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 {
+ perPage = pp
+ }
+ }
+
+ fullPath := strings.TrimPrefix(r.URL.Path, "/tag/")
+ tagPairs := strings.Split(fullPath, "/and/tag/")
+
+ breadcrumbs := []Breadcrumb{
+ {Name: "Home", URL: "/"},
+ {Name: "Tags", URL: "/tags"},
+ }
+
+ var filters []filter
+ currentPath := "/tag"
+
+ for i, pair := range tagPairs {
+ parts := strings.Split(pair, "/")
+ if len(parts) != 2 {
+ renderError(w, "Invalid tag filter path", http.StatusBadRequest)
+ return
+ }
+
+ f := filter{
+ Category: parts[0],
+ Value: parts[1],
+ IsPreviews: parts[1] == "previews",
+ }
+
+ // Expand with aliases (unless it's a special tag)
+ if parts[1] != "unassigned" && parts[1] != "previews" {
+ f.Values = expandTagWithAliases(parts[0], parts[1])
+ }
+
+ filters = append(filters, f)
+
+ // Build breadcrumb path incrementally
+ if i == 0 {
+ currentPath += "/" + parts[0] + "/" + parts[1]
+ } else {
+ currentPath += "/and/tag/" + parts[0] + "/" + parts[1]
+ }
+
+ // Add category breadcrumb (only if it's the first occurrence)
+ categoryExists := false
+ for _, bc := range breadcrumbs {
+ if bc.Name == parts[0] {
+ categoryExists = true
+ break
+ }
+ }
+ if !categoryExists {
+ breadcrumbs = append(breadcrumbs, Breadcrumb{
+ Name: strings.Title(parts[0]),
+ URL: "/tags#tag-" + parts[0],
+ })
+ }
+
+ // Add value breadcrumb
+ breadcrumbs = append(breadcrumbs, Breadcrumb{
+ Name: strings.Title(parts[1]),
+ URL: currentPath,
+ })
+ }
+
+ // Check if we're in preview mode for any filter
+ hasPreviewFilter := false
+ for _, f := range filters {
+ if f.IsPreviews {
+ hasPreviewFilter = true
+ break
+ }
+ }
+
+ if hasPreviewFilter {
+ // Handle preview mode
+ files, err := getPreviewFiles(filters)
+ if err != nil {
+ renderError(w, "Failed to fetch preview files", http.StatusInternalServerError)
+ return
+ }
+
+ var titleParts []string
+ for _, f := range filters {
+ titleParts = append(titleParts, fmt.Sprintf("%s: %s", f.Category, f.Value))
+ }
+ title := "Tagged: " + strings.Join(titleParts, " + ")
+
+ pageData := buildPageDataWithPagination(title, ListData{
+ Tagged: files,
+ Untagged: nil,
+ Breadcrumbs: []Breadcrumb{},
+ }, 1, len(files), len(files))
+ pageData.Breadcrumbs = breadcrumbs
+
+ renderTemplate(w, "list.html", pageData)
+ return
+ }
+
+ // Build count query (existing logic)
+ countQuery := `SELECT COUNT(DISTINCT f.id) FROM files f WHERE 1=1`
+ countArgs := []interface{}{}
+
+ for _, f := range filters {
+ if f.Value == "unassigned" {
+ countQuery += `
+ AND NOT EXISTS (
+ SELECT 1
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ JOIN categories c ON c.id = t.category_id
+ WHERE ft.file_id = f.id AND c.name = ?
+ )`
+ countArgs = append(countArgs, f.Category)
+ } else {
+ // Build OR clause for aliases
+ placeholders := make([]string, len(f.Values))
+ for i := range f.Values {
+ placeholders[i] = "?"
+ }
+
+ countQuery += fmt.Sprintf(`
+ AND EXISTS (
+ SELECT 1
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ JOIN categories c ON c.id = t.category_id
+ WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s)
+ )`, strings.Join(placeholders, ","))
+
+ countArgs = append(countArgs, f.Category)
+ for _, v := range f.Values {
+ countArgs = append(countArgs, v)
+ }
+ }
+ }
+
+ var total int
+ err := db.QueryRow(countQuery, countArgs...).Scan(&total)
+ if err != nil {
+ renderError(w, "Failed to count files", http.StatusInternalServerError)
+ return
+ }
+
+ // Build main query with pagination (existing logic)
+ query := `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f WHERE 1=1`
+ args := []interface{}{}
+
+ for _, f := range filters {
+ if f.Value == "unassigned" {
+ query += `
+ AND NOT EXISTS (
+ SELECT 1
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ JOIN categories c ON c.id = t.category_id
+ WHERE ft.file_id = f.id AND c.name = ?
+ )`
+ args = append(args, f.Category)
+ } else {
+ // Build OR clause for aliases
+ placeholders := make([]string, len(f.Values))
+ for i := range f.Values {
+ placeholders[i] = "?"
+ }
+
+ query += fmt.Sprintf(`
+ AND EXISTS (
+ SELECT 1
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ JOIN categories c ON c.id = t.category_id
+ WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s)
+ )`, strings.Join(placeholders, ","))
+
+ args = append(args, f.Category)
+ for _, v := range f.Values {
+ args = append(args, v)
+ }
+ }
+ }
+
+ offset := (page - 1) * perPage
+ query += ` ORDER BY f.id DESC LIMIT ? OFFSET ?`
+ args = append(args, perPage, offset)
+
+ files, err := queryFilesWithTags(query, args...)
+ if err != nil {
+ renderError(w, "Failed to fetch files", http.StatusInternalServerError)
+ return
+ }
+
+ var titleParts []string
+ for _, f := range filters {
+ titleParts = append(titleParts, fmt.Sprintf("%s: %s", f.Category, f.Value))
+ }
+ title := "Tagged: " + strings.Join(titleParts, ", ")
+
+ pageData := buildPageDataWithPagination(title, ListData{
+ Tagged: files,
+ Untagged: nil,
+ Breadcrumbs: []Breadcrumb{},
+ }, page, total, perPage)
+ pageData.Breadcrumbs = breadcrumbs
+
+ renderTemplate(w, "list.html", pageData)
+}
+
+func expandTagWithAliases(category, value string) []string {
+ values := []string{value}
+
+ for _, group := range config.TagAliases {
+ if group.Category != category {
+ continue
+ }
+
+ // Check if the value is in this alias group
+ found := false
+ for _, alias := range group.Aliases {
+ if strings.EqualFold(alias, value) {
+ found = true
+ break
+ }
+ }
+
+ if found {
+ // Add all aliases from this group
+ for _, alias := range group.Aliases {
+ if !strings.EqualFold(alias, value) {
+ values = append(values, alias)
+ }
+ }
+ break
+ }
+ }
+
+ return values
+}
+
+func queryFilesWithTags(query string, args ...interface{}) ([]File, error) {
+ rows, err := db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var files []File
+ for rows.Next() {
+ var f File
+ if err := rows.Scan(&f.ID, &f.Filename, &f.Path, &f.Description); err != nil {
+ return nil, err
+ }
+ f.EscapedFilename = url.PathEscape(f.Filename)
+ files = append(files, f)
+ }
+ return files, nil
+}
+\ No newline at end of file
diff --git a/include-general.go b/include-general.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+func sanitizeFilename(filename string) string {
+ if filename == "" {
+ return "file"
+ }
+ filename = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(filename, "/", "_"), "\\", "_"), "..", "_")
+ if filename == "" {
+ return "file"
+ }
+ return filename
+}
+
+func renderError(w http.ResponseWriter, message string, statusCode int) {
+ http.Error(w, message, statusCode)
+}
+
+func renderTemplate(w http.ResponseWriter, tmplName string, data PageData) {
+ if err := tmpl.ExecuteTemplate(w, tmplName, data); err != nil {
+ renderError(w, "Template rendering failed", http.StatusInternalServerError)
+ }
+}
+
+func redirectWithWarning(w http.ResponseWriter, r *http.Request, baseURL, warningMsg string) {
+ redirectURL := baseURL
+ if warningMsg != "" {
+ redirectURL += "?warning=" + url.QueryEscape(warningMsg)
+ }
+ http.Redirect(w, r, redirectURL, http.StatusSeeOther)
+}
+
+func errorString(err error) string {
+ if err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
+func successString(err error, msg string) string {
+ if err == nil {
+ return msg
+ }
+ return ""
+}
+
+func buildPageData(title string, data interface{}) PageData {
+ tagMap, _ := getTagData()
+ return PageData{Title: title, Data: data, Tags: tagMap, GallerySize: config.GallerySize,}
+}
+
+func getTagData() (map[string][]TagDisplay, error) {
+ rows, err := db.Query(`
+ SELECT c.name, t.value, COUNT(ft.file_id)
+ FROM tags t
+ JOIN categories c ON c.id = t.category_id
+ LEFT JOIN file_tags ft ON ft.tag_id = t.id
+ GROUP BY t.id
+ HAVING COUNT(ft.file_id) > 0
+ ORDER BY c.name, t.value`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ tagMap := make(map[string][]TagDisplay)
+ for rows.Next() {
+ var cat, val string
+ var count int
+ rows.Scan(&cat, &val, &count)
+ tagMap[cat] = append(tagMap[cat], TagDisplay{Value: val, Count: count})
+ }
+ return tagMap, nil
+}
+
+func tagsHandler(w http.ResponseWriter, r *http.Request) {
+ pageData := buildPageData("All Tags", nil)
+ pageData.Data = pageData.Tags
+ renderTemplate(w, "tags.html", pageData)
+}
+\ No newline at end of file
diff --git a/include-pagination.go b/include-pagination.go
@@ -0,0 +1,65 @@
+package main
+
+func getUntaggedFilesPaginated(page, perPage int) ([]File, int, error) {
+ // Get total count
+ var total int
+ 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)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ offset := (page - 1) * perPage
+ files, err := queryFilesWithTags(`
+ SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description
+ FROM files f
+ LEFT JOIN file_tags ft ON ft.file_id = f.id
+ WHERE ft.file_id IS NULL
+ ORDER BY f.id DESC
+ LIMIT ? OFFSET ?
+ `, perPage, offset)
+
+ return files, total, err
+}
+
+func buildPageDataWithPagination(title string, data interface{}, page, total, perPage int) PageData {
+ pd := buildPageData(title, data)
+ pd.Pagination = calculatePagination(page, total, perPage)
+ return pd
+}
+
+func calculatePagination(page, total, perPage int) *Pagination {
+ totalPages := (total + perPage - 1) / perPage
+ if totalPages < 1 {
+ totalPages = 1
+ }
+
+ return &Pagination{
+ CurrentPage: page,
+ TotalPages: totalPages,
+ HasPrev: page > 1,
+ HasNext: page < totalPages,
+ PrevPage: page - 1,
+ NextPage: page + 1,
+ PerPage: perPage,
+ }
+}
+
+func getTaggedFilesPaginated(page, perPage int) ([]File, int, error) {
+ // Get total count
+ var total int
+ err := db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f JOIN file_tags ft ON ft.file_id = f.id`).Scan(&total)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ offset := (page - 1) * perPage
+ files, err := queryFilesWithTags(`
+ SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description
+ FROM files f
+ JOIN file_tags ft ON ft.file_id = f.id
+ ORDER BY f.id DESC
+ LIMIT ? OFFSET ?
+ `, perPage, offset)
+
+ return files, total, err
+}
+\ No newline at end of file
diff --git a/include-previews.go b/include-previews.go
@@ -0,0 +1,120 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+)
+
+// getPreviewFiles returns one representative file for each tag value in the specified category
+func getPreviewFiles(filters []filter) ([]File, error) {
+ // Find the preview filter category
+ var previewCategory string
+ for _, f := range filters {
+ if f.IsPreviews {
+ previewCategory = f.Category
+ break
+ }
+ }
+
+ if previewCategory == "" {
+ return []File{}, nil
+ }
+
+ // First, get all tag values for the preview category that have files
+ tagQuery := `
+ SELECT DISTINCT t.value
+ FROM tags t
+ JOIN categories c ON t.category_id = c.id
+ JOIN file_tags ft ON ft.tag_id = t.id
+ WHERE c.name = ?
+ ORDER BY t.value`
+
+ tagRows, err := db.Query(tagQuery, previewCategory)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query tag values: %w", err)
+ }
+ defer tagRows.Close()
+
+ var tagValues []string
+ for tagRows.Next() {
+ var tagValue string
+ if err := tagRows.Scan(&tagValue); err != nil {
+ return nil, fmt.Errorf("failed to scan tag value: %w", err)
+ }
+ tagValues = append(tagValues, tagValue)
+ }
+
+
+ if len(tagValues) == 0 {
+ return []File{}, nil
+ }
+
+ // For each tag value, find one representative file
+ var allFiles []File
+ for _, tagValue := range tagValues {
+ // Build query for this specific tag value with all filters applied
+ query := `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description
+ FROM files f
+ WHERE 1=1`
+ args := []interface{}{}
+
+ // Apply all filters (including the preview category with this specific value)
+ for _, filter := range filters {
+ if filter.IsPreviews {
+ // For the preview filter, use the current tag value we're iterating over
+ query += `
+ AND EXISTS (
+ SELECT 1
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ JOIN categories c ON c.id = t.category_id
+ WHERE ft.file_id = f.id AND c.name = ? AND t.value = ?
+ )`
+ args = append(args, filter.Category, tagValue)
+ } else if filter.Value == "unassigned" {
+ query += `
+ AND NOT EXISTS (
+ SELECT 1
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ JOIN categories c ON c.id = t.category_id
+ WHERE ft.file_id = f.id AND c.name = ?
+ )`
+ args = append(args, filter.Category)
+ } else {
+ // Normal filter with aliases
+ placeholders := make([]string, len(filter.Values))
+ for i := range filter.Values {
+ placeholders[i] = "?"
+ }
+
+ query += fmt.Sprintf(`
+ AND EXISTS (
+ SELECT 1
+ FROM file_tags ft
+ JOIN tags t ON ft.tag_id = t.id
+ JOIN categories c ON c.id = t.category_id
+ WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s)
+ )`, strings.Join(placeholders, ","))
+
+ args = append(args, filter.Category)
+ for _, v := range filter.Values {
+ args = append(args, v)
+ }
+ }
+ }
+
+ query += ` ORDER BY f.id DESC LIMIT 1`
+
+ files, err := queryFilesWithTags(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query files for tag %s: %w", tagValue, err)
+ }
+
+ if len(files) > 0 {
+ allFiles = append(allFiles, files[0])
+ }
+ }
+
+ return allFiles, nil
+}
+\ No newline at end of file
diff --git a/include-search.go b/include-search.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "database/sql"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+func searchHandler(w http.ResponseWriter, r *http.Request) {
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+
+ var files []File
+ var searchTitle string
+
+ if query != "" {
+ sqlPattern := "%" + strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(query), "*", "%"), "?", "_") + "%"
+
+ rows, err := db.Query(`
+ SELECT f.id, f.filename, f.path, COALESCE(f.description, '') AS description,
+ c.name AS category, t.value AS tag
+ FROM files f
+ LEFT JOIN file_tags ft ON ft.file_id = f.id
+ LEFT JOIN tags t ON t.id = ft.tag_id
+ LEFT JOIN categories c ON c.id = t.category_id
+ WHERE LOWER(f.filename) LIKE ? OR LOWER(f.description) LIKE ? OR LOWER(t.value) LIKE ?
+ ORDER BY f.filename
+ `, sqlPattern, sqlPattern, sqlPattern)
+ if err != nil {
+ renderError(w, "Search failed: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ fileMap := make(map[int]*File)
+ for rows.Next() {
+ var id int
+ var filename, path, description, category, tag sql.NullString
+
+ if err := rows.Scan(&id, &filename, &path, &description, &category, &tag); err != nil {
+ renderError(w, "Failed to read search results: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ f, exists := fileMap[id]
+ if !exists {
+ f = &File{
+ ID: id,
+ Filename: filename.String,
+ Path: path.String,
+ EscapedFilename: url.PathEscape(filename.String),
+ Description: description.String,
+ Tags: make(map[string][]string),
+ }
+ fileMap[id] = f
+ }
+
+ if category.Valid && tag.Valid && tag.String != "" {
+ f.Tags[category.String] = append(f.Tags[category.String], tag.String)
+ }
+ }
+
+ for _, f := range fileMap {
+ files = append(files, *f)
+ }
+
+ searchTitle = fmt.Sprintf("Search Results for: %s", query)
+ } else {
+ searchTitle = "Search Files"
+ }
+
+ pageData := buildPageData(searchTitle, files)
+ pageData.Query = query
+ pageData.Files = files
+ renderTemplate(w, "search.html", pageData)
+}
+\ No newline at end of file
diff --git a/include-types.go b/include-types.go
@@ -0,0 +1,100 @@
+package main
+
+type File struct {
+ ID int
+ Filename string
+ EscapedFilename string
+ Path string
+ Description string
+ Tags map[string][]string
+}
+
+type Config struct {
+ DatabasePath string `json:"database_path"`
+ UploadDir string `json:"upload_dir"`
+ ServerPort string `json:"server_port"`
+ InstanceName string `json:"instance_name"`
+ GallerySize string `json:"gallery_size"`
+ ItemsPerPage string `json:"items_per_page"`
+ TagAliases []TagAliasGroup `json:"tag_aliases"`
+}
+
+type Breadcrumb struct {
+ Name string
+ URL string
+}
+
+type TagAliasGroup struct {
+ Category string `json:"category"`
+ Aliases []string `json:"aliases"`
+}
+
+type TagDisplay struct {
+ Value string
+ Count int
+}
+
+type ListData struct {
+ Tagged []File
+ Untagged []File
+ Breadcrumbs []Breadcrumb
+}
+
+type PageData struct {
+ Title string
+ Data interface{}
+ Query string
+ IP string
+ Port string
+ Files []File
+ Tags map[string][]TagDisplay
+ Breadcrumbs []Breadcrumb
+ Pagination *Pagination
+ GallerySize string
+}
+
+type Pagination struct {
+ CurrentPage int
+ TotalPages int
+ HasPrev bool
+ HasNext bool
+ PrevPage int
+ NextPage int
+ PerPage int
+}
+
+type VideoFile struct {
+ ID int
+ Filename string
+ Path string
+ HasThumbnail bool
+ ThumbnailPath string
+ EscapedFilename string
+}
+
+type filter struct {
+ Category string
+ Value string
+ Values []string // Expanded values including aliases
+ IsPreviews bool // New field to indicate preview mode
+}
+
+type BulkTagFormData struct {
+ Categories []string
+ RecentFiles []File
+ Error string
+ Success string
+ FormData struct {
+ FileRange string
+ Category string
+ Value string
+ Operation string
+ TagQuery string
+ SelectionMode string
+ }
+}
+
+type TagPair struct {
+ Category string
+ Value string
+}
diff --git a/include-uploads.go b/include-uploads.go
@@ -0,0 +1,305 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+func uploadHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ pageData := buildPageData("Add File", nil)
+ renderTemplate(w, "add.html", pageData)
+ return
+ }
+
+ // Parse the multipart form (with max memory limit, e.g., 32MB)
+ err := r.ParseMultipartForm(32 << 20)
+ if err != nil {
+ renderError(w, "Failed to parse form", http.StatusBadRequest)
+ return
+ }
+
+ files := r.MultipartForm.File["file"]
+ if len(files) == 0 {
+ renderError(w, "No files uploaded", http.StatusBadRequest)
+ return
+ }
+
+ var warnings []string
+
+ // Process each file
+ for _, fileHeader := range files {
+ file, err := fileHeader.Open()
+ if err != nil {
+ renderError(w, "Failed to open uploaded file", http.StatusInternalServerError)
+ return
+ }
+ defer file.Close()
+
+ _, warningMsg, err := processUpload(file, fileHeader.Filename)
+ if err != nil {
+ renderError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if warningMsg != "" {
+ warnings = append(warnings, warningMsg)
+ }
+ }
+
+ var warningMsg string
+ if len(warnings) > 0 {
+ warningMsg = strings.Join(warnings, "; ")
+ }
+
+ redirectWithWarning(w, r, "/untagged", warningMsg)
+}
+
+func processUpload(src io.Reader, filename string) (int64, string, error) {
+ finalFilename, finalPath, err := checkFileConflictStrict(filename)
+ if err != nil {
+ return 0, "", err
+ }
+
+ tempPath := finalPath + ".tmp"
+ tempFile, err := os.Create(tempPath)
+ if err != nil {
+ return 0, "", fmt.Errorf("failed to create temp file: %v", err)
+ }
+
+ _, err = io.Copy(tempFile, src)
+ tempFile.Close()
+ if err != nil {
+ os.Remove(tempPath)
+ return 0, "", fmt.Errorf("failed to copy file data: %v", err)
+ }
+
+ ext := strings.ToLower(filepath.Ext(filename))
+ videoExts := map[string]bool{
+ ".mp4": true, ".mov": true, ".avi": true,
+ ".mkv": true, ".webm": true, ".m4v": true,
+ }
+
+ var processedPath string
+ var warningMsg string
+
+ if videoExts[ext] || ext == ".cbz" {
+ // Process videos and CBZ files
+ processedPath, warningMsg, err = processVideoFile(tempPath, finalPath)
+ if err != nil {
+ os.Remove(tempPath)
+ return 0, "", err
+ }
+ } else {
+ // Non-video, non-CBZ → just rename temp file to final
+ if err := os.Rename(tempPath, finalPath); err != nil {
+ return 0, "", fmt.Errorf("failed to move file: %v", err)
+ }
+ processedPath = finalPath
+ }
+
+ id, err := saveFileToDatabase(finalFilename, processedPath)
+ if err != nil {
+ os.Remove(processedPath)
+ return 0, "", err
+ }
+
+ return id, warningMsg, nil
+}
+
+func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Redirect(w, r, "/upload", http.StatusSeeOther)
+ return
+ }
+
+ fileURL := r.FormValue("fileurl")
+ if fileURL == "" {
+ renderError(w, "No URL provided", http.StatusBadRequest)
+ return
+ }
+
+ customFilename := strings.TrimSpace(r.FormValue("filename"))
+
+ parsedURL, err := url.ParseRequestURI(fileURL)
+ if err != nil || !(parsedURL.Scheme == "http" || parsedURL.Scheme == "https") {
+ renderError(w, "Invalid URL", http.StatusBadRequest)
+ return
+ }
+
+ resp, err := http.Get(fileURL)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ renderError(w, "Failed to download file", http.StatusBadRequest)
+ return
+ }
+ defer resp.Body.Close()
+
+ var filename string
+ urlExt := filepath.Ext(parsedURL.Path)
+ if customFilename != "" {
+ filename = customFilename
+ if filepath.Ext(filename) == "" && urlExt != "" {
+ filename += urlExt
+ }
+ } else {
+ parts := strings.Split(parsedURL.Path, "/")
+ filename = parts[len(parts)-1]
+ if filename == "" {
+ filename = "file_from_url"
+ }
+ }
+
+ id, warningMsg, err := processUpload(resp.Body, filename)
+ if err != nil {
+ renderError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg)
+}
+
+func ytdlpHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Redirect(w, r, "/upload", http.StatusSeeOther)
+ return
+ }
+
+ videoURL := r.FormValue("url")
+ if videoURL == "" {
+ renderError(w, "No URL provided", http.StatusBadRequest)
+ return
+ }
+
+ outTemplate := filepath.Join(config.UploadDir, "%(title)s.%(ext)s")
+ filenameCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, "--get-filename", videoURL)
+ filenameBytes, err := filenameCmd.Output()
+ if err != nil {
+ renderError(w, fmt.Sprintf("Failed to get filename: %v", err), http.StatusInternalServerError)
+ return
+ }
+ expectedFullPath := strings.TrimSpace(string(filenameBytes))
+ expectedFilename := filepath.Base(expectedFullPath)
+
+ finalFilename, finalPath, err := checkFileConflictStrict(expectedFilename)
+ if err != nil {
+ renderError(w, err.Error(), http.StatusConflict)
+ return
+ }
+
+ downloadCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, videoURL)
+ downloadCmd.Stdout = os.Stdout
+ downloadCmd.Stderr = os.Stderr
+ if err := downloadCmd.Run(); err != nil {
+ renderError(w, fmt.Sprintf("Failed to download video: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if expectedFullPath != finalPath {
+ if err := os.Rename(expectedFullPath, finalPath); err != nil {
+ renderError(w, fmt.Sprintf("Failed to move downloaded file: %v", err), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ tempPath := finalPath + ".tmp"
+ if err := os.Rename(finalPath, tempPath); err != nil {
+ renderError(w, fmt.Sprintf("Failed to create temp file for processing: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ processedPath, warningMsg, err := processVideoFile(tempPath, finalPath)
+ if err != nil {
+ os.Remove(tempPath)
+ renderError(w, fmt.Sprintf("Failed to process video: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ id, err := saveFileToDatabase(finalFilename, processedPath)
+ if err != nil {
+ os.Remove(processedPath)
+ renderError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg)
+}
+
+func processVideoFile(tempPath, finalPath string) (string, string, error) {
+ ext := strings.ToLower(filepath.Ext(finalPath))
+
+ // Handle CBZ files
+ if ext == ".cbz" {
+ if err := os.Rename(tempPath, finalPath); err != nil {
+ return "", "", fmt.Errorf("failed to move file: %v", err)
+ }
+ if err := generateCBZThumbnail(finalPath, config.UploadDir, filepath.Base(finalPath)); err != nil {
+ log.Printf("Warning: could not generate CBZ thumbnail: %v", err)
+ }
+ return finalPath, "", nil
+ }
+
+ // Handle video files
+ codec, err := detectVideoCodec(tempPath)
+ if err != nil {
+ return "", "", err
+ }
+ if codec == "hevc" || codec == "h265" {
+ warningMsg := "The video uses HEVC and has been re-encoded to H.264 for browser compatibility."
+ if err := reencodeHEVCToH264(tempPath, finalPath); err != nil {
+ return "", "", fmt.Errorf("failed to re-encode HEVC video: %v", err)
+ }
+ os.Remove(tempPath)
+ return finalPath, warningMsg, nil
+ }
+ if err := os.Rename(tempPath, finalPath); err != nil {
+ return "", "", fmt.Errorf("failed to move file: %v", err)
+ }
+
+ // Generate thumbnail for video files
+ if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".mkv" || ext == ".webm" || ext == ".m4v" {
+ if err := generateThumbnail(finalPath, config.UploadDir, filepath.Base(finalPath)); err != nil {
+ log.Printf("Warning: could not generate thumbnail: %v", err)
+ }
+ }
+
+ return finalPath, "", nil
+}
+
+func saveFileToDatabase(filename, path string) (int64, error) {
+ res, err := db.Exec("INSERT INTO files (filename, path, description) VALUES (?, ?, '')", filename, path)
+ if err != nil {
+ return 0, fmt.Errorf("failed to save file to database: %v", err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return 0, fmt.Errorf("failed to get inserted ID: %v", err)
+ }
+ return id, nil
+}
+
+
+func detectVideoCodec(filePath string) (string, error) {
+ cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
+ "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", filePath)
+ out, err := cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("failed to probe video codec: %v", err)
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+func reencodeHEVCToH264(inputPath, outputPath string) error {
+ cmd := exec.Command("ffmpeg", "-i", inputPath,
+ "-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23",
+ "-c:a", "aac", "-movflags", "+faststart", outputPath)
+ cmd.Stderr = os.Stderr
+ cmd.Stdout = os.Stdout
+ return cmd.Run()
+}
+\ No newline at end of file
diff --git a/include-viewer.go b/include-viewer.go
@@ -0,0 +1,243 @@
+package main
+
+import (
+ "database/sql"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+func getPreviousTagValue(category string, excludeFileID int) (string, error) {
+ var value string
+ err := db.QueryRow(`
+ SELECT t.value
+ FROM tags t
+ JOIN categories c ON c.id = t.category_id
+ JOIN file_tags ft ON ft.tag_id = t.id
+ JOIN files f ON f.id = ft.file_id
+ WHERE c.name = ? AND ft.file_id != ?
+ ORDER BY ft.rowid DESC
+ LIMIT 1
+ `, category, excludeFileID).Scan(&value)
+
+ if err == sql.ErrNoRows {
+ return "", fmt.Errorf("no previous tag found for category: %s", category)
+ }
+ if err != nil {
+ return "", err
+ }
+
+ return value, nil
+}
+
+func fileHandler(w http.ResponseWriter, r *http.Request) {
+ idStr := strings.TrimPrefix(r.URL.Path, "/file/")
+ if strings.Contains(idStr, "/") {
+ idStr = strings.SplitN(idStr, "/", 2)[0]
+ }
+
+ var f File
+ 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)
+ if err != nil {
+ renderError(w, "File not found", http.StatusNotFound)
+ return
+ }
+
+ f.Tags = make(map[string][]string)
+ rows, _ := db.Query(`
+ SELECT c.name, t.value
+ FROM tags t
+ JOIN categories c ON c.id = t.category_id
+ JOIN file_tags ft ON ft.tag_id = t.id
+ WHERE ft.file_id=?`, f.ID)
+ for rows.Next() {
+ var cat, val string
+ rows.Scan(&cat, &val)
+ f.Tags[cat] = append(f.Tags[cat], val)
+ }
+ rows.Close()
+
+ if r.Method == http.MethodPost {
+ if r.FormValue("action") == "update_description" {
+ description := r.FormValue("description")
+ if len(description) > 2048 {
+ description = description[:2048]
+ }
+
+ if _, err := db.Exec("UPDATE files SET description = ? WHERE id = ?", description, f.ID); err != nil {
+ renderError(w, "Failed to update description", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther)
+ return
+ }
+ cat := strings.TrimSpace(r.FormValue("category"))
+ val := strings.TrimSpace(r.FormValue("value"))
+ if cat != "" && val != "" {
+ originalVal := val
+ if val == "!" {
+ previousVal, err := getPreviousTagValue(cat, f.ID)
+ if err != nil {
+ http.Redirect(w, r, "/file/"+idStr+"?error="+url.QueryEscape("No previous tag found for category: "+cat), http.StatusSeeOther)
+ return
+ }
+ val = previousVal
+ }
+ _, tagID, err := getOrCreateCategoryAndTag(cat, val)
+ if err != nil {
+ http.Redirect(w, r, "/file/"+idStr+"?error="+url.QueryEscape("Failed to create tag: "+err.Error()), http.StatusSeeOther)
+ return
+ }
+ _, err = db.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", f.ID, tagID)
+ if err != nil {
+ http.Redirect(w, r, "/file/"+idStr+"?error="+url.QueryEscape("Failed to add tag: "+err.Error()), http.StatusSeeOther)
+ return
+ }
+ if originalVal == "!" {
+ http.Redirect(w, r, "/file/"+idStr+"?success="+url.QueryEscape("Tag '"+cat+": "+val+"' copied from previous file"), http.StatusSeeOther)
+ return
+ }
+ }
+ http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther)
+ return
+ }
+
+ catRows, _ := db.Query(`
+ SELECT DISTINCT c.name
+ FROM categories c
+ JOIN tags t ON t.category_id = c.id
+ JOIN file_tags ft ON ft.tag_id = t.id
+ ORDER BY c.name
+ `)
+ var cats []string
+ for catRows.Next() {
+ var c string
+ catRows.Scan(&c)
+ cats = append(cats, c)
+ }
+ catRows.Close()
+
+ pageData := buildPageDataWithIP(f.Filename, struct {
+ File File
+ Categories []string
+ EscapedFilename string
+ }{f, cats, url.PathEscape(f.Filename)})
+
+ renderTemplate(w, "file.html", pageData)
+}
+
+func buildPageDataWithIP(title string, data interface{}) PageData {
+ pageData := buildPageData(title, data)
+ ip, _ := getLocalIP()
+ pageData.IP = ip
+ pageData.Port = strings.TrimPrefix(config.ServerPort, ":")
+ return pageData
+}
+
+func getLocalIP() (string, error) {
+ addrs, err := net.InterfaceAddrs()
+ if err != nil {
+ return "", err
+ }
+ for _, addr := range addrs {
+ if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
+ if ipnet.IP.To4() != nil {
+ return ipnet.IP.String(), nil
+ }
+ }
+ }
+ return "", fmt.Errorf("no connected network interface found")
+}
+
+func tagActionHandler(w http.ResponseWriter, r *http.Request, parts []string) {
+ fileID := parts[2]
+ cat := parts[4]
+ val := parts[5]
+ action := parts[6]
+
+ if action == "delete" && r.Method == http.MethodPost {
+ var tagID int
+ db.QueryRow(`
+ SELECT t.id
+ FROM tags t
+ JOIN categories c ON c.id=t.category_id
+ WHERE c.name=? AND t.value=?`, cat, val).Scan(&tagID)
+ if tagID != 0 {
+ db.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID)
+ }
+ }
+ http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther)
+}
+
+func getOrCreateCategoryAndTag(category, value string) (int, int, error) {
+ category = strings.TrimSpace(category)
+ value = strings.TrimSpace(value)
+ var catID int
+ err := db.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID)
+ if err == sql.ErrNoRows {
+ res, err := db.Exec("INSERT INTO categories(name) VALUES(?)", category)
+ if err != nil {
+ return 0, 0, err
+ }
+ cid, _ := res.LastInsertId()
+ catID = int(cid)
+ } else if err != nil {
+ return 0, 0, err
+ }
+
+ var tagID int
+ if value != "" {
+ err = db.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID)
+ if err == sql.ErrNoRows {
+ res, err := db.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value)
+ if err != nil {
+ return 0, 0, err
+ }
+ tid, _ := res.LastInsertId()
+ tagID = int(tid)
+ } else if err != nil {
+ return 0, 0, err
+ }
+ }
+
+ return catID, tagID, nil
+}
+
+func listFilesHandler(w http.ResponseWriter, r *http.Request) {
+ // Get page number from query params
+ pageStr := r.URL.Query().Get("page")
+ page := 1
+ if pageStr != "" {
+ if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
+ page = p
+ }
+ }
+
+ // Get per page from config
+ perPage := 50
+ if config.ItemsPerPage != "" {
+ if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 {
+ perPage = pp
+ }
+ }
+
+ tagged, taggedTotal, _ := getTaggedFilesPaginated(page, perPage)
+ untagged, untaggedTotal, _ := getUntaggedFilesPaginated(page, perPage)
+
+ // Use the larger total for pagination
+ total := taggedTotal
+ if untaggedTotal > total {
+ total = untaggedTotal
+ }
+
+ pageData := buildPageDataWithPagination("File Browser", ListData{
+ Tagged: tagged,
+ Untagged: untagged,
+ Breadcrumbs: []Breadcrumb{},
+ }, page, total, perPage)
+
+ renderTemplate(w, "list.html", pageData)
+}
+\ No newline at end of file
diff --git a/main.go b/main.go
@@ -2,21 +2,12 @@ package main
import (
"database/sql"
- "encoding/json"
"fmt"
"html/template"
- "io"
- "io/ioutil"
"log"
- "net"
"net/http"
- "net/url"
"os"
- "os/exec"
- "path/filepath"
- "strconv"
"strings"
- "time"
_ "github.com/mattn/go-sqlite3"
)
@@ -27,299 +18,6 @@ var (
config Config
)
-type File struct {
- ID int
- Filename string
- EscapedFilename string
- Path string
- Description string
- Tags map[string][]string
-}
-
-type Config struct {
- DatabasePath string `json:"database_path"`
- UploadDir string `json:"upload_dir"`
- ServerPort string `json:"server_port"`
- InstanceName string `json:"instance_name"`
- GallerySize string `json:"gallery_size"`
- ItemsPerPage string `json:"items_per_page"`
- TagAliases []TagAliasGroup `json:"tag_aliases"`
-}
-
-type Breadcrumb struct {
- Name string
- URL string
-}
-
-type TagAliasGroup struct {
- Category string `json:"category"`
- Aliases []string `json:"aliases"`
-}
-
-type TagDisplay struct {
- Value string
- Count int
-}
-
-type ListData struct {
- Tagged []File
- Untagged []File
- Breadcrumbs []Breadcrumb
-}
-
-type PageData struct {
- Title string
- Data interface{}
- Query string
- IP string
- Port string
- Files []File
- Tags map[string][]TagDisplay
- Breadcrumbs []Breadcrumb
- Pagination *Pagination
- GallerySize string
-}
-
-type Pagination struct {
- CurrentPage int
- TotalPages int
- HasPrev bool
- HasNext bool
- PrevPage int
- NextPage int
- PerPage int
-}
-
-type VideoFile struct {
- ID int
- Filename string
- Path string
- HasThumbnail bool
- ThumbnailPath string
- EscapedFilename string
-}
-
-type filter struct {
- Category string
- Value string
- Values []string // Expanded values including aliases
- IsPreviews bool // New field to indicate preview mode
-}
-
-func expandTagWithAliases(category, value string) []string {
- values := []string{value}
-
- for _, group := range config.TagAliases {
- if group.Category != category {
- continue
- }
-
- // Check if the value is in this alias group
- found := false
- for _, alias := range group.Aliases {
- if strings.EqualFold(alias, value) {
- found = true
- break
- }
- }
-
- if found {
- // Add all aliases from this group
- for _, alias := range group.Aliases {
- if !strings.EqualFold(alias, value) {
- values = append(values, alias)
- }
- }
- break
- }
- }
-
- return values
-}
-
-func getOrCreateCategoryAndTag(category, value string) (int, int, error) {
- category = strings.TrimSpace(category)
- value = strings.TrimSpace(value)
- var catID int
- err := db.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID)
- if err == sql.ErrNoRows {
- res, err := db.Exec("INSERT INTO categories(name) VALUES(?)", category)
- if err != nil {
- return 0, 0, err
- }
- cid, _ := res.LastInsertId()
- catID = int(cid)
- } else if err != nil {
- return 0, 0, err
- }
-
- var tagID int
- if value != "" {
- err = db.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID)
- if err == sql.ErrNoRows {
- res, err := db.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value)
- if err != nil {
- return 0, 0, err
- }
- tid, _ := res.LastInsertId()
- tagID = int(tid)
- } else if err != nil {
- return 0, 0, err
- }
- }
-
- return catID, tagID, nil
-}
-
-func queryFilesWithTags(query string, args ...interface{}) ([]File, error) {
- rows, err := db.Query(query, args...)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var files []File
- for rows.Next() {
- var f File
- if err := rows.Scan(&f.ID, &f.Filename, &f.Path, &f.Description); err != nil {
- return nil, err
- }
- f.EscapedFilename = url.PathEscape(f.Filename)
- files = append(files, f)
- }
- return files, nil
-}
-
-func getTaggedFiles() ([]File, error) {
- return queryFilesWithTags(`
- SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description
- FROM files f
- JOIN file_tags ft ON ft.file_id = f.id
- ORDER BY f.id DESC
- `)
-}
-
-func getTaggedFilesPaginated(page, perPage int) ([]File, int, error) {
- // Get total count
- var total int
- err := db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f JOIN file_tags ft ON ft.file_id = f.id`).Scan(&total)
- if err != nil {
- return nil, 0, err
- }
-
- offset := (page - 1) * perPage
- files, err := queryFilesWithTags(`
- SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description
- FROM files f
- JOIN file_tags ft ON ft.file_id = f.id
- ORDER BY f.id DESC
- LIMIT ? OFFSET ?
- `, perPage, offset)
-
- return files, total, err
-}
-
-func getUntaggedFiles() ([]File, error) {
- return queryFilesWithTags(`
- SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description
- FROM files f
- LEFT JOIN file_tags ft ON ft.file_id = f.id
- WHERE ft.file_id IS NULL
- ORDER BY f.id DESC
- `)
-}
-
-func getUntaggedFilesPaginated(page, perPage int) ([]File, int, error) {
- // Get total count
- var total int
- 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)
- if err != nil {
- return nil, 0, err
- }
-
- offset := (page - 1) * perPage
- files, err := queryFilesWithTags(`
- SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description
- FROM files f
- LEFT JOIN file_tags ft ON ft.file_id = f.id
- WHERE ft.file_id IS NULL
- ORDER BY f.id DESC
- LIMIT ? OFFSET ?
- `, perPage, offset)
-
- return files, total, err
-}
-
-func buildPageData(title string, data interface{}) PageData {
- tagMap, _ := getTagData()
- return PageData{Title: title, Data: data, Tags: tagMap, GallerySize: config.GallerySize,}
-}
-
-func buildPageDataWithPagination(title string, data interface{}, page, total, perPage int) PageData {
- pd := buildPageData(title, data)
- pd.Pagination = calculatePagination(page, total, perPage)
- return pd
-}
-
-func calculatePagination(page, total, perPage int) *Pagination {
- totalPages := (total + perPage - 1) / perPage
- if totalPages < 1 {
- totalPages = 1
- }
-
- return &Pagination{
- CurrentPage: page,
- TotalPages: totalPages,
- HasPrev: page > 1,
- HasNext: page < totalPages,
- PrevPage: page - 1,
- NextPage: page + 1,
- PerPage: perPage,
- }
-}
-
-func buildPageDataWithIP(title string, data interface{}) PageData {
- pageData := buildPageData(title, data)
- ip, _ := getLocalIP()
- pageData.IP = ip
- pageData.Port = strings.TrimPrefix(config.ServerPort, ":")
- return pageData
-}
-
-func renderError(w http.ResponseWriter, message string, statusCode int) {
- http.Error(w, message, statusCode)
-}
-
-func renderTemplate(w http.ResponseWriter, tmplName string, data PageData) {
- if err := tmpl.ExecuteTemplate(w, tmplName, data); err != nil {
- renderError(w, "Template rendering failed", http.StatusInternalServerError)
- }
-}
-
-func getTagData() (map[string][]TagDisplay, error) {
- rows, err := db.Query(`
- SELECT c.name, t.value, COUNT(ft.file_id)
- FROM tags t
- JOIN categories c ON c.id = t.category_id
- LEFT JOIN file_tags ft ON ft.tag_id = t.id
- GROUP BY t.id
- HAVING COUNT(ft.file_id) > 0
- ORDER BY c.name, t.value`)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- tagMap := make(map[string][]TagDisplay)
- for rows.Next() {
- var cat, val string
- var count int
- rows.Scan(&cat, &val, &count)
- tagMap[cat] = append(tagMap[cat], TagDisplay{Value: val, Count: count})
- }
- return tagMap, nil
-}
-
func main() {
if err := loadConfig(); err != nil {
log.Fatalf("Failed to load config: %v", err)
@@ -363,6 +61,8 @@ func main() {
os.MkdirAll("static", 0755)
tmpl = template.Must(template.New("").Funcs(template.FuncMap{
+ "add": func(a, b int) int { return a + b },
+ "sub": func(a, b int) int { return a - b },
"hasAnySuffix": func(s string, suffixes ...string) bool {
for _, suf := range suffixes {
if strings.HasSuffix(strings.ToLower(s), suf) {
@@ -399,6 +99,7 @@ func main() {
http.HandleFunc("/bulk-tag", bulkTagHandler)
http.HandleFunc("/admin", adminHandler)
http.HandleFunc("/thumbnails/generate", generateThumbnailHandler)
+ http.HandleFunc("/cbz/", cbzViewerHandler)
http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(config.UploadDir))))
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
@@ -408,2183 +109,3 @@ func main() {
log.Printf("Upload directory: %s", config.UploadDir)
http.ListenAndServe(config.ServerPort, nil)
}
-
-func searchHandler(w http.ResponseWriter, r *http.Request) {
- query := strings.TrimSpace(r.URL.Query().Get("q"))
-
- var files []File
- var searchTitle string
-
- if query != "" {
- sqlPattern := "%" + strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(query), "*", "%"), "?", "_") + "%"
-
- rows, err := db.Query(`
- SELECT f.id, f.filename, f.path, COALESCE(f.description, '') AS description,
- c.name AS category, t.value AS tag
- FROM files f
- LEFT JOIN file_tags ft ON ft.file_id = f.id
- LEFT JOIN tags t ON t.id = ft.tag_id
- LEFT JOIN categories c ON c.id = t.category_id
- WHERE LOWER(f.filename) LIKE ? OR LOWER(f.description) LIKE ? OR LOWER(t.value) LIKE ?
- ORDER BY f.filename
- `, sqlPattern, sqlPattern, sqlPattern)
- if err != nil {
- renderError(w, "Search failed: "+err.Error(), http.StatusInternalServerError)
- return
- }
- defer rows.Close()
-
- fileMap := make(map[int]*File)
- for rows.Next() {
- var id int
- var filename, path, description, category, tag sql.NullString
-
- if err := rows.Scan(&id, &filename, &path, &description, &category, &tag); err != nil {
- renderError(w, "Failed to read search results: "+err.Error(), http.StatusInternalServerError)
- return
- }
-
- f, exists := fileMap[id]
- if !exists {
- f = &File{
- ID: id,
- Filename: filename.String,
- Path: path.String,
- EscapedFilename: url.PathEscape(filename.String),
- Description: description.String,
- Tags: make(map[string][]string),
- }
- fileMap[id] = f
- }
-
- if category.Valid && tag.Valid && tag.String != "" {
- f.Tags[category.String] = append(f.Tags[category.String], tag.String)
- }
- }
-
- for _, f := range fileMap {
- files = append(files, *f)
- }
-
- searchTitle = fmt.Sprintf("Search Results for: %s", query)
- } else {
- searchTitle = "Search Files"
- }
-
- pageData := buildPageData(searchTitle, files)
- pageData.Query = query
- pageData.Files = files
- renderTemplate(w, "search.html", pageData)
-}
-
-func processUpload(src io.Reader, filename string) (int64, string, error) {
- finalFilename, finalPath, err := checkFileConflictStrict(filename)
- if err != nil {
- return 0, "", err
- }
-
- tempPath := finalPath + ".tmp"
- tempFile, err := os.Create(tempPath)
- if err != nil {
- return 0, "", fmt.Errorf("failed to create temp file: %v", err)
- }
-
- _, err = io.Copy(tempFile, src)
- tempFile.Close()
- if err != nil {
- os.Remove(tempPath)
- return 0, "", fmt.Errorf("failed to copy file data: %v", err)
- }
-
- ext := strings.ToLower(filepath.Ext(filename))
- videoExts := map[string]bool{
- ".mp4": true, ".mov": true, ".avi": true,
- ".mkv": true, ".webm": true, ".m4v": true,
- }
-
- var processedPath string
- var warningMsg string
-
- if videoExts[ext] {
- processedPath, warningMsg, err = processVideoFile(tempPath, finalPath)
- if err != nil {
- os.Remove(tempPath)
- return 0, "", err
- }
- } else {
- // Non-video → just rename temp file to final
- if err := os.Rename(tempPath, finalPath); err != nil {
- return 0, "", fmt.Errorf("failed to move file: %v", err)
- }
- processedPath = finalPath
- }
-
- id, err := saveFileToDatabase(finalFilename, processedPath)
- if err != nil {
- os.Remove(processedPath)
- return 0, "", err
- }
-
- return id, warningMsg, nil
-}
-
-
-func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Redirect(w, r, "/upload", http.StatusSeeOther)
- return
- }
-
- fileURL := r.FormValue("fileurl")
- if fileURL == "" {
- renderError(w, "No URL provided", http.StatusBadRequest)
- return
- }
-
- customFilename := strings.TrimSpace(r.FormValue("filename"))
-
- parsedURL, err := url.ParseRequestURI(fileURL)
- if err != nil || !(parsedURL.Scheme == "http" || parsedURL.Scheme == "https") {
- renderError(w, "Invalid URL", http.StatusBadRequest)
- return
- }
-
- resp, err := http.Get(fileURL)
- if err != nil || resp.StatusCode != http.StatusOK {
- renderError(w, "Failed to download file", http.StatusBadRequest)
- return
- }
- defer resp.Body.Close()
-
- var filename string
- urlExt := filepath.Ext(parsedURL.Path)
- if customFilename != "" {
- filename = customFilename
- if filepath.Ext(filename) == "" && urlExt != "" {
- filename += urlExt
- }
- } else {
- parts := strings.Split(parsedURL.Path, "/")
- filename = parts[len(parts)-1]
- if filename == "" {
- filename = "file_from_url"
- }
- }
-
- id, warningMsg, err := processUpload(resp.Body, filename)
- if err != nil {
- renderError(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg)
-}
-
-func listFilesHandler(w http.ResponseWriter, r *http.Request) {
- // Get page number from query params
- pageStr := r.URL.Query().Get("page")
- page := 1
- if pageStr != "" {
- if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
- page = p
- }
- }
-
- // Get per page from config
- perPage := 50
- if config.ItemsPerPage != "" {
- if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 {
- perPage = pp
- }
- }
-
- tagged, taggedTotal, _ := getTaggedFilesPaginated(page, perPage)
- untagged, untaggedTotal, _ := getUntaggedFilesPaginated(page, perPage)
-
- // Use the larger total for pagination
- total := taggedTotal
- if untaggedTotal > total {
- total = untaggedTotal
- }
-
- pageData := buildPageDataWithPagination("File Browser", ListData{
- Tagged: tagged,
- Untagged: untagged,
- Breadcrumbs: []Breadcrumb{},
- }, page, total, perPage)
-
- renderTemplate(w, "list.html", pageData)
-}
-
-func untaggedFilesHandler(w http.ResponseWriter, r *http.Request) {
- // Get page number from query params
- pageStr := r.URL.Query().Get("page")
- page := 1
- if pageStr != "" {
- if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
- page = p
- }
- }
-
- // Get per page from config
- perPage := 50
- if config.ItemsPerPage != "" {
- if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 {
- perPage = pp
- }
- }
-
- files, total, _ := getUntaggedFilesPaginated(page, perPage)
- pageData := buildPageDataWithPagination("Untagged Files", files, page, total, perPage)
- renderTemplate(w, "untagged.html", pageData)
-}
-
-func uploadHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodGet {
- pageData := buildPageData("Add File", nil)
- renderTemplate(w, "add.html", pageData)
- return
- }
-
- // Parse the multipart form (with max memory limit, e.g., 32MB)
- err := r.ParseMultipartForm(32 << 20)
- if err != nil {
- renderError(w, "Failed to parse form", http.StatusBadRequest)
- return
- }
-
- files := r.MultipartForm.File["file"]
- if len(files) == 0 {
- renderError(w, "No files uploaded", http.StatusBadRequest)
- return
- }
-
- var warnings []string
-
- // Process each file
- for _, fileHeader := range files {
- file, err := fileHeader.Open()
- if err != nil {
- renderError(w, "Failed to open uploaded file", http.StatusInternalServerError)
- return
- }
- defer file.Close()
-
- _, warningMsg, err := processUpload(file, fileHeader.Filename)
- if err != nil {
- renderError(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- if warningMsg != "" {
- warnings = append(warnings, warningMsg)
- }
- }
-
- var warningMsg string
- if len(warnings) > 0 {
- warningMsg = strings.Join(warnings, "; ")
- }
-
- redirectWithWarning(w, r, "/untagged", warningMsg)
-}
-
-func redirectWithWarning(w http.ResponseWriter, r *http.Request, baseURL, warningMsg string) {
- redirectURL := baseURL
- if warningMsg != "" {
- redirectURL += "?warning=" + url.QueryEscape(warningMsg)
- }
- http.Redirect(w, r, redirectURL, http.StatusSeeOther)
-}
-
-func checkFileConflictStrict(filename string) (string, string, error) {
- finalPath := filepath.Join(config.UploadDir, filename)
- if _, err := os.Stat(finalPath); err == nil {
- return "", "", fmt.Errorf("a file with that name already exists")
- } else if !os.IsNotExist(err) {
- return "", "", fmt.Errorf("failed to check for existing file: %v", err)
- }
- return filename, finalPath, nil
-}
-
-func getLocalIP() (string, error) {
- addrs, err := net.InterfaceAddrs()
- if err != nil {
- return "", err
- }
- for _, addr := range addrs {
- if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
- if ipnet.IP.To4() != nil {
- return ipnet.IP.String(), nil
- }
- }
- }
- return "", fmt.Errorf("no connected network interface found")
-}
-
-func fileRouter(w http.ResponseWriter, r *http.Request) {
- parts := strings.Split(r.URL.Path, "/")
-
- if len(parts) >= 4 && parts[3] == "delete" {
- fileDeleteHandler(w, r, parts)
- return
- }
-
- if len(parts) >= 4 && parts[3] == "rename" {
- fileRenameHandler(w, r, parts)
- return
- }
-
- if len(parts) >= 7 && parts[3] == "tag" {
- tagActionHandler(w, r, parts)
- return
- }
-
- fileHandler(w, r)
-}
-
-func fileDeleteHandler(w http.ResponseWriter, r *http.Request, parts []string) {
- if r.Method != http.MethodPost {
- http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther)
- return
- }
-
- fileID := parts[2]
-
- var currentFile File
- err := db.QueryRow("SELECT id, filename, path FROM files WHERE id=?", fileID).Scan(¤tFile.ID, ¤tFile.Filename, ¤tFile.Path)
- if err != nil {
- renderError(w, "File not found", http.StatusNotFound)
- return
- }
-
- tx, err := db.Begin()
- if err != nil {
- renderError(w, "Failed to start transaction", http.StatusInternalServerError)
- return
- }
- defer tx.Rollback()
-
- if _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=?", fileID); err != nil {
- renderError(w, "Failed to delete file tags", http.StatusInternalServerError)
- return
- }
-
- if _, err = tx.Exec("DELETE FROM files WHERE id=?", fileID); err != nil {
- renderError(w, "Failed to delete file record", http.StatusInternalServerError)
- return
- }
-
- if err = tx.Commit(); err != nil {
- renderError(w, "Failed to commit transaction", http.StatusInternalServerError)
- return
- }
-
- if err = os.Remove(currentFile.Path); err != nil {
- log.Printf("Warning: Failed to delete physical file %s: %v", currentFile.Path, err)
- }
-
- // Delete thumbnail if it exists
- thumbPath := filepath.Join(config.UploadDir, "thumbnails", currentFile.Filename+".jpg")
- if _, err := os.Stat(thumbPath); err == nil {
- if err := os.Remove(thumbPath); err != nil {
- log.Printf("Warning: Failed to delete thumbnail %s: %v", thumbPath, err)
- }
- }
-
- http.Redirect(w, r, "/?deleted="+currentFile.Filename, http.StatusSeeOther)
-}
-
-func fileRenameHandler(w http.ResponseWriter, r *http.Request, parts []string) {
- if r.Method != http.MethodPost {
- http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther)
- return
- }
-
- fileID := parts[2]
- newFilename := sanitizeFilename(strings.TrimSpace(r.FormValue("newfilename")))
-
- if newFilename == "" {
- renderError(w, "New filename cannot be empty", http.StatusBadRequest)
- return
- }
-
- var currentFilename, currentPath string
- err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(¤tFilename, ¤tPath)
- if err != nil {
- renderError(w, "File not found", http.StatusNotFound)
- return
- }
-
- if currentFilename == newFilename {
- http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther)
- return
- }
-
- newPath := filepath.Join(config.UploadDir, newFilename)
- if _, err := os.Stat(newPath); !os.IsNotExist(err) {
- renderError(w, "A file with that name already exists", http.StatusConflict)
- return
- }
-
- if err := os.Rename(currentPath, newPath); err != nil {
- renderError(w, "Failed to rename physical file: "+err.Error(), http.StatusInternalServerError)
- return
- }
-
- thumbOld := filepath.Join(config.UploadDir, "thumbnails", currentFilename+".jpg")
- thumbNew := filepath.Join(config.UploadDir, "thumbnails", newFilename+".jpg")
-
- if _, err := os.Stat(thumbOld); err == nil {
- if err := os.Rename(thumbOld, thumbNew); err != nil {
- os.Rename(newPath, currentPath)
- renderError(w, "Failed to rename thumbnail: "+err.Error(), http.StatusInternalServerError)
- return
- }
- }
-
- _, err = db.Exec("UPDATE files SET filename=?, path=? WHERE id=?", newFilename, newPath, fileID)
- if err != nil {
- os.Rename(newPath, currentPath)
- if _, err := os.Stat(thumbNew); err == nil {
- os.Rename(thumbNew, thumbOld)
- }
- renderError(w, "Failed to update database", http.StatusInternalServerError)
- return
- }
-
- http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther)
-}
-
-func getPreviousTagValue(category string, excludeFileID int) (string, error) {
- var value string
- err := db.QueryRow(`
- SELECT t.value
- FROM tags t
- JOIN categories c ON c.id = t.category_id
- JOIN file_tags ft ON ft.tag_id = t.id
- JOIN files f ON f.id = ft.file_id
- WHERE c.name = ? AND ft.file_id != ?
- ORDER BY ft.rowid DESC
- LIMIT 1
- `, category, excludeFileID).Scan(&value)
-
- if err == sql.ErrNoRows {
- return "", fmt.Errorf("no previous tag found for category: %s", category)
- }
- if err != nil {
- return "", err
- }
-
- return value, nil
-}
-
-func fileHandler(w http.ResponseWriter, r *http.Request) {
- idStr := strings.TrimPrefix(r.URL.Path, "/file/")
- if strings.Contains(idStr, "/") {
- idStr = strings.SplitN(idStr, "/", 2)[0]
- }
-
- var f File
- 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)
- if err != nil {
- renderError(w, "File not found", http.StatusNotFound)
- return
- }
-
- f.Tags = make(map[string][]string)
- rows, _ := db.Query(`
- SELECT c.name, t.value
- FROM tags t
- JOIN categories c ON c.id = t.category_id
- JOIN file_tags ft ON ft.tag_id = t.id
- WHERE ft.file_id=?`, f.ID)
- for rows.Next() {
- var cat, val string
- rows.Scan(&cat, &val)
- f.Tags[cat] = append(f.Tags[cat], val)
- }
- rows.Close()
-
- if r.Method == http.MethodPost {
- if r.FormValue("action") == "update_description" {
- description := r.FormValue("description")
- if len(description) > 2048 {
- description = description[:2048]
- }
-
- if _, err := db.Exec("UPDATE files SET description = ? WHERE id = ?", description, f.ID); err != nil {
- renderError(w, "Failed to update description", http.StatusInternalServerError)
- return
- }
- http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther)
- return
- }
- cat := strings.TrimSpace(r.FormValue("category"))
- val := strings.TrimSpace(r.FormValue("value"))
- if cat != "" && val != "" {
- originalVal := val
- if val == "!" {
- previousVal, err := getPreviousTagValue(cat, f.ID)
- if err != nil {
- http.Redirect(w, r, "/file/"+idStr+"?error="+url.QueryEscape("No previous tag found for category: "+cat), http.StatusSeeOther)
- return
- }
- val = previousVal
- }
- _, tagID, err := getOrCreateCategoryAndTag(cat, val)
- if err != nil {
- http.Redirect(w, r, "/file/"+idStr+"?error="+url.QueryEscape("Failed to create tag: "+err.Error()), http.StatusSeeOther)
- return
- }
- _, err = db.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", f.ID, tagID)
- if err != nil {
- http.Redirect(w, r, "/file/"+idStr+"?error="+url.QueryEscape("Failed to add tag: "+err.Error()), http.StatusSeeOther)
- return
- }
- if originalVal == "!" {
- http.Redirect(w, r, "/file/"+idStr+"?success="+url.QueryEscape("Tag '"+cat+": "+val+"' copied from previous file"), http.StatusSeeOther)
- return
- }
- }
- http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther)
- return
- }
-
- catRows, _ := db.Query(`
- SELECT DISTINCT c.name
- FROM categories c
- JOIN tags t ON t.category_id = c.id
- JOIN file_tags ft ON ft.tag_id = t.id
- ORDER BY c.name
- `)
- var cats []string
- for catRows.Next() {
- var c string
- catRows.Scan(&c)
- cats = append(cats, c)
- }
- catRows.Close()
-
- pageData := buildPageDataWithIP(f.Filename, struct {
- File File
- Categories []string
- EscapedFilename string
- }{f, cats, url.PathEscape(f.Filename)})
-
- renderTemplate(w, "file.html", pageData)
-}
-
-func tagActionHandler(w http.ResponseWriter, r *http.Request, parts []string) {
- fileID := parts[2]
- cat := parts[4]
- val := parts[5]
- action := parts[6]
-
- if action == "delete" && r.Method == http.MethodPost {
- var tagID int
- db.QueryRow(`
- SELECT t.id
- FROM tags t
- JOIN categories c ON c.id=t.category_id
- WHERE c.name=? AND t.value=?`, cat, val).Scan(&tagID)
- if tagID != 0 {
- db.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID)
- }
- }
- http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther)
-}
-
-func tagsHandler(w http.ResponseWriter, r *http.Request) {
- pageData := buildPageData("All Tags", nil)
- pageData.Data = pageData.Tags
- renderTemplate(w, "tags.html", pageData)
-}
-
-func tagFilterHandler(w http.ResponseWriter, r *http.Request) {
- pageStr := r.URL.Query().Get("page")
- page := 1
- if pageStr != "" {
- if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
- page = p
- }
- }
-
- perPage := 50
- if config.ItemsPerPage != "" {
- if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 {
- perPage = pp
- }
- }
-
- fullPath := strings.TrimPrefix(r.URL.Path, "/tag/")
- tagPairs := strings.Split(fullPath, "/and/tag/")
-
- breadcrumbs := []Breadcrumb{
- {Name: "Home", URL: "/"},
- {Name: "Tags", URL: "/tags"},
- }
-
- var filters []filter
- currentPath := "/tag"
-
- for i, pair := range tagPairs {
- parts := strings.Split(pair, "/")
- if len(parts) != 2 {
- renderError(w, "Invalid tag filter path", http.StatusBadRequest)
- return
- }
-
- f := filter{
- Category: parts[0],
- Value: parts[1],
- IsPreviews: parts[1] == "previews",
- }
-
- // Expand with aliases (unless it's a special tag)
- if parts[1] != "unassigned" && parts[1] != "previews" {
- f.Values = expandTagWithAliases(parts[0], parts[1])
- }
-
- filters = append(filters, f)
-
- // Build breadcrumb path incrementally
- if i == 0 {
- currentPath += "/" + parts[0] + "/" + parts[1]
- } else {
- currentPath += "/and/tag/" + parts[0] + "/" + parts[1]
- }
-
- // Add category breadcrumb (only if it's the first occurrence)
- categoryExists := false
- for _, bc := range breadcrumbs {
- if bc.Name == parts[0] {
- categoryExists = true
- break
- }
- }
- if !categoryExists {
- breadcrumbs = append(breadcrumbs, Breadcrumb{
- Name: strings.Title(parts[0]),
- URL: "/tags#tag-" + parts[0],
- })
- }
-
- // Add value breadcrumb
- breadcrumbs = append(breadcrumbs, Breadcrumb{
- Name: strings.Title(parts[1]),
- URL: currentPath,
- })
- }
-
- // Check if we're in preview mode for any filter
- hasPreviewFilter := false
- for _, f := range filters {
- if f.IsPreviews {
- hasPreviewFilter = true
- break
- }
- }
-
- if hasPreviewFilter {
- // Handle preview mode
- files, err := getPreviewFiles(filters)
- if err != nil {
- renderError(w, "Failed to fetch preview files", http.StatusInternalServerError)
- return
- }
-
- var titleParts []string
- for _, f := range filters {
- titleParts = append(titleParts, fmt.Sprintf("%s: %s", f.Category, f.Value))
- }
- title := "Tagged: " + strings.Join(titleParts, " + ")
-
- pageData := buildPageDataWithPagination(title, ListData{
- Tagged: files,
- Untagged: nil,
- Breadcrumbs: []Breadcrumb{},
- }, 1, len(files), len(files))
- pageData.Breadcrumbs = breadcrumbs
-
- renderTemplate(w, "list.html", pageData)
- return
- }
-
- // Build count query (existing logic)
- countQuery := `SELECT COUNT(DISTINCT f.id) FROM files f WHERE 1=1`
- countArgs := []interface{}{}
-
- for _, f := range filters {
- if f.Value == "unassigned" {
- countQuery += `
- AND NOT EXISTS (
- SELECT 1
- FROM file_tags ft
- JOIN tags t ON ft.tag_id = t.id
- JOIN categories c ON c.id = t.category_id
- WHERE ft.file_id = f.id AND c.name = ?
- )`
- countArgs = append(countArgs, f.Category)
- } else {
- // Build OR clause for aliases
- placeholders := make([]string, len(f.Values))
- for i := range f.Values {
- placeholders[i] = "?"
- }
-
- countQuery += fmt.Sprintf(`
- AND EXISTS (
- SELECT 1
- FROM file_tags ft
- JOIN tags t ON ft.tag_id = t.id
- JOIN categories c ON c.id = t.category_id
- WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s)
- )`, strings.Join(placeholders, ","))
-
- countArgs = append(countArgs, f.Category)
- for _, v := range f.Values {
- countArgs = append(countArgs, v)
- }
- }
- }
-
- var total int
- err := db.QueryRow(countQuery, countArgs...).Scan(&total)
- if err != nil {
- renderError(w, "Failed to count files", http.StatusInternalServerError)
- return
- }
-
- // Build main query with pagination (existing logic)
- query := `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f WHERE 1=1`
- args := []interface{}{}
-
- for _, f := range filters {
- if f.Value == "unassigned" {
- query += `
- AND NOT EXISTS (
- SELECT 1
- FROM file_tags ft
- JOIN tags t ON ft.tag_id = t.id
- JOIN categories c ON c.id = t.category_id
- WHERE ft.file_id = f.id AND c.name = ?
- )`
- args = append(args, f.Category)
- } else {
- // Build OR clause for aliases
- placeholders := make([]string, len(f.Values))
- for i := range f.Values {
- placeholders[i] = "?"
- }
-
- query += fmt.Sprintf(`
- AND EXISTS (
- SELECT 1
- FROM file_tags ft
- JOIN tags t ON ft.tag_id = t.id
- JOIN categories c ON c.id = t.category_id
- WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s)
- )`, strings.Join(placeholders, ","))
-
- args = append(args, f.Category)
- for _, v := range f.Values {
- args = append(args, v)
- }
- }
- }
-
- offset := (page - 1) * perPage
- query += ` ORDER BY f.id DESC LIMIT ? OFFSET ?`
- args = append(args, perPage, offset)
-
- files, err := queryFilesWithTags(query, args...)
- if err != nil {
- renderError(w, "Failed to fetch files", http.StatusInternalServerError)
- return
- }
-
- var titleParts []string
- for _, f := range filters {
- titleParts = append(titleParts, fmt.Sprintf("%s: %s", f.Category, f.Value))
- }
- title := "Tagged: " + strings.Join(titleParts, ", ")
-
- pageData := buildPageDataWithPagination(title, ListData{
- Tagged: files,
- Untagged: nil,
- Breadcrumbs: []Breadcrumb{},
- }, page, total, perPage)
- pageData.Breadcrumbs = breadcrumbs
-
- renderTemplate(w, "list.html", pageData)
-}
-
-// getPreviewFiles returns one representative file for each tag value in the specified category
-func getPreviewFiles(filters []filter) ([]File, error) {
- // Find the preview filter category
- var previewCategory string
- for _, f := range filters {
- if f.IsPreviews {
- previewCategory = f.Category
- break
- }
- }
-
- if previewCategory == "" {
- return []File{}, nil
- }
-
- // First, get all tag values for the preview category that have files
- tagQuery := `
- SELECT DISTINCT t.value
- FROM tags t
- JOIN categories c ON t.category_id = c.id
- JOIN file_tags ft ON ft.tag_id = t.id
- WHERE c.name = ?
- ORDER BY t.value`
-
- tagRows, err := db.Query(tagQuery, previewCategory)
- if err != nil {
- return nil, fmt.Errorf("failed to query tag values: %w", err)
- }
- defer tagRows.Close()
-
- var tagValues []string
- for tagRows.Next() {
- var tagValue string
- if err := tagRows.Scan(&tagValue); err != nil {
- return nil, fmt.Errorf("failed to scan tag value: %w", err)
- }
- tagValues = append(tagValues, tagValue)
- }
-
-
- if len(tagValues) == 0 {
- return []File{}, nil
- }
-
- // For each tag value, find one representative file
- var allFiles []File
- for _, tagValue := range tagValues {
- // Build query for this specific tag value with all filters applied
- query := `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description
- FROM files f
- WHERE 1=1`
- args := []interface{}{}
-
- // Apply all filters (including the preview category with this specific value)
- for _, filter := range filters {
- if filter.IsPreviews {
- // For the preview filter, use the current tag value we're iterating over
- query += `
- AND EXISTS (
- SELECT 1
- FROM file_tags ft
- JOIN tags t ON ft.tag_id = t.id
- JOIN categories c ON c.id = t.category_id
- WHERE ft.file_id = f.id AND c.name = ? AND t.value = ?
- )`
- args = append(args, filter.Category, tagValue)
- } else if filter.Value == "unassigned" {
- query += `
- AND NOT EXISTS (
- SELECT 1
- FROM file_tags ft
- JOIN tags t ON ft.tag_id = t.id
- JOIN categories c ON c.id = t.category_id
- WHERE ft.file_id = f.id AND c.name = ?
- )`
- args = append(args, filter.Category)
- } else {
- // Normal filter with aliases
- placeholders := make([]string, len(filter.Values))
- for i := range filter.Values {
- placeholders[i] = "?"
- }
-
- query += fmt.Sprintf(`
- AND EXISTS (
- SELECT 1
- FROM file_tags ft
- JOIN tags t ON ft.tag_id = t.id
- JOIN categories c ON c.id = t.category_id
- WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s)
- )`, strings.Join(placeholders, ","))
-
- args = append(args, filter.Category)
- for _, v := range filter.Values {
- args = append(args, v)
- }
- }
- }
-
- query += ` ORDER BY f.id DESC LIMIT 1`
-
- files, err := queryFilesWithTags(query, args...)
- if err != nil {
- return nil, fmt.Errorf("failed to query files for tag %s: %w", tagValue, err)
- }
-
- if len(files) > 0 {
- allFiles = append(allFiles, files[0])
- }
- }
-
- return allFiles, nil
-}
-
-func loadConfig() error {
- config = Config{
- DatabasePath: "./database.db",
- UploadDir: "uploads",
- ServerPort: ":8080",
- InstanceName: "Taggart",
- GallerySize: "400px",
- ItemsPerPage: "100",
- TagAliases: []TagAliasGroup{},
- }
-
- if data, err := ioutil.ReadFile("config.json"); err == nil {
- if err := json.Unmarshal(data, &config); err != nil {
- return err
- }
- }
-
- return os.MkdirAll(config.UploadDir, 0755)
-}
-
-func saveConfig() error {
- data, err := json.MarshalIndent(config, "", " ")
- if err != nil {
- return err
- }
- return ioutil.WriteFile("config.json", data, 0644)
-}
-
-func validateConfig(newConfig Config) error {
- if newConfig.DatabasePath == "" {
- return fmt.Errorf("database path cannot be empty")
- }
-
- if newConfig.UploadDir == "" {
- return fmt.Errorf("upload directory cannot be empty")
- }
-
- if newConfig.ServerPort == "" || !strings.HasPrefix(newConfig.ServerPort, ":") {
- return fmt.Errorf("server port must be in format ':8080'")
- }
-
- if err := os.MkdirAll(newConfig.UploadDir, 0755); err != nil {
- return fmt.Errorf("cannot create upload directory: %v", err)
- }
-
- return nil
-}
-
-func adminHandler(w http.ResponseWriter, r *http.Request) {
- // Get orphaned files
- orphans, _ := getOrphanedFiles(config.UploadDir)
-
- // Get video files for thumbnails
- missingThumbnails, _ := getMissingThumbnailVideos()
-
- switch r.Method {
- case http.MethodPost:
- action := r.FormValue("action")
-
- switch action {
- case "save", "":
- handleSaveSettings(w, r, orphans, missingThumbnails)
- return
-
- case "backup":
- err := backupDatabase(config.DatabasePath)
- pageData := buildPageData("Admin", struct {
- Config Config
- Error string
- Success string
- Orphans []string
- MissingThumbnails []VideoFile
- }{
- Config: config,
- Error: errorString(err),
- Success: successString(err, "Database backup created successfully!"),
- Orphans: orphans,
- MissingThumbnails: missingThumbnails,
- })
- renderTemplate(w, "admin.html", pageData)
- return
-
- case "vacuum":
- err := vacuumDatabase(config.DatabasePath)
- pageData := buildPageData("Admin", struct {
- Config Config
- Error string
- Success string
- Orphans []string
- MissingThumbnails []VideoFile
- }{
- Config: config,
- Error: errorString(err),
- Success: successString(err, "Database vacuum completed successfully!"),
- Orphans: orphans,
- MissingThumbnails: missingThumbnails,
- })
- renderTemplate(w, "admin.html", pageData)
- return
-
- case "save_aliases":
- handleSaveAliases(w, r, orphans, missingThumbnails)
- return
- }
-
- default:
- pageData := buildPageData("Admin", struct {
- Config Config
- Error string
- Success string
- Orphans []string
- MissingThumbnails []VideoFile
- }{
- Config: config,
- Error: "",
- Success: "",
- Orphans: orphans,
- MissingThumbnails: missingThumbnails,
- })
- renderTemplate(w, "admin.html", pageData)
- }
-}
-
-func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphans []string, missingThumbnails []VideoFile) {
- aliasesJSON := r.FormValue("aliases_json")
-
- var aliases []TagAliasGroup
- if aliasesJSON != "" {
- if err := json.Unmarshal([]byte(aliasesJSON), &aliases); err != nil {
- pageData := buildPageData("Admin", struct {
- Config Config
- Error string
- Success string
- Orphans []string
- MissingThumbnails []VideoFile
- }{
- Config: config,
- Error: "Invalid aliases JSON: " + err.Error(),
- Success: "",
- Orphans: orphans,
- MissingThumbnails: missingThumbnails,
- })
- renderTemplate(w, "admin.html", pageData)
- return
- }
- }
-
- config.TagAliases = aliases
-
- if err := saveConfig(); err != nil {
- pageData := buildPageData("Admin", struct {
- Config Config
- Error string
- Success string
- Orphans []string
- MissingThumbnails []VideoFile
- }{
- Config: config,
- Error: "Failed to save configuration: " + err.Error(),
- Success: "",
- Orphans: orphans,
- MissingThumbnails: missingThumbnails,
- })
- renderTemplate(w, "admin.html", pageData)
- return
- }
-
- pageData := buildPageData("Admin", struct {
- Config Config
- Error string
- Success string
- Orphans []string
- MissingThumbnails []VideoFile
- }{
- Config: config,
- Error: "",
- Success: "Tag aliases saved successfully!",
- Orphans: orphans,
- MissingThumbnails: missingThumbnails,
- })
- renderTemplate(w, "admin.html", pageData)
-}
-
-func handleSaveSettings(w http.ResponseWriter, r *http.Request, orphans []string, missingThumbnails []VideoFile) {
- newConfig := Config{
- DatabasePath: strings.TrimSpace(r.FormValue("database_path")),
- UploadDir: strings.TrimSpace(r.FormValue("upload_dir")),
- ServerPort: strings.TrimSpace(r.FormValue("server_port")),
- InstanceName: strings.TrimSpace(r.FormValue("instance_name")),
- GallerySize: strings.TrimSpace(r.FormValue("gallery_size")),
- ItemsPerPage: strings.TrimSpace(r.FormValue("items_per_page")),
- TagAliases: config.TagAliases, // Preserve existing aliases
- }
-
- if err := validateConfig(newConfig); err != nil {
- pageData := buildPageData("Admin", struct {
- Config Config
- Error string
- Success string
- Orphans []string
- MissingThumbnails []VideoFile
- }{
- Config: config,
- Error: err.Error(),
- Success: "",
- Orphans: orphans,
- MissingThumbnails: missingThumbnails,
- })
- renderTemplate(w, "admin.html", pageData)
- return
- }
-
- needsRestart := (newConfig.DatabasePath != config.DatabasePath ||
- newConfig.ServerPort != config.ServerPort)
-
- config = newConfig
- if err := saveConfig(); err != nil {
- pageData := buildPageData("Admin", struct {
- Config Config
- Error string
- Success string
- Orphans []string
- MissingThumbnails []VideoFile
- }{
- Config: config,
- Error: "Failed to save configuration: " + err.Error(),
- Success: "",
- Orphans: orphans,
- MissingThumbnails: missingThumbnails,
- })
- renderTemplate(w, "admin.html", pageData)
- return
- }
-
- var message string
- if needsRestart {
- message = "Settings saved successfully! Please restart the server for database/port changes to take effect."
- } else {
- message = "Settings saved successfully!"
- }
-
- pageData := buildPageData("Admin", struct {
- Config Config
- Error string
- Success string
- Orphans []string
- MissingThumbnails []VideoFile
- }{
- Config: config,
- Error: "",
- Success: message,
- Orphans: orphans,
- MissingThumbnails: missingThumbnails,
- })
- renderTemplate(w, "admin.html", pageData)
-}
-
-
-func errorString(err error) string {
- if err != nil {
- return err.Error()
- }
- return ""
-}
-
-func successString(err error, msg string) string {
- if err == nil {
- return msg
- }
- return ""
-}
-
-func backupDatabase(dbPath string) error {
- if dbPath == "" {
- return fmt.Errorf("database path not configured")
- }
-
- timestamp := time.Now().Format("20060102_150405")
- backupPath := fmt.Sprintf("%s_backup_%s.db", strings.TrimSuffix(dbPath, filepath.Ext(dbPath)), timestamp)
-
- input, err := os.Open(dbPath)
- if err != nil {
- return fmt.Errorf("failed to open database: %w", err)
- }
- defer input.Close()
-
- output, err := os.Create(backupPath)
- if err != nil {
- return fmt.Errorf("failed to create backup file: %w", err)
- }
- defer output.Close()
-
- if _, err := io.Copy(output, input); err != nil {
- return fmt.Errorf("failed to copy database: %w", err)
- }
-
- return nil
-}
-
-func vacuumDatabase(dbPath string) error {
- db, err := sql.Open("sqlite3", dbPath)
- if err != nil {
- return fmt.Errorf("failed to open database: %w", err)
- }
- defer db.Close()
-
- _, err = db.Exec("VACUUM;")
- if err != nil {
- return fmt.Errorf("VACUUM failed: %w", err)
- }
-
- return nil
-}
-
-func ytdlpHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Redirect(w, r, "/upload", http.StatusSeeOther)
- return
- }
-
- videoURL := r.FormValue("url")
- if videoURL == "" {
- renderError(w, "No URL provided", http.StatusBadRequest)
- return
- }
-
- outTemplate := filepath.Join(config.UploadDir, "%(title)s.%(ext)s")
- filenameCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, "--get-filename", videoURL)
- filenameBytes, err := filenameCmd.Output()
- if err != nil {
- renderError(w, fmt.Sprintf("Failed to get filename: %v", err), http.StatusInternalServerError)
- return
- }
- expectedFullPath := strings.TrimSpace(string(filenameBytes))
- expectedFilename := filepath.Base(expectedFullPath)
-
- finalFilename, finalPath, err := checkFileConflictStrict(expectedFilename)
- if err != nil {
- renderError(w, err.Error(), http.StatusConflict)
- return
- }
-
- downloadCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, videoURL)
- downloadCmd.Stdout = os.Stdout
- downloadCmd.Stderr = os.Stderr
- if err := downloadCmd.Run(); err != nil {
- renderError(w, fmt.Sprintf("Failed to download video: %v", err), http.StatusInternalServerError)
- return
- }
-
- if expectedFullPath != finalPath {
- if err := os.Rename(expectedFullPath, finalPath); err != nil {
- renderError(w, fmt.Sprintf("Failed to move downloaded file: %v", err), http.StatusInternalServerError)
- return
- }
- }
-
- tempPath := finalPath + ".tmp"
- if err := os.Rename(finalPath, tempPath); err != nil {
- renderError(w, fmt.Sprintf("Failed to create temp file for processing: %v", err), http.StatusInternalServerError)
- return
- }
-
- processedPath, warningMsg, err := processVideoFile(tempPath, finalPath)
- if err != nil {
- os.Remove(tempPath)
- renderError(w, fmt.Sprintf("Failed to process video: %v", err), http.StatusInternalServerError)
- return
- }
-
- id, err := saveFileToDatabase(finalFilename, processedPath)
- if err != nil {
- os.Remove(processedPath)
- renderError(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg)
-}
-
-func parseFileIDRange(rangeStr string) ([]int, error) {
- var fileIDs []int
- parts := strings.Split(rangeStr, ",")
-
- for _, part := range parts {
- part = strings.TrimSpace(part)
- if part == "" {
- continue
- }
-
- if strings.Contains(part, "-") {
- rangeParts := strings.Split(part, "-")
- if len(rangeParts) != 2 {
- return nil, fmt.Errorf("invalid range format: %s", part)
- }
-
- start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
- if err != nil {
- return nil, fmt.Errorf("invalid start ID in range %s: %v", part, err)
- }
-
- end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
- if err != nil {
- return nil, fmt.Errorf("invalid end ID in range %s: %v", part, err)
- }
-
- if start > end {
- return nil, fmt.Errorf("invalid range %s: start must be <= end", part)
- }
-
- for i := start; i <= end; i++ {
- fileIDs = append(fileIDs, i)
- }
- } else {
- id, err := strconv.Atoi(part)
- if err != nil {
- return nil, fmt.Errorf("invalid file ID: %s", part)
- }
- fileIDs = append(fileIDs, id)
- }
- }
-
- uniqueIDs := make(map[int]bool)
- var result []int
- for _, id := range fileIDs {
- if !uniqueIDs[id] {
- uniqueIDs[id] = true
- result = append(result, id)
- }
- }
-
- return result, nil
-}
-
-func validateFileIDs(fileIDs []int) ([]File, error) {
- if len(fileIDs) == 0 {
- return nil, fmt.Errorf("no file IDs provided")
- }
-
- placeholders := make([]string, len(fileIDs))
- args := make([]interface{}, len(fileIDs))
- for i, id := range fileIDs {
- placeholders[i] = "?"
- args[i] = id
- }
-
- query := fmt.Sprintf("SELECT id, filename, path FROM files WHERE id IN (%s) ORDER BY id",
- strings.Join(placeholders, ","))
-
- rows, err := db.Query(query, args...)
- if err != nil {
- return nil, fmt.Errorf("database error: %v", err)
- }
- defer rows.Close()
-
- var files []File
- foundIDs := make(map[int]bool)
-
- for rows.Next() {
- var f File
- err := rows.Scan(&f.ID, &f.Filename, &f.Path)
- if err != nil {
- return nil, fmt.Errorf("error scanning file: %v", err)
- }
- files = append(files, f)
- foundIDs[f.ID] = true
- }
-
- var missingIDs []int
- for _, id := range fileIDs {
- if !foundIDs[id] {
- missingIDs = append(missingIDs, id)
- }
- }
-
- if len(missingIDs) > 0 {
- return files, fmt.Errorf("file IDs not found: %v", missingIDs)
- }
-
- return files, nil
-}
-
-func applyBulkTagOperations(fileIDs []int, category, value, operation string) error {
- category = strings.TrimSpace(category)
- value = strings.TrimSpace(value)
- if category == "" {
- return fmt.Errorf("category cannot be empty")
- }
-
- if operation == "add" && value == "" {
- return fmt.Errorf("value cannot be empty when adding tags")
- }
-
- tx, err := db.Begin()
- if err != nil {
- return fmt.Errorf("failed to start transaction: %v", err)
- }
- defer tx.Rollback()
-
- var catID int
- err = tx.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID)
- if err != nil && err != sql.ErrNoRows {
- return fmt.Errorf("failed to query category: %v", err)
- }
-
- if catID == 0 {
- if operation == "remove" {
- return fmt.Errorf("cannot remove non-existent category: %s", category)
- }
- res, err := tx.Exec("INSERT INTO categories(name) VALUES(?)", category)
- if err != nil {
- return fmt.Errorf("failed to create category: %v", err)
- }
- cid, _ := res.LastInsertId()
- catID = int(cid)
- }
-
- var tagID int
- if value != "" {
- err = tx.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID)
- if err != nil && err != sql.ErrNoRows {
- return fmt.Errorf("failed to query tag: %v", err)
- }
-
- if tagID == 0 {
- if operation == "remove" {
- return fmt.Errorf("cannot remove non-existent tag: %s=%s", category, value)
- }
- res, err := tx.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value)
- if err != nil {
- return fmt.Errorf("failed to create tag: %v", err)
- }
- tid, _ := res.LastInsertId()
- tagID = int(tid)
- }
- }
-
- for _, fileID := range fileIDs {
- if operation == "add" {
- _, err = tx.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", fileID, tagID)
- } else if operation == "remove" {
- if value != "" {
- _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID)
- } else {
- _, 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)
- }
- } else {
- return fmt.Errorf("invalid operation: %s (must be 'add' or 'remove')", operation)
- }
- if err != nil {
- return fmt.Errorf("failed to %s tag for file %d: %v", operation, fileID, err)
- }
- }
-
- return tx.Commit()
-}
-
-type BulkTagFormData struct {
- Categories []string
- RecentFiles []File
- Error string
- Success string
- FormData struct {
- FileRange string
- Category string
- Value string
- Operation string
- TagQuery string
- SelectionMode string
- }
-}
-
-func getBulkTagFormData() BulkTagFormData {
- catRows, _ := db.Query("SELECT name FROM categories ORDER BY name")
- var cats []string
- for catRows.Next() {
- var c string
- catRows.Scan(&c)
- cats = append(cats, c)
- }
- catRows.Close()
-
- recentRows, _ := db.Query("SELECT id, filename FROM files ORDER BY id DESC LIMIT 20")
- var recentFiles []File
- for recentRows.Next() {
- var f File
- recentRows.Scan(&f.ID, &f.Filename)
- recentFiles = append(recentFiles, f)
- }
- recentRows.Close()
-
- return BulkTagFormData{
- Categories: cats,
- RecentFiles: recentFiles,
- FormData: struct {
- FileRange string
- Category string
- Value string
- Operation string
- TagQuery string
- SelectionMode string
- }{Operation: "add"},
- }
-}
-
-func bulkTagHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodGet {
- formData := getBulkTagFormData()
- pageData := buildPageData("Bulk Tag Editor", formData)
- renderTemplate(w, "bulk-tag.html", pageData)
- return
- }
- if r.Method == http.MethodPost {
- rangeStr := strings.TrimSpace(r.FormValue("file_range"))
- tagQuery := strings.TrimSpace(r.FormValue("tag_query"))
- selectionMode := r.FormValue("selection_mode")
- category := strings.TrimSpace(r.FormValue("category"))
- value := strings.TrimSpace(r.FormValue("value"))
- operation := r.FormValue("operation")
-
- formData := getBulkTagFormData()
- formData.FormData.FileRange = rangeStr
- formData.FormData.TagQuery = tagQuery
- formData.FormData.SelectionMode = selectionMode
- formData.FormData.Category = category
- formData.FormData.Value = value
- formData.FormData.Operation = operation
-
- createErrorResponse := func(errorMsg string) {
- formData.Error = errorMsg
- pageData := buildPageData("Bulk Tag Editor", formData)
- renderTemplate(w, "bulk-tag.html", pageData)
- }
-
- // Validate selection mode
- if selectionMode == "" {
- selectionMode = "range" // default
- }
-
- // Validate inputs based on selection mode
- if selectionMode == "range" && rangeStr == "" {
- createErrorResponse("File range cannot be empty")
- return
- }
- if selectionMode == "tags" && tagQuery == "" {
- createErrorResponse("Tag query cannot be empty")
- return
- }
- if category == "" {
- createErrorResponse("Category cannot be empty")
- return
- }
- if operation == "add" && value == "" {
- createErrorResponse("Value cannot be empty when adding tags")
- return
- }
-
- // Get file IDs based on selection mode
- var fileIDs []int
- var err error
-
- if selectionMode == "range" {
- fileIDs, err = parseFileIDRange(rangeStr)
- if err != nil {
- createErrorResponse(fmt.Sprintf("Invalid file range: %v", err))
- return
- }
- } else if selectionMode == "tags" {
- fileIDs, err = getFileIDsFromTagQuery(tagQuery)
- if err != nil {
- createErrorResponse(fmt.Sprintf("Tag query error: %v", err))
- return
- }
- if len(fileIDs) == 0 {
- createErrorResponse("No files match the tag query")
- return
- }
- } else {
- createErrorResponse("Invalid selection mode")
- return
- }
-
- validFiles, err := validateFileIDs(fileIDs)
- if err != nil {
- createErrorResponse(fmt.Sprintf("File validation error: %v", err))
- return
- }
-
- err = applyBulkTagOperations(fileIDs, category, value, operation)
- if err != nil {
- createErrorResponse(fmt.Sprintf("Tag operation failed: %v", err))
- return
- }
-
- // Build success message
- var successMsg string
- var selectionDesc string
- if selectionMode == "range" {
- selectionDesc = fmt.Sprintf("file range '%s'", rangeStr)
- } else {
- selectionDesc = fmt.Sprintf("tag query '%s'", tagQuery)
- }
-
- if operation == "add" {
- successMsg = fmt.Sprintf("Tag '%s: %s' added to %d files matching %s",
- category, value, len(validFiles), selectionDesc)
- } else {
- if value != "" {
- successMsg = fmt.Sprintf("Tag '%s: %s' removed from %d files matching %s",
- category, value, len(validFiles), selectionDesc)
- } else {
- successMsg = fmt.Sprintf("All '%s' category tags removed from %d files matching %s",
- category, len(validFiles), selectionDesc)
- }
- }
-
- // Add file list
- var filenames []string
- for _, f := range validFiles {
- filenames = append(filenames, f.Filename)
- }
- if len(filenames) <= 5 {
- successMsg += fmt.Sprintf(": %s", strings.Join(filenames, ", "))
- } else {
- successMsg += fmt.Sprintf(": %s and %d more", strings.Join(filenames[:5], ", "), len(filenames)-5)
- }
-
- formData.Success = successMsg
- pageData := buildPageData("Bulk Tag Editor", formData)
- renderTemplate(w, "bulk-tag.html", pageData)
- return
- }
- renderError(w, "Method not allowed", http.StatusMethodNotAllowed)
-}
-
-// getFileIDsFromTagQuery parses a tag query and returns matching file IDs
-// Supports queries like:
-// - "colour:blue" (single tag)
-// - "colour:blue,size:large" (multiple tags - AND logic)
-// - "colour:blue OR colour:red" (OR logic)
-func getFileIDsFromTagQuery(query string) ([]int, error) {
- query = strings.TrimSpace(query)
- if query == "" {
- return nil, fmt.Errorf("empty query")
- }
-
- // Check if query contains OR operator
- if strings.Contains(strings.ToUpper(query), " OR ") {
- return getFileIDsFromORQuery(query)
- }
-
- // Otherwise treat as AND query (comma-separated or single tag)
- return getFileIDsFromANDQuery(query)
-}
-
-// getFileIDsFromANDQuery handles comma-separated tags (AND logic)
-func getFileIDsFromANDQuery(query string) ([]int, error) {
- tagPairs := strings.Split(query, ",")
- var tags []TagPair
-
- for _, pair := range tagPairs {
- pair = strings.TrimSpace(pair)
- if pair == "" {
- continue
- }
-
- parts := strings.SplitN(pair, ":", 2)
- if len(parts) != 2 {
- return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
- }
-
- tags = append(tags, TagPair{
- Category: strings.TrimSpace(parts[0]),
- Value: strings.TrimSpace(parts[1]),
- })
- }
-
- if len(tags) == 0 {
- return nil, fmt.Errorf("no valid tags found in query")
- }
-
- // Query database for files matching ALL tags
- return findFilesWithAllTags(tags)
-}
-
-// getFileIDsFromORQuery handles OR-separated tags
-func getFileIDsFromORQuery(query string) ([]int, error) {
- tagPairs := strings.Split(strings.ToUpper(query), " OR ")
- var tags []TagPair
-
- for _, pair := range tagPairs {
- pair = strings.TrimSpace(pair)
- if pair == "" {
- continue
- }
-
- parts := strings.SplitN(pair, ":", 2)
- if len(parts) != 2 {
- return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair)
- }
-
- tags = append(tags, TagPair{
- Category: strings.TrimSpace(parts[0]),
- Value: strings.TrimSpace(parts[1]),
- })
- }
-
- if len(tags) == 0 {
- return nil, fmt.Errorf("no valid tags found in query")
- }
-
- // Query database for files matching ANY tag
- return findFilesWithAnyTag(tags)
-}
-
-// TagPair represents a category-value pair
-type TagPair struct {
- Category string
- Value string
-}
-
-// findFilesWithAllTags returns file IDs that have ALL the specified tags
-func findFilesWithAllTags(tags []TagPair) ([]int, error) {
- if len(tags) == 0 {
- return nil, fmt.Errorf("no tags specified")
- }
-
- // Build query with subqueries for each tag
- query := `
- SELECT f.id
- FROM files f
- WHERE `
-
- var conditions []string
- var args []interface{}
- argIndex := 1
-
- for _, tag := range tags {
- conditions = append(conditions, fmt.Sprintf(`
- EXISTS (
- SELECT 1 FROM file_tags ft
- JOIN tags t ON ft.tag_id = t.id
- JOIN categories c ON t.category_id = c.id
- WHERE ft.file_id = f.id
- AND c.name = $%d
- AND t.value = $%d
- )`, argIndex, argIndex+1))
- args = append(args, tag.Category, tag.Value)
- argIndex += 2
- }
-
- query += strings.Join(conditions, " AND ")
- query += " ORDER BY f.id"
-
- rows, err := db.Query(query, args...)
- if err != nil {
- return nil, fmt.Errorf("database query failed: %w", err)
- }
- defer rows.Close()
-
- var fileIDs []int
- for rows.Next() {
- var id int
- if err := rows.Scan(&id); err != nil {
- return nil, fmt.Errorf("scan error: %w", err)
- }
- fileIDs = append(fileIDs, id)
- }
-
- return fileIDs, rows.Err()
-}
-
-// findFilesWithAnyTag returns file IDs that have ANY of the specified tags
-func findFilesWithAnyTag(tags []TagPair) ([]int, error) {
- if len(tags) == 0 {
- return nil, fmt.Errorf("no tags specified")
- }
-
- // Build query with OR conditions
- query := `
- SELECT DISTINCT f.id
- FROM files f
- INNER JOIN file_tags ft ON f.id = ft.file_id
- INNER JOIN tags t ON ft.tag_id = t.id
- INNER JOIN categories c ON t.category_id = c.id
- WHERE `
-
- var conditions []string
- var args []interface{}
- argIndex := 1
-
- for _, tag := range tags {
- conditions = append(conditions, fmt.Sprintf(
- "(c.name = $%d AND t.value = $%d)",
- argIndex, argIndex+1))
- args = append(args, tag.Category, tag.Value)
- argIndex += 2
- }
-
- query += strings.Join(conditions, " OR ")
- query += " ORDER BY f.id"
-
- rows, err := db.Query(query, args...)
- if err != nil {
- return nil, fmt.Errorf("database query failed: %w", err)
- }
- defer rows.Close()
-
- var fileIDs []int
- for rows.Next() {
- var id int
- if err := rows.Scan(&id); err != nil {
- return nil, fmt.Errorf("scan error: %w", err)
- }
- fileIDs = append(fileIDs, id)
- }
-
- return fileIDs, rows.Err()
-}
-
-func sanitizeFilename(filename string) string {
- if filename == "" {
- return "file"
- }
- filename = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(filename, "/", "_"), "\\", "_"), "..", "_")
- if filename == "" {
- return "file"
- }
- return filename
-}
-
-func detectVideoCodec(filePath string) (string, error) {
- cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
- "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", filePath)
- out, err := cmd.Output()
- if err != nil {
- return "", fmt.Errorf("failed to probe video codec: %v", err)
- }
- return strings.TrimSpace(string(out)), nil
-}
-
-func reencodeHEVCToH264(inputPath, outputPath string) error {
- cmd := exec.Command("ffmpeg", "-i", inputPath,
- "-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23",
- "-c:a", "aac", "-movflags", "+faststart", outputPath)
- cmd.Stderr = os.Stderr
- cmd.Stdout = os.Stdout
- return cmd.Run()
-}
-
-func processVideoFile(tempPath, finalPath string) (string, string, error) {
- codec, err := detectVideoCodec(tempPath)
- if err != nil {
- return "", "", err
- }
-
- if codec == "hevc" || codec == "h265" {
- warningMsg := "The video uses HEVC and has been re-encoded to H.264 for browser compatibility."
- if err := reencodeHEVCToH264(tempPath, finalPath); err != nil {
- return "", "", fmt.Errorf("failed to re-encode HEVC video: %v", err)
- }
- os.Remove(tempPath)
- return finalPath, warningMsg, nil
- }
-
- if err := os.Rename(tempPath, finalPath); err != nil {
- return "", "", fmt.Errorf("failed to move file: %v", err)
- }
-
- ext := strings.ToLower(filepath.Ext(finalPath))
- if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".mkv" || ext == ".webm" || ext == ".m4v" {
- if err := generateThumbnail(finalPath, config.UploadDir, filepath.Base(finalPath)); err != nil {
- log.Printf("Warning: could not generate thumbnail: %v", err)
- }
- }
-
- return finalPath, "", nil
-}
-
-func saveFileToDatabase(filename, path string) (int64, error) {
- res, err := db.Exec("INSERT INTO files (filename, path, description) VALUES (?, ?, '')", filename, path)
- if err != nil {
- return 0, fmt.Errorf("failed to save file to database: %v", err)
- }
- id, err := res.LastInsertId()
- if err != nil {
- return 0, fmt.Errorf("failed to get inserted ID: %v", err)
- }
- return id, nil
-}
-
-func getFilesOnDisk(uploadDir string) ([]string, error) {
- entries, err := os.ReadDir(uploadDir)
- if err != nil {
- return nil, err
- }
- var files []string
- for _, e := range entries {
- if !e.IsDir() {
- files = append(files, e.Name())
- }
- }
- return files, nil
-}
-
-func getFilesInDB() (map[string]bool, error) {
- rows, err := db.Query(`SELECT filename FROM files`)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- fileMap := make(map[string]bool)
- for rows.Next() {
- var name string
- rows.Scan(&name)
- fileMap[name] = true
- }
- return fileMap, nil
-}
-
-func getOrphanedFiles(uploadDir string) ([]string, error) {
- diskFiles, err := getFilesOnDisk(uploadDir)
- if err != nil {
- return nil, err
- }
-
- dbFiles, err := getFilesInDB()
- if err != nil {
- return nil, err
- }
-
- var orphans []string
- for _, f := range diskFiles {
- if !dbFiles[f] {
- orphans = append(orphans, f)
- }
- }
- return orphans, nil
-}
-
-func orphansHandler(w http.ResponseWriter, r *http.Request) {
- orphans, err := getOrphanedFiles(config.UploadDir)
- if err != nil {
- renderError(w, "Error reading orphaned files", http.StatusInternalServerError)
- return
- }
-
- pageData := buildPageData("Orphaned Files", orphans)
- renderTemplate(w, "orphans.html", pageData)
-}
-
-func generateThumbnailAtTime(videoPath, uploadDir, filename, timestamp string) error {
- thumbDir := filepath.Join(uploadDir, "thumbnails")
- if err := os.MkdirAll(thumbDir, 0755); err != nil {
- return fmt.Errorf("failed to create thumbnails directory: %v", err)
- }
-
- thumbPath := filepath.Join(thumbDir, filename+".jpg")
-
- cmd := exec.Command("ffmpeg", "-y", "-ss", timestamp, "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
-
- if err := cmd.Run(); err != nil {
- return fmt.Errorf("failed to generate thumbnail at %s: %v", timestamp, err)
- }
-
- return nil
-}
-
-func getVideoFiles() ([]VideoFile, error) {
- videoExts := []string{".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"}
-
- rows, err := db.Query(`SELECT id, filename, path FROM files ORDER BY id DESC`)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var videos []VideoFile
- for rows.Next() {
- var v VideoFile
- if err := rows.Scan(&v.ID, &v.Filename, &v.Path); err != nil {
- continue
- }
-
- // Check if it's a video file
- isVideo := false
- ext := strings.ToLower(filepath.Ext(v.Filename))
- for _, vidExt := range videoExts {
- if ext == vidExt {
- isVideo = true
- break
- }
- }
-
- if !isVideo {
- continue
- }
-
- v.EscapedFilename = url.PathEscape(v.Filename)
- thumbPath := filepath.Join(config.UploadDir, "thumbnails", v.Filename+".jpg")
- v.ThumbnailPath = "/uploads/thumbnails/" + v.EscapedFilename + ".jpg"
-
- if _, err := os.Stat(thumbPath); err == nil {
- v.HasThumbnail = true
- }
-
- videos = append(videos, v)
- }
-
- return videos, nil
-}
-
-func getMissingThumbnailVideos() ([]VideoFile, error) {
- allVideos, err := getVideoFiles()
- if err != nil {
- return nil, err
- }
-
- var missing []VideoFile
- for _, v := range allVideos {
- if !v.HasThumbnail {
- missing = append(missing, v)
- }
- }
-
- return missing, nil
-}
-
-func thumbnailsHandler(w http.ResponseWriter, r *http.Request) {
- allVideos, err := getVideoFiles()
- if err != nil {
- renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError)
- return
- }
-
- missing, err := getMissingThumbnailVideos()
- if err != nil {
- renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError)
- return
- }
-
- pageData := buildPageData("Thumbnail Management", struct {
- AllVideos []VideoFile
- MissingThumbnails []VideoFile
- Error string
- Success string
- }{
- AllVideos: allVideos,
- MissingThumbnails: missing,
- Error: r.URL.Query().Get("error"),
- Success: r.URL.Query().Get("success"),
- })
-
- renderTemplate(w, "thumbnails.html", pageData)
-}
-
-func generateThumbnailHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Redirect(w, r, "/admin", http.StatusSeeOther)
- return
- }
-
- action := r.FormValue("action")
- redirectTo := r.FormValue("redirect")
- if redirectTo == "" {
- redirectTo = "thumbnails"
- }
-
- redirectBase := "/" + redirectTo
-
- switch action {
- case "generate_all":
- missing, err := getMissingThumbnailVideos()
- if err != nil {
- http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to get videos: "+err.Error()), http.StatusSeeOther)
- return
- }
-
- successCount := 0
- var errors []string
-
- for _, v := range missing {
- err := generateThumbnail(v.Path, config.UploadDir, v.Filename)
- if err != nil {
- errors = append(errors, fmt.Sprintf("%s: %v", v.Filename, err))
- } else {
- successCount++
- }
- }
-
- if len(errors) > 0 {
- 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)
- } else {
- http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Successfully generated %d thumbnails", successCount)), http.StatusSeeOther)
- }
-
- case "generate_single":
- fileID := r.FormValue("file_id")
- timestamp := strings.TrimSpace(r.FormValue("timestamp"))
-
- if timestamp == "" {
- timestamp = "00:00:05"
- }
-
- var filename, path string
- err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(&filename, &path)
- if err != nil {
- http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("File not found"), http.StatusSeeOther)
- return
- }
-
- err = generateThumbnailAtTime(path, config.UploadDir, filename, timestamp)
- if err != nil {
- http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to generate thumbnail: "+err.Error()), http.StatusSeeOther)
- return
- }
-
- if redirectTo == "admin" {
- http.Redirect(w, r, "/admin?success="+url.QueryEscape(fmt.Sprintf("Thumbnail generated for file %s at %s", fileID, timestamp)), http.StatusSeeOther)
- } else {
- http.Redirect(w, r, fmt.Sprintf("/file/%s?success=%s", fileID, url.QueryEscape(fmt.Sprintf("Thumbnail generated at %s", timestamp))), http.StatusSeeOther)
- }
-
- default:
- http.Redirect(w, r, redirectBase, http.StatusSeeOther)
- }
-}
-
-func generateThumbnail(videoPath, uploadDir, filename string) error {
- thumbDir := filepath.Join(uploadDir, "thumbnails")
- if err := os.MkdirAll(thumbDir, 0755); err != nil {
- return fmt.Errorf("failed to create thumbnails directory: %v", err)
- }
-
- thumbPath := filepath.Join(thumbDir, filename+".jpg")
-
- cmd := exec.Command("ffmpeg", "-y", "-ss", "00:00:05", "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
-
- if err := cmd.Run(); err != nil {
- cmd := exec.Command("ffmpeg", "-y", "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- if err2 := cmd.Run(); err2 != nil {
- return fmt.Errorf("failed to generate thumbnail: %v", err2)
- }
- }
-
- return nil
-}