taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

main.go (64965B)


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