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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user