tagliatelle

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

include-properties.go (7562B)


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