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 }