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
+143 -4
View File
@@ -43,6 +43,7 @@ type stack struct {
photos *photos.Service
albums *albums.Service
auth *auth.Service
hub *sse.Hub
}
// openStack opens a migrated SQLite database at path and builds the services.
@@ -61,7 +62,7 @@ func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
}
repos := store.NewSQLite(database)
hub := sse.NewHub(1)
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour)
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
if err != nil {
t.Fatalf("signer: %v", err)
}
@@ -72,7 +73,145 @@ func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
authSvc := auth.NewService(repos.Auth, auth.NewTokenManager("secret", 15*time.Minute, time.Hour))
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, blob)
photosSvc := photos.NewService(repos.Photos, albumsSvc, blob, hub)
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc}, func() { database.Close() }
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc, hub: hub}, func() { database.Close() }
}
func TestUploadSetsDefaultCover(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "cover@test.local", "password123", "C")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Cover"})
if err != nil {
t.Fatalf("create album: %v", err)
}
first, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload first: %v", err)
}
got, _, err := st.albums.Get(ctx, album.ID, user.ID)
if err != nil {
t.Fatalf("get album: %v", err)
}
if got.CoverPhotoID == nil || *got.CoverPhotoID != first.ID {
t.Fatalf("cover = %v, want first photo %s", got.CoverPhotoID, first.ID)
}
second, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "b.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload second: %v", err)
}
got, _, err = st.albums.Get(ctx, album.ID, user.ID)
if err != nil {
t.Fatalf("get album after second: %v", err)
}
if got.CoverPhotoID == nil || *got.CoverPhotoID != first.ID {
t.Fatalf("cover changed to %v, want still first %s (second=%s)", got.CoverPhotoID, first.ID, second.ID)
}
}
func TestCoverClearedWhenCoverPhotoLeaves(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "clear@test.local", "password123", "C")
if err != nil {
t.Fatalf("register: %v", err)
}
albumA, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "A"})
if err != nil {
t.Fatalf("create A: %v", err)
}
albumB, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "B"})
if err != nil {
t.Fatalf("create B: %v", err)
}
cover, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: albumA.ID, UserID: user.ID, Filename: "cover.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload cover: %v", err)
}
// Trashing the cover clears it.
if err := st.photos.Delete(ctx, cover.ID, user.ID); err != nil {
t.Fatalf("delete cover: %v", err)
}
goal, _, _ := st.albums.Get(ctx, albumA.ID, user.ID)
if goal.CoverPhotoID != nil {
t.Fatalf("cover not cleared on delete: %v", goal.CoverPhotoID)
}
// A fresh upload becomes the cover, and moving it out clears it again.
next, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: albumA.ID, UserID: user.ID, Filename: "next.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload next: %v", err)
}
goal, _, _ = st.albums.Get(ctx, albumA.ID, user.ID)
if goal.CoverPhotoID == nil || *goal.CoverPhotoID != next.ID {
t.Fatalf("cover = %v, want %s", goal.CoverPhotoID, next.ID)
}
if _, err := st.photos.MoveBatch(ctx, []string{next.ID}, user.ID, albumB.ID); err != nil {
t.Fatalf("move: %v", err)
}
goal, _, _ = st.albums.Get(ctx, albumA.ID, user.ID)
if goal.CoverPhotoID != nil {
t.Fatalf("cover not cleared on move: %v", goal.CoverPhotoID)
}
}
func TestWorkerPublishesPhotoUpdated(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute, 2)
wctx, cancel := context.WithCancel(ctx)
defer cancel()
go worker.Run(wctx)
user, _, err := st.auth.Register(ctx, "evt@test.local", "password123", "E")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Trip"})
if err != nil {
t.Fatalf("create album: %v", err)
}
sub := st.hub.SubscribeAlbum(album.ID)
defer sub.Close()
if _, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t),
}); err != nil {
t.Fatalf("upload: %v", err)
}
select {
case ev := <-sub.Events():
if ev.Type != "photo.updated" {
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
}
case <-time.After(5 * time.Second):
t.Fatalf("no photo.updated event published for generated variants")
}
}
func TestSQLiteEndToEndWithRestart(t *testing.T) {
@@ -81,7 +220,7 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) {
blobs := t.TempDir()
st, closeDB := openStack(t, path, blobs)
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute)
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute, 2)
wctx, cancel := context.WithCancel(ctx)
go worker.Run(wctx)
@@ -127,7 +266,7 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) {
st2, close2 := openStack(t, path, blobs)
defer close2()
worker2 := photos.NewWorker(st2.photos, 20*time.Millisecond, 10, time.Minute)
worker2 := photos.NewWorker(st2.photos, 20*time.Millisecond, 10, time.Minute, 2)
wctx2, cancel2 := context.WithCancel(ctx)
defer cancel2()
go worker2.Run(wctx2)