taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

main.go (43984B)


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