package main import ( "encoding/json" "net/http" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // PpLabel mirrors the shape PhotoPrism's /api/v1/labels endpoint returns. // We decode enough to filter + recalculate PhotoCount; fields the client // doesn't render are skipped for token efficiency. type PpLabel struct { UID string `json:"UID"` Name string `json:"Name"` Slug string `json:"Slug"` CustomSlug string `json:"CustomSlug"` Priority int `json:"Priority"` Favorite bool `json:"Favorite"` PhotoCount int `json:"PhotoCount"` Thumb string `json:"Thumb"` CreatedAt string `json:"CreatedAt"` UpdatedAt string `json:"UpdatedAt"` } // handleLabels proxies PhotoPrism's /api/v1/labels and then post-filters // each label's PhotoCount (and removes labels with zero count) so they // reflect only photos under the caller's BasePath. // // Route: GET /api/sidecar/labels (behind requireSession) func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { token := ctxToken(c) basePath := ctxBasePath(c) // Forward the query string (count, offset, q, all, …) to PhotoPrism. query := c.Request.URL.RawQuery // Call PhotoPrism's labels endpoint using the caller's token. resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/labels?"+query, token, nil) if err != nil || !resp.OK { c.JSON(http.StatusBadGateway, gin.H{"error": "upstream labels request failed"}) return } // Decode labels. var labels []PpLabel if err := json.Unmarshal(resp.Body, &labels); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse labels"}) return } // If the user has no BasePath (admin/empty), return labels as-is. if basePath == "" || ppDb == nil { c.JSON(http.StatusOK, labels) return } // One query: count + a representative scoped thumb for every label // the user can see. Replaces N per-label queries with a single JOIN. prefix := basePath + "/%" type labelStat struct { LabelUID string `gorm:"column:label_uid"` Cnt int64 `gorm:"column:cnt"` ThumbHash string `gorm:"column:thumb_hash"` } var stats []labelStat if err := ppDb.Raw(` SELECT lb.label_uid AS label_uid, COUNT(DISTINCT p.id) AS cnt, COALESCE(MIN(f.file_hash), '') AS thumb_hash FROM photos_labels pl JOIN photos p ON pl.photo_id = p.id JOIN labels lb ON pl.label_id = lb.id LEFT JOIN files f ON f.photo_uid = p.photo_uid AND f.file_primary = 1 AND f.file_missing = 0 WHERE (p.photo_path = ? OR p.photo_path LIKE ?) AND p.deleted_at IS NULL GROUP BY lb.label_uid HAVING cnt > 0 `, basePath, prefix).Scan(&stats).Error; err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "label stats query failed"}) return } cntMap := make(map[string]int64, len(stats)) thumbMap := make(map[string]string, len(stats)) for _, s := range stats { cntMap[s.LabelUID] = s.Cnt thumbMap[s.LabelUID] = s.ThumbHash } filtered := make([]PpLabel, 0, len(stats)) for _, l := range labels { cnt, ok := cntMap[l.UID] if !ok || cnt == 0 { continue } l.PhotoCount = int(cnt) if th := thumbMap[l.UID]; th != "" { l.Thumb = th } filtered = append(filtered, l) } c.JSON(http.StatusOK, filtered) } } // Now also handle the session/config count scoping. // PpCounts mirrors PhotoPrism's session config.count block that drives // the sidebar badges (review, archive, all, etc.). type PpCounts struct { All int `json:"all"` Photos int `json:"photos"` Media int `json:"media"` Videos int `json:"videos"` Review int `json:"review"` Archived int `json:"archived"` Hidden int `json:"hidden"` Private int `json:"private"` Favorites int `json:"favorites"` } // handleScopedCounts returns user-scoped counts for review/archive/all // so the sidebar badges match what the user actually sees. // // Route: GET /api/sidecar/counts (behind requireSession) func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { basePath := ctxBasePath(c) if basePath == "" || ppDb == nil { // Admin or no DB — can't scope, return empty. c.JSON(http.StatusOK, PpCounts{}) return } prefix := basePath + "/%" pathCond := "(p.photo_path = ? OR p.photo_path LIKE ?)" args := []any{basePath, prefix} var counts PpCounts // All non-deleted photos in this user's scope. ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND `+pathCond, args...).Scan(&counts.All) // Photos needing review (quality < 3). ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_quality < 3 AND `+pathCond, args...).Scan(&counts.Review) // Archived (soft-deleted) photos. ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NOT NULL AND `+pathCond, args...).Scan(&counts.Archived) // Private photos. ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_private = 1 AND `+pathCond, args...).Scan(&counts.Private) // Photos (type image). ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('image','raw','live','animated') AND `+pathCond, args...).Scan(&counts.Photos) // Videos. ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('video','hdr','burst','live') AND `+pathCond, args...).Scan(&counts.Videos) // Favorites. ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_favorite = 1 AND `+pathCond, args...).Scan(&counts.Favorites) c.JSON(http.StatusOK, counts) } }