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
+59 -1
View File
@@ -40,9 +40,21 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
}
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
w := NewWorker(svc, time.Second, 10, time.Minute)
sub := svc.hub.SubscribeAlbum(album)
defer sub.Close()
w := NewWorker(svc, time.Second, 10, time.Minute, 2)
w.processVariantJobs(ctx)
select {
case ev := <-sub.Events():
if ev.Type != "photo.updated" {
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
}
case <-time.After(time.Second):
t.Fatalf("no variant-ready event published")
}
refreshed, err := svc.store.GetByID(ctx, p.ID, "")
if err != nil {
t.Fatalf("get refreshed: %v", err)
@@ -82,6 +94,52 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
}
}
func TestWorkerConcurrencyPool(t *testing.T) {
w := &Worker{concurrency: 3}
ids := []string{"a", "b", "c", "d", "e"}
started := make(chan struct{}, len(ids))
release := make(chan struct{})
done := make(chan struct{})
go func() {
w.forEachConcurrent(ids, func(string) {
started <- struct{}{}
<-release
})
close(done)
}()
for i := 0; i < 3; i++ {
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatalf("only %d tasks started concurrently, want 3", i)
}
}
select {
case <-started:
t.Fatalf("more than concurrency tasks started concurrently")
case <-time.After(50 * time.Millisecond):
}
close(release)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("pool did not finish")
}
}
func TestWorkerConcurrencySequentialAtOne(t *testing.T) {
w := &Worker{concurrency: 1}
var order []string
w.forEachConcurrent([]string{"a", "b", "c"}, func(id string) {
order = append(order, id)
})
if len(order) != 3 || order[0] != "a" || order[1] != "b" || order[2] != "c" {
t.Fatalf("sequential order = %v, want [a b c]", order)
}
}
func countObjects(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ids []string) int {
t.Helper()
var n int