taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

main.go (43697B)


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