Files
mule-image/sidecar/main.go
dtoro 75008f238a feat(review): fast keyboard-first Stacks & Duplicates resolve queue
Both tabs get a resolve-and-advance queue instead of independent
click-to-focus cards: ↑/↓ or j/k rove between groups, resolving a
group removes it optimistically and auto-advances focus, and a sticky
header tracks reclaimable bytes + a running "resolved this session"
tally. ⌘Z undoes via a new sidecar restore endpoint (gridKeyNav — the
usual ⌘Z owner — isn't mounted on these tabs, so DuplicatesView wires
its own).

Sidecar (handlers_dups.go, fs.go, main.go):
- POST /duplicates/restore — inverse of /duplicates/archive, moves
  quarantined files back to their original path with the same BasePath
  guards and async reindex-with-cleanup.
- Scan results now include each file's mtime so the UI can label
  older/newer copies.

Stack losers now go through the same sidecar quarantine as
cross-folder duplicates (setPrimary + archiveDuplicatePaths) instead
of a hard PhotoPrism DELETE, so both tabs share one recoverable,
undoable resolution path (services/duplicateActions.svelte.ts).

StackGroupCard: comparison-first — fact rows highlight the best
size/resolution per file, a "Suggested" badge appears when one file
wins outright, and Space opens a fullscreen CompareLightbox that flips
between candidates while preserving zoom/pan (extracted the zoom/pan
gesture handling from PreviewPane into a shared lib/actions/zoomPan.ts
action so both consumers share one implementation).

CrossFolderGroupCard: since every copy is byte-identical, the old grid
of N identical thumbnails told the user nothing — replaced with one
thumbnail plus a path list that highlights the differing folder
segment and flags the indexed/newest copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 11:00:36 +02:00

164 lines
5.2 KiB
Go

// mule-sidecar — Go service for endpoints PhotoPrism does not expose.
//
// Ports the Node prototype (server.mjs) to the stack the merge plan calls
// out: Go + Gin + GORM + MariaDB. Same wire contract as the prototype so
// the SvelteKit web client doesn't need to change.
//
// Auth model is unchanged: the caller's X-Auth-Token is the only authority.
// requireSession validates it against PhotoPrism's /api/v1/photos before
// any destructive op runs.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
cfg, err := loadConfig()
if err != nil {
slog.Error("config", "err", err)
os.Exit(1)
}
db, err := openDB(cfg.DSN)
if err != nil {
slog.Error("db open", "err", err)
os.Exit(1)
}
pp := newPPClient(cfg.PhotoprismBaseURL)
// Apply any declared username→BasePath mapping to PhotoPrism's
// auth_users table. Runs immediately + every 60s thereafter so a
// user who logs in after the sidecar booted still gets their
// BasePath wired without an admin restart.
startUserBasepathReconciler(cfg)
// Open a second DB handle pointed at PhotoPrism's own schema for
// handlers that need to query auth_users, photos, labels, etc.
// May be nil if PpDSN is empty (no PP_DB_PASSWORD set).
var ppDb *gorm.DB
if cfg.PpDSN != "" {
if d, err := openDB(cfg.PpDSN); err == nil {
ppDb = d
} else {
slog.Warn("pp db open failed — scoped labels/counts unavailable", "err", err)
}
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// Keep `%2F` literal in path params so callers can pass URL-encoded
// nested folder paths (e.g. `foo%2Fbar`) without the router splitting
// them into separate segments. Handlers decode via url.PathUnescape.
r.UseRawPath = true
r.UnescapePathValues = false
r.Use(gin.Recovery())
// Health probe — unauthenticated so a process supervisor can call it
// without needing PhotoPrism to be reachable.
r.GET("/api/sidecar/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"ok": true,
"originalsRoot": cfg.OriginalsRoot,
})
})
// Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/prefs", handlePrefsGet(db))
auth.PUT("/prefs", handlePrefsPut(cfg, db))
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
auth.POST("/duplicates/restore", handleDupRestore(cfg, pp, db))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb))
auth.GET("/countries", handleCountries(ppDb))
}
// User-scoped photos — post-filters by BasePath so review/archive
// tabs only show photos the user owns.
auth.GET("/timeline", handlePhotos(pp))
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
// the /notes view isn't capped to the newest slice.
auth.GET("/notes", handleNotes(pp))
// User-scoped folders — post-filters the folder tree by BasePath
// so the sidebar shows only folders under the user's library root.
auth.GET("/folders", handleFoldersProxy(pp))
}
// PhotoPrism-compatible scoped proxy — the public /api/v1 surface for
// both the web client and third-party PhotoPrism apps (Caddy routes
// /api/v1 here instead of straight to PhotoPrism, which does not
// enforce base_path in CE). See handlers_ppproxy.go for the rules.
r.Any("/api/v1/*rest", handlePPProxy(cfg, ppDb))
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
}
// Graceful shutdown so an in-flight duplicate scan or heap convert
// gets a chance to finish (or at least flush logs) on SIGTERM.
idleClosed := make(chan struct{})
go func() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
slog.Info("shutdown signal received")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
close(idleClosed)
}()
slog.Info("mule-sidecar listening",
"addr", "http://"+addr,
"originals", cfg.OriginalsRoot,
)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("listen", "err", err)
os.Exit(1)
}
<-idleClosed
}