Files
mule-image/sidecar/handlers_labels.go

171 lines
5.6 KiB
Go

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)
}
}