fix(photos): faster large-album loading and album cover lifecycle

Large albums opened slowly and flickered; the default album cover was
also lost in the media-objects refactor.

Large-album loading:
- quantize signed object URLs within a window and send Cache-Control so
  previews are reused instead of re-fetched
- publish photo.updated (not photo.created) when variants are ready, so
  clients update in place without reordering the grid
- worker concurrency + newest-first variant priority; raise default batch

Album cover:
- first uploaded photo becomes the cover when none is set
- clear the cover when the cover photo is trashed or moved out
- "set as album cover" in the photo context menu and actions panel

OpenSpec changes: fix-large-album-load, fix-album-cover
This commit is contained in:
iu
2026-10-11 14:15:59 +03:00
parent 6361e48b20
commit d7a37328f0
40 changed files with 999 additions and 57 deletions
+48 -13
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"log/slog"
"sync"
"time"
"galo/internal/auth"
@@ -16,15 +17,16 @@ const maxVariantAttempts = 5
// and the object garbage collector entirely from the state recorded in the
// metadata layer, so it resumes correctly after a restart.
type Worker struct {
svc *Service
interval time.Duration
batch int
pendingTTL time.Duration
lease time.Duration
svc *Service
interval time.Duration
batch int
pendingTTL time.Duration
lease time.Duration
concurrency int
}
// NewWorker builds a worker. Parameters are clamped to sane defaults.
func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time.Duration) *Worker {
func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time.Duration, concurrency int) *Worker {
if interval <= 0 {
interval = 5 * time.Second
}
@@ -34,12 +36,16 @@ func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time.
if pendingTTL <= 0 {
pendingTTL = 10 * time.Minute
}
if concurrency <= 0 {
concurrency = 4
}
return &Worker{
svc: svc,
interval: interval,
batch: batch,
pendingTTL: pendingTTL,
lease: interval * 4,
svc: svc,
interval: interval,
batch: batch,
pendingTTL: pendingTTL,
lease: interval * 4,
concurrency: concurrency,
}
}
@@ -71,9 +77,36 @@ func (w *Worker) processVariantJobs(ctx context.Context) {
slog.Error("claim variant jobs", "error", err)
return
}
for _, id := range ids {
w.forEachConcurrent(ids, func(id string) {
w.generateVariants(ctx, id)
})
}
// forEachConcurrent runs fn over ids with at most w.concurrency functions in
// flight. Generation is CPU-bound (decode + resize + encode), so parallelism
// scales with cores; a concurrency of 1 stays strictly sequential.
func (w *Worker) forEachConcurrent(ids []string, fn func(string)) {
if len(ids) == 0 {
return
}
if w.concurrency <= 1 {
for _, id := range ids {
fn(id)
}
return
}
sem := make(chan struct{}, w.concurrency)
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
sem <- struct{}{}
go func(id string) {
defer wg.Done()
defer func() { <-sem }()
fn(id)
}(id)
}
wg.Wait()
}
func (w *Worker) generateVariants(ctx context.Context, photoID string) {
@@ -162,7 +195,9 @@ func (w *Worker) notify(ctx context.Context, photoID string) {
if err := w.svc.attachURLs(ctx, p); err != nil {
return
}
w.svc.hub.Publish(p.AlbumID, "photo.created", p)
// Variant readiness updates an existing photo; it is not a new photo in the
// album, so clients update it in place instead of inserting at the top.
w.svc.hub.Publish(p.AlbumID, "photo.updated", p)
}
func (w *Worker) fail(ctx context.Context, photoID, msg string) {