tagliatelle

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

commit f7635f5674a71164e9863c54103a3aa46a0d9283
parent 5fce4d0789de09cbf906bbf4a9d03345443fa80b
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Thu, 26 Feb 2026 12:16:05 +0000

Add computed properties functionality

Diffstat:
Minclude-admin.go | 3+++
Minclude-db.go | 8+++++++-
Minclude-files.go | 7+++++++
Minclude-general.go | 9++++++++-
Ainclude-properties.go | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minclude-routes.go | 2++
Minclude-types.go | 22++++++++++++++--------
Minclude-uploads.go | 1+
Minclude-viewer.go | 35++++++++++++++++++++++++++++++++---
Mstatic/style.css | 3+--
Mtemplates/_header.html | 10++++++++++
Mtemplates/admin.html | 14++++++++++++++
Mtemplates/file.html | 16++++++++++++++++
Atemplates/properties.html | 16++++++++++++++++
14 files changed, 382 insertions(+), 15 deletions(-)

diff --git a/include-admin.go b/include-admin.go @@ -119,6 +119,9 @@ func adminHandler(w http.ResponseWriter, r *http.Request) { case "save_sed_rules": handleSaveSedRules(w, r, orphanData, missingThumbnails) + + case "compute_properties": + handleComputeProperties(w, r, orphanData, missingThumbnails) } default: diff --git a/include-db.go b/include-db.go @@ -8,7 +8,7 @@ import ( // InitDatabase opens the database connection and creates tables if needed func InitDatabase(dbPath string) (*sql.DB, error) { - db, err := sql.Open("sqlite3", dbPath) + db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000") if err != nil { return nil, err } @@ -45,6 +45,12 @@ func createTables(db *sql.DB) error { tag_id INTEGER, UNIQUE(file_id, tag_id) ); + CREATE TABLE IF NOT EXISTS file_properties ( + file_id INTEGER, + key TEXT, + value TEXT, + UNIQUE(file_id, key) + ); CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY CHECK (id = 1), content TEXT DEFAULT '', diff --git a/include-files.go b/include-files.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" ) @@ -140,6 +141,12 @@ func fileRenameHandler(w http.ResponseWriter, r *http.Request, parts []string) { return } + // Recompute properties in case the extension changed + db.Exec("DELETE FROM file_properties WHERE file_id = ?", fileID) + if id, err := strconv.ParseInt(fileID, 10, 64); err == nil { + computeProperties(id, newPath) + } + http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) } diff --git a/include-general.go b/include-general.go @@ -51,7 +51,14 @@ func successString(err error, msg string) string { func buildPageData(title string, data interface{}) PageData { tagMap, _ := getTagData() - return PageData{Title: title, Data: data, Tags: tagMap, GallerySize: config.GallerySize,} + propMap, _ := getPropertyNav() + return PageData{ + Title: title, + Data: data, + Tags: tagMap, + Properties: propMap, + GallerySize: config.GallerySize, + } } func getTagData() (map[string][]TagDisplay, error) { diff --git a/include-properties.go b/include-properties.go @@ -0,0 +1,251 @@ +package main + +import ( + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +func computeProperties(fileID int64, filePath string) { + ext := strings.ToLower(filepath.Ext(filePath)) + + setProperty(fileID, "filetype", strings.TrimPrefix(ext, ".")) + + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp": + computeImageProperties(fileID, filePath) + case ".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v": + computeVideoProperties(fileID, filePath) + } +} + +func setProperty(fileID int64, key, value string) { + if value == "" { + return + } + _, err := db.Exec( + `INSERT OR IGNORE INTO file_properties (file_id, key, value) VALUES (?, ?, ?)`, + fileID, key, value, + ) + if err != nil { + log.Printf("Warning: failed to set property %s for file %d: %v", key, fileID, err) + } +} + +func computeImageProperties(fileID int64, filePath string) { + f, err := os.Open(filePath) + if err != nil { + log.Printf("Warning: could not open image for properties %s: %v", filePath, err) + return + } + defer f.Close() + + cfg, _, err := image.DecodeConfig(f) + if err != nil { + log.Printf("Warning: could not decode image config %s: %v", filePath, err) + return + } + + w, h := cfg.Width, cfg.Height + + var orientation string + switch { + case w > h: + orientation = "landscape" + case h > w: + orientation = "portrait" + default: + orientation = "square" + } + setProperty(fileID, "orientation", orientation) + + mp := w * h + var tier string + switch { + case mp < 1_000_000: + tier = "small" + case mp < 4_000_000: + tier = "medium" + case mp < 12_000_000: + tier = "large" + default: + tier = "huge" + } + setProperty(fileID, "resolution", tier) +} + +func computeVideoProperties(fileID int64, filePath string) { + cmd := exec.Command("ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=nokey=1:noprint_wrappers=1", + filePath, + ) + out, err := cmd.Output() + if err != nil { + log.Printf("Warning: ffprobe failed for %s: %v", filePath, err) + return + } + + seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if err != nil { + log.Printf("Warning: could not parse duration for %s: %v", filePath, err) + return + } + + var bucket string + switch { + case seconds < 60: + bucket = "tiny" + case seconds < 300: + bucket = "short" + case seconds < 2700: + bucket = "moderate" + default: + bucket = "long" + } + setProperty(fileID, "duration", bucket) +} + +func computeMissingProperties() (int, error) { + rows, err := db.Query(` + SELECT f.id, f.path + FROM files f + WHERE NOT EXISTS ( + SELECT 1 FROM file_properties fp WHERE fp.file_id = f.id + ) + `) + if err != nil { + return 0, fmt.Errorf("failed to query files: %w", err) + } + + type fileRow struct { + id int64 + path string + } + var files []fileRow + for rows.Next() { + var r fileRow + if err := rows.Scan(&r.id, &r.path); err != nil { + continue + } + files = append(files, r) + } + rows.Close() + + if err := rows.Err(); err != nil { + return 0, err + } + + for _, f := range files { + computeProperties(f.id, f.path) + } + return len(files), nil +} + +func getPropertyNav() (map[string][]PropertyDisplay, error) { + rows, err := db.Query(` + SELECT key, value, COUNT(*) as cnt + FROM file_properties + GROUP BY key, value + ORDER BY key, value + `) + if err != nil { + return nil, err + } + defer rows.Close() + + propMap := make(map[string][]PropertyDisplay) + for rows.Next() { + var key, val string + var count int + if err := rows.Scan(&key, &val, &count); err != nil { + continue + } + propMap[key] = append(propMap[key], PropertyDisplay{Value: val, Count: count}) + } + return propMap, nil +} + +func propertyFilterHandler(w http.ResponseWriter, r *http.Request) { + trimmed := strings.TrimPrefix(r.URL.Path, "/property/") + parts := strings.SplitN(trimmed, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + renderError(w, "Invalid property filter path", http.StatusBadRequest) + return + } + + key := parts[0] + value := parts[1] + + page := pageFromRequest(r) + perPage := perPageFromConfig(50) + + var total int + err := db.QueryRow(` + SELECT COUNT(DISTINCT f.id) + FROM files f + JOIN file_properties fp ON fp.file_id = f.id + WHERE fp.key = ? AND fp.value = ? + `, key, value).Scan(&total) + if err != nil { + renderError(w, "Failed to count files", http.StatusInternalServerError) + return + } + + offset := (page - 1) * perPage + files, err := queryFilesWithTags(` + SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description + FROM files f + JOIN file_properties fp ON fp.file_id = f.id + WHERE fp.key = ? AND fp.value = ? + ORDER BY f.id DESC + LIMIT ? OFFSET ? + `, key, value, perPage, offset) + if err != nil { + renderError(w, "Failed to fetch files", http.StatusInternalServerError) + return + } + + breadcrumbs := []Breadcrumb{ + {Name: "home", URL: "/"}, + {Name: "properties", URL: "/properties"}, + {Name: key, URL: "/properties#prop-" + key}, + {Name: value, URL: r.URL.Path}, + } + + title := fmt.Sprintf("%s: %s", key, value) + pageData := buildPageDataWithPagination(title, ListData{ + Tagged: files, + Breadcrumbs: breadcrumbs, + }, page, total, perPage, r) + pageData.Breadcrumbs = breadcrumbs + + renderTemplate(w, "list.html", pageData) +} + +func propertiesIndexHandler(w http.ResponseWriter, r *http.Request) { + pageData := buildPageData("Properties", nil) + pageData.Data = pageData.Properties + renderTemplate(w, "properties.html", pageData) +} + +func handleComputeProperties(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { + count, err := computeMissingProperties() + data := currentAdminState(r, orphanData, missingThumbnails) + if err != nil { + data.Error = "Property computation failed: " + err.Error() + } else { + data.Success = fmt.Sprintf("Computed properties for %d files.", count) + } + renderAdminPage(w, r, data) +} diff --git a/include-routes.go b/include-routes.go @@ -22,6 +22,8 @@ func RegisterRoutes() { http.HandleFunc("/notes/preview", notesPreviewHandler) http.HandleFunc("/notes/save", notesSaveHandler) http.HandleFunc("/notes/stats", notesStatsHandler) + http.HandleFunc("/properties", propertiesIndexHandler) + http.HandleFunc("/property/", propertyFilterHandler) http.HandleFunc("/search", searchHandler) http.HandleFunc("/tag/", tagFilterHandler) http.HandleFunc("/tags", tagsHandler) diff --git a/include-types.go b/include-types.go @@ -35,6 +35,11 @@ type TagDisplay struct { Count int } +type PropertyDisplay struct { + Value string + Count int +} + type ListData struct { Tagged []File Untagged []File @@ -42,15 +47,16 @@ type ListData struct { } type PageData struct { - Title string - Data interface{} - Query string - IP string - Port string - Files []File - Tags map[string][]TagDisplay + Title string + Data interface{} + Query string + IP string + Port string + Files []File + Tags map[string][]TagDisplay + Properties map[string][]PropertyDisplay Breadcrumbs []Breadcrumb - Pagination *Pagination + Pagination *Pagination GallerySize string } diff --git a/include-uploads.go b/include-uploads.go @@ -297,6 +297,7 @@ func saveFileToDatabase(filename, path string) (int64, error) { if err != nil { return 0, fmt.Errorf("failed to get inserted ID: %v", err) } + computeProperties(id, path) return id, nil } diff --git a/include-viewer.go b/include-viewer.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "net/url" + "strconv" "strings" ) @@ -119,11 +120,25 @@ func fileHandler(w http.ResponseWriter, r *http.Request) { } catRows.Close() + propRows, _ := db.Query(` + SELECT key, value FROM file_properties + WHERE file_id = ? + ORDER BY key, value + `, f.ID) + fileProps := make(map[string]string) + for propRows.Next() { + var k, v string + propRows.Scan(&k, &v) + fileProps[k] = v + } + propRows.Close() + pageData := buildPageDataWithIP(f.Filename, struct { File File Categories []string EscapedFilename string - }{f, cats, url.PathEscape(f.Filename)}) + Properties map[string]string + }{f, cats, url.PathEscape(f.Filename), fileProps}) renderTemplate(w, "file.html", pageData) } @@ -206,8 +221,22 @@ func getOrCreateCategoryAndTag(category, value string) (int, int, error) { } func listFilesHandler(w http.ResponseWriter, r *http.Request) { - page := pageFromRequest(r) - perPage := perPageFromConfig(50) + // Get page number from query params + pageStr := r.URL.Query().Get("page") + page := 1 + if pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + // Get per page from config + perPage := 50 + if config.ItemsPerPage != "" { + if pp, err := strconv.Atoi(config.ItemsPerPage); err == nil && pp > 0 { + perPage = pp + } + } tagged, taggedTotal, _ := getTaggedFilesPaginated(page, perPage) untagged, untaggedTotal, _ := getUntaggedFilesPaginated(page, perPage) diff --git a/static/style.css b/static/style.css @@ -26,8 +26,7 @@ nav ul li ul li:hover>ul,nav ul li:hover>ul{display:block} nav ul li ul{display:none;position:absolute;top:100%;left:0;z-index:1000;padding:0} nav ul.sub-menu, nav ul.sub-menu li ul {border: 1px solid gray} nav ul.sub-menu li {border-right: none} -nav ul.sub-menu li a:first-letter{text-transform:capitalize} -nav ul.sub-menu li a{color:#add8e6;background:#1a1a1a; min-width: 110px} +nav ul.sub-menu li a{color:#add8e6;background:#1a1a1a; min-width: 110px; text-transform:capitalize} nav ul.sub-menu li ul li a{min-width: 170px} nav ul.sub-menu li a:hover{background:#2a2a2a} diff --git a/templates/_header.html b/templates/_header.html @@ -30,6 +30,16 @@ <li><a href="/bulk-tag">Bulk Editor</a></li> <li><a href="/untagged">Untagged</a></li> </ul></li> +<li><a href="/properties"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="000000" d="M3.5 4A1.5 1.5 0 0 0 2 5.5v2A1.5 1.5 0 0 0 3.5 9h2A1.5 1.5 0 0 0 7 7.5v-2A1.5 1.5 0 0 0 5.5 4zM3 5.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5zM9.5 5a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zm-6 4A1.5 1.5 0 0 0 2 12.5v2A1.5 1.5 0 0 0 3.5 16h2A1.5 1.5 0 0 0 7 14.5v-2A1.5 1.5 0 0 0 5.5 11zM3 12.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5zm6.5-.5a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0 2a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></svg><span>Properties</span></a> + <ul class="sub-menu"> + {{range $key, $values := .Properties}}<li> + <a href="/properties#prop-{{$key}}">{{$key}}</a> + <ul> + {{range $values}}<li><a href="/property/{{$key}}/{{.Value}}" {{if eq $key "filetype"}}style="text-transform:lowercase"{{end}}>{{.Value}} ({{.Count}})</a></li> + {{end}} + </ul> + </li>{{end}} + </ul></li> <li><a href="/notes"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="000000" d="M7.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM7 10.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m.5 2.5a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1zm-1-11a.5.5 0 0 0-.5.5V3h-.5A1.5 1.5 0 0 0 4 4.5v12A1.5 1.5 0 0 0 5.5 18h6a.5.5 0 0 0 .354-.146l4-4A.5.5 0 0 0 16 13.5v-9A1.5 1.5 0 0 0 14.5 3H14v-.5a.5.5 0 0 0-1 0V3h-2.5v-.5a.5.5 0 0 0-1 0V3H7v-.5a.5.5 0 0 0-.5-.5m8 2a.5.5 0 0 1 .5.5V13h-2.5a1.5 1.5 0 0 0-1.5 1.5V17H5.5a.5.5 0 0 1-.5-.5v-12a.5.5 0 0 1 .5-.5zm-.207 10L12 16.293V14.5a.5.5 0 0 1 .5-.5z"/></svg><span>Notes</span></a></li> <li><a href="/admin"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="#000000" d="M9 6.5a4.5 4.5 0 0 1 6.352-4.102a.5.5 0 0 1 .148.809L13.207 5.5L14.5 6.793L16.793 4.5a.5.5 0 0 1 .809.147a4.5 4.5 0 0 1-5.207 6.216L6.03 17.311a2.357 2.357 0 0 1-3.374-3.293L9.082 7.36A4.52 4.52 0 0 1 9 6.5ZM13.5 3a3.5 3.5 0 0 0-3.387 4.386a.5.5 0 0 1-.125.473l-6.612 6.854a1.357 1.357 0 0 0 1.942 1.896l6.574-6.66a.5.5 0 0 1 .512-.124a3.5 3.5 0 0 0 4.521-4.044l-2.072 2.073a.5.5 0 0 1-.707 0l-2-2a.5.5 0 0 1 0-.708l2.073-2.072a3.518 3.518 0 0 0-.72-.074Z"/></svg><span>Admin</span></a></li> </ul> diff --git a/templates/admin.html b/templates/admin.html @@ -134,6 +134,20 @@ </button> <small style="color: #666; margin-left: 10px;">Reclaims unused space and optimizes database performance</small> </form> + + <hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;"> + <h3>File Properties</h3> + <p style="color: #666; margin-bottom: 10px;"> + Compute auto-detected properties (filetype, duration, orientation, resolution) for any files that do not yet have them. + </p> + <form method="post"> + <input type="hidden" name="active_tab" value="database"> + <input type="hidden" name="action" value="compute_properties"> + <button type="submit" style="background-color: #17a2b8; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> + Compute Missing Properties + </button> + <small style="color: #666; margin-left: 10px;">Processes only files with no existing properties</small> + </form> </div> <!-- Aliases Tab --> diff --git a/templates/file.html b/templates/file.html @@ -34,6 +34,22 @@ </details> <details> + <summary>Properties</summary> + {{if .Data.Properties}} + <ul> + {{range $k, $v := .Data.Properties}} + <li> + <span class="file-tag-category">{{$k}}:</span> + <a href="/property/{{$k}}/{{$v}}" {{if eq $k "filetype"}}style="text-transform:lowercase"{{end}}>{{$v}}</a> + </li> + {{end}} + </ul> + {{else}} + <p>No properties computed yet.</p> + {{end}} + </details> + + <details> <summary>Raw URL</summary> <input id="raw-url" value="http://{{.IP}}:{{.Port}}/uploads/{{.Data.EscapedFilename}}"><br> <button class="text-button" id="copy-btn">Copy</button><br> diff --git a/templates/properties.html b/templates/properties.html @@ -0,0 +1,16 @@ +{{template "_header" .}} +<h1>Properties</h1> + +{{range $key, $values := .Data}} +<details><summary><a href="#prop-{{$key}}" id="prop-{{$key}}">{{$key}}</a></summary> +<ul> + {{range $values}} + <li><a href="/property/{{$key}}/{{.Value}}" {{if eq $key "filetype"}}style="text-transform:lowercase"{{end}}>{{.Value}} ({{.Count}})</a></li> + {{end}} +</ul> +</details> +{{else}} +<p>No properties have been computed yet. Visit the <a href="/admin">Admin</a> page to compute them.</p> +{{end}} + +{{template "_footer"}}