taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

main.go (68963B)


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