tagliatelle

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

include-properties.go (7620B)


      1 package main
      2 
      3 import (
      4 	"fmt"
      5 	"image"
      6 	_ "image/gif"
      7 	_ "image/jpeg"
      8 	_ "image/png"
      9 	"log"
     10 	"net/http"
     11 	"os"
     12 	"os/exec"
     13 	"path/filepath"
     14 	"strconv"
     15 	"strings"
     16 )
     17 
     18 type PropertyDisplay struct {
     19 	Value string
     20 	Count int
     21 }
     22 
     23 func computeProperties(fileID int64, filePath string) {
     24 	ext := strings.ToLower(filepath.Ext(filePath))
     25 
     26 	setProperty(fileID, "filetype", strings.TrimPrefix(ext, "."))
     27 
     28 	switch ext {
     29 	case ".jpg", ".jpeg", ".png", ".gif", ".webp":
     30 		computeImageProperties(fileID, filePath)
     31 	case ".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v":
     32 		computeVideoProperties(fileID, filePath)
     33 	}
     34 }
     35 
     36 func setProperty(fileID int64, key, value string) {
     37 	if value == "" {
     38 		return
     39 	}
     40 	_, err := db.Exec(
     41 		`INSERT OR IGNORE INTO file_properties (file_id, key, value) VALUES (?, ?, ?)`,
     42 		fileID, key, value,
     43 	)
     44 	if err != nil {
     45 		log.Printf("Warning: failed to set property %s for file %d: %v", key, fileID, err)
     46 	}
     47 }
     48 
     49 func computeImageProperties(fileID int64, filePath string) {
     50 	f, err := os.Open(filePath)
     51 	if err != nil {
     52 		log.Printf("Warning: could not open image for properties %s: %v", filePath, err)
     53 		return
     54 	}
     55 	defer f.Close()
     56 
     57 	cfg, _, err := image.DecodeConfig(f)
     58 	if err != nil {
     59 		log.Printf("Warning: could not decode image config %s: %v", filePath, err)
     60 		return
     61 	}
     62 
     63 	w, h := cfg.Width, cfg.Height
     64 
     65 	var orientation string
     66 	switch {
     67 	case w > h:
     68 		orientation = "landscape"
     69 	case h > w:
     70 		orientation = "portrait"
     71 	default:
     72 		orientation = "square"
     73 	}
     74 	setProperty(fileID, "orientation", orientation)
     75 
     76 	mp := w * h
     77 	var tier string
     78 	switch {
     79 	case mp < 1_000_000:
     80 		tier = "small"
     81 	case mp < 4_000_000:
     82 		tier = "medium"
     83 	case mp < 12_000_000:
     84 		tier = "large"
     85 	default:
     86 		tier = "huge"
     87 	}
     88 	setProperty(fileID, "resolution", tier)
     89 }
     90 
     91 func computeVideoProperties(fileID int64, filePath string) {
     92 	cmd := exec.Command("ffprobe",
     93 		"-v", "error",
     94 		"-show_entries", "format=duration",
     95 		"-of", "default=nokey=1:noprint_wrappers=1",
     96 		filePath,
     97 	)
     98 	out, err := cmd.Output()
     99 	if err != nil {
    100 		log.Printf("Warning: ffprobe failed for %s: %v", filePath, err)
    101 		return
    102 	}
    103 
    104 	seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
    105 	if err != nil {
    106 		log.Printf("Warning: could not parse duration for %s: %v", filePath, err)
    107 		return
    108 	}
    109 
    110 	var bucket string
    111 	switch {
    112 	case seconds < 60:
    113 		bucket = "tiny"
    114 	case seconds < 300:
    115 		bucket = "short"
    116 	case seconds < 2700:
    117 		bucket = "moderate"
    118 	default:
    119 		bucket = "long"
    120 	}
    121 	setProperty(fileID, "duration", bucket)
    122 }
    123 
    124 func computeMissingProperties() (int, error) {
    125     rows, err := db.Query(`
    126         SELECT f.id, f.path
    127         FROM files f
    128         WHERE NOT EXISTS (
    129             SELECT 1 FROM file_properties fp WHERE fp.file_id = f.id
    130         )
    131     `)
    132     if err != nil {
    133         return 0, fmt.Errorf("failed to query files: %w", err)
    134     }
    135 
    136     type fileRow struct {
    137         id   int64
    138         path string
    139     }
    140     var files []fileRow
    141     for rows.Next() {
    142         var r fileRow
    143         if err := rows.Scan(&r.id, &r.path); err != nil {
    144             continue
    145         }
    146         files = append(files, r)
    147     }
    148     rows.Close()
    149 
    150     if err := rows.Err(); err != nil {
    151         return 0, err
    152     }
    153 
    154     for _, f := range files {
    155         computeProperties(f.id, f.path)
    156     }
    157     return len(files), nil
    158 }
    159 
    160 func getPropertyNav() (map[string][]PropertyDisplay, error) {
    161 	rows, err := db.Query(`
    162 		SELECT fp.key, fp.value, COUNT(*) as cnt
    163 		FROM file_properties fp
    164 		JOIN files f ON f.id = fp.file_id
    165 		GROUP BY fp.key, fp.value
    166 		ORDER BY fp.key, fp.value
    167 	`)
    168 	if err != nil {
    169 		return nil, err
    170 	}
    171 	defer rows.Close()
    172 
    173 	propMap := make(map[string][]PropertyDisplay)
    174 	for rows.Next() {
    175 		var key, val string
    176 		var count int
    177 		if err := rows.Scan(&key, &val, &count); err != nil {
    178 			continue
    179 		}
    180 		propMap[key] = append(propMap[key], PropertyDisplay{Value: val, Count: count})
    181 	}
    182 	return propMap, nil
    183 }
    184 
    185 func propertyFilterHandler(w http.ResponseWriter, r *http.Request) {
    186 	trimmed := strings.TrimPrefix(r.URL.Path, "/property/")
    187 
    188 	// If path contains /and/ delegate to the shared multi-filter
    189 	if strings.Contains(trimmed, "/and/") {
    190 		page := pageFromRequest(r)
    191 		perPage := perPageFromConfig(50)
    192 
    193 		filters, breadcrumbs, err := parseFilterSegments(trimmed, "property")
    194 		if err != nil {
    195 			renderError(w, "Invalid filter path", http.StatusBadRequest)
    196 			return
    197 		}
    198 
    199 		where, whereArgs := buildTagFilterWhere(filters)
    200 
    201 		var total int
    202 		countArgs := append([]interface{}(nil), whereArgs...)
    203 		err = db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f`+where, countArgs...).Scan(&total)
    204 		if err != nil {
    205 			log.Printf("Error: propertyFilterHandler: failed to count files: %v", err)
    206 			renderError(w, "Failed to count files", http.StatusInternalServerError)
    207 			return
    208 		}
    209 
    210 		offset := (page - 1) * perPage
    211 		dataArgs := append(append([]interface{}(nil), whereArgs...), perPage, offset)
    212 		files, err := queryFilesWithTags(
    213 			`SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f`+
    214 				where+` ORDER BY f.id DESC LIMIT ? OFFSET ?`,
    215 			dataArgs...,
    216 		)
    217 		if err != nil {
    218 			log.Printf("Error: propertyFilterHandler: failed to fetch files: %v", err)
    219 			renderError(w, "Failed to fetch files", http.StatusInternalServerError)
    220 			return
    221 		}
    222 
    223 		title := buildFilterTitle(filters, ", ")
    224 		pageData := buildPageDataWithPagination(title, ListData{
    225 			Tagged:      files,
    226 			Untagged:    nil,
    227 			Breadcrumbs: []Breadcrumb{},
    228 		}, page, total, perPage, r)
    229 		pageData.Breadcrumbs = breadcrumbs
    230 
    231 		renderTemplate(w, "list.html", pageData)
    232 		return
    233 	}
    234 
    235 	parts := strings.SplitN(trimmed, "/", 2)
    236 	if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
    237 		renderError(w, "Invalid property filter path", http.StatusBadRequest)
    238 		return
    239 	}
    240 
    241 	key := parts[0]
    242 	value := parts[1]
    243 
    244 	page := pageFromRequest(r)
    245 	perPage := perPageFromConfig(50)
    246 
    247 	var total int
    248 	err := db.QueryRow(`
    249 		SELECT COUNT(DISTINCT f.id)
    250 		FROM files f
    251 		JOIN file_properties fp ON fp.file_id = f.id
    252 		WHERE fp.key = ? AND fp.value = ?
    253 	`, key, value).Scan(&total)
    254 	if err != nil {
    255 		renderError(w, "Failed to count files", http.StatusInternalServerError)
    256 		return
    257 	}
    258 
    259 	offset := (page - 1) * perPage
    260 	files, err := queryFilesWithTags(`
    261 		SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description
    262 		FROM files f
    263 		JOIN file_properties fp ON fp.file_id = f.id
    264 		WHERE fp.key = ? AND fp.value = ?
    265 		ORDER BY f.id DESC
    266 		LIMIT ? OFFSET ?
    267 	`, key, value, perPage, offset)
    268 	if err != nil {
    269 		renderError(w, "Failed to fetch files", http.StatusInternalServerError)
    270 		return
    271 	}
    272 
    273 	breadcrumbs := []Breadcrumb{
    274 		{Name: "home", URL: "/"},
    275 		{Name: "properties", URL: "/properties"},
    276 		{Name: key, URL: "/properties#prop-" + key},
    277 		{Name: value, URL: r.URL.Path},
    278 	}
    279 
    280 	title := fmt.Sprintf("%s: %s", key, value)
    281 	pageData := buildPageDataWithPagination(title, ListData{
    282 		Tagged:      files,
    283 		Breadcrumbs: breadcrumbs,
    284 	}, page, total, perPage, r)
    285 	pageData.Breadcrumbs = breadcrumbs
    286 
    287 	renderTemplate(w, "list.html", pageData)
    288 }
    289 
    290 func propertiesIndexHandler(w http.ResponseWriter, r *http.Request) {
    291 	pageData := buildPageData("Properties", nil)
    292 	pageData.Data = pageData.Properties
    293 	renderTemplate(w, "properties.html", pageData)
    294 }
    295 
    296 func handleComputeProperties(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) {
    297 	count, err := computeMissingProperties()
    298 	data := currentAdminState(r, orphanData, missingThumbnails)
    299 	if err != nil {
    300 		data.Error = "Property computation failed: " + err.Error()
    301 	} else {
    302 		data.Success = fmt.Sprintf("Computed properties for %d files.", count)
    303 	}
    304 	renderAdminPage(w, r, data)
    305 }