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:
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"}}