//go:build integration package uploads import ( "context" "crypto/sha256" "encoding/json" "log/slog" "os" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "pipu/api/internal/auth" "pipu/api/internal/library" "pipu/api/internal/musicbrainz" "pipu/api/internal/platform/db" ) type groupedCatalogue map[string]musicbrainz.Release func (c groupedCatalogue) Search(_ context.Context, q musicbrainz.Query) ([]musicbrainz.Release, error) { return []musicbrainz.Release{c[q.Album]}, nil } func (c groupedCatalogue) Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error) { return musicbrainz.Cover{Data: []byte{0xff, 0xd8, 0xff, 1}, MIME: "image/jpeg", Exact: true}, nil } func TestReleaseGroupsIsolateJobsMetadataAndArtwork(t *testing.T) { address := os.Getenv("TEST_DATABASE_URL") if address == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := db.Connect(ctx, address) require.NoError(t, err) defer pool.Close() require.NoError(t, db.Migrate(ctx, pool)) author := uuid.New() _, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','admin')`, author, "groups-"+author.String()) require.NoError(t, err) source, err := library.NewStore(pool).SaveSource(ctx, library.Source{ID: uuid.New(), Name: "groups-test", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{track:02} - {title}.{ext}")}) require.NoError(t, err) store := NewStore(pool) sumA, sumB := sha256.Sum256([]byte(uuid.NewString())), sha256.Sum256([]byte(uuid.NewString())) cover := "covers/original.jpg" batch, reserved, err := store.CreateBatch(ctx, author, nil, []NewFile{{ClientID: "a", Name: "a.flac", Size: 10, SHA256: sumA[:]}, {ClientID: "b", Name: "b.flac", Size: 10, SHA256: sumB[:]}}, DefaultSettings, &cover) require.NoError(t, err) defer func() { _, _ = pool.Exec(ctx, `DELETE FROM upload_batches WHERE id=$1`, batch) _ = store.ReconcileQuota(ctx) }() _, err = pool.Exec(ctx, `UPDATE upload_release_jobs SET state='not_found' WHERE batch_id<>$1`, batch) require.NoError(t, err) for i, f := range reserved { m := Meta{Artist: "Artist", AlbumArtist: "Artist", Album: []string{"Album A", "Album B"}[i], Title: "Song"} raw, _ := json.Marshal(m) _, err = pool.Exec(ctx, `UPDATE upload_files SET status='pending_review',metadata=$2,duration_ms=180000 WHERE id=$1`, f.FileID, raw) require.NoError(t, err) } catalogue := groupedCatalogue{} for _, album := range []string{"Album A", "Album B"} { catalogue[album] = musicbrainz.Release{ID: uuid.NewString(), Title: album, Artist: "Artist", Type: "Album", Tracks: []musicbrainz.Track{{Title: "Song", Artist: "Artist", Disc: 1, Position: 1, DurationMs: 180000}}} } svc := NewService(store, &previewCatalog{source: source}, previewRoot(t.TempDir()), nil, nil, nil, library.FPCalc{}, Config{StagingDir: t.TempDir()}, slog.New(slog.DiscardHandler)) svc.SetReleaseCatalogue(catalogue) review := auth.WithPrincipal(ctx, auth.Principal{UserID: author, Role: auth.RoleAdmin, ReviewsUploads: true}) root, err := svc.GetReleaseReport(review, batch) require.NoError(t, err) require.Len(t, root.Groups, 2) require.Equal(t, "needs_input", root.State) group := map[string]string{} for _, g := range root.Groups { group[g.Album] = g.ID require.False(t, g.HasCover, "ambiguous original batch artwork must not be copied to every album") } a, b := group["Album A"], group["Album B"] _, err = svc.SearchReleases(review, batch, musicbrainz.Query{}) require.ErrorContains(t, err, "release_group_required") _, err = svc.GetReleaseReport(review, batch, uuid.NewString()) require.ErrorContains(t, err, "release_group_not_found") require.True(t, svc.lookupNextRelease(ctx)) require.True(t, svc.lookupNextRelease(ctx)) reportA, err := svc.GetReleaseReport(review, batch, a) require.NoError(t, err) require.True(t, reportA.Candidates[0].Complete) reportB, err := svc.GetReleaseReport(review, batch, b) require.NoError(t, err) require.True(t, reportB.Candidates[0].Complete) _, err = svc.ApplyRelease(review, batch, catalogue["Album B"].ID, true, false, a) require.ErrorContains(t, err, "release_not_found") used, _, err := store.Usage(ctx, author) require.NoError(t, err) _, err = svc.ApplyRelease(review, batch, catalogue["Album B"].ID, true, true, b) require.NoError(t, err) aFiles, err := store.AllInReleaseGroup(ctx, batch, a) require.NoError(t, err) require.Nil(t, aFiles[0].CoverPath) require.Equal(t, "Album A", aFiles[0].Meta.Album) require.Empty(t, aFiles[0].Tags["musicbrainz_albumid"]) bFiles, err := store.AllInReleaseGroup(ctx, batch, b) require.NoError(t, err) require.NotNil(t, bFiles[0].CoverPath) require.Equal(t, catalogue["Album B"].ID, bFiles[0].Tags["musicbrainz_albumid"]) kept, err := store.StagingInUse(ctx) require.NoError(t, err) require.True(t, kept[*bFiles[0].CoverPath]) afterA, err := svc.GetReleaseReport(review, batch, a) require.NoError(t, err) require.Equal(t, reportA.InputHash, afterA.InputHash) require.Nil(t, afterA.SelectedRelease) newUsed, _, err := store.Usage(ctx, author) require.NoError(t, err) require.Equal(t, used, newUsed) // A job lease for A survives a metadata edit in B. _, err = svc.SearchReleases(review, batch, musicbrainz.Query{GroupID: a}) require.NoError(t, err) job, err := store.ClaimRelease(ctx) require.NoError(t, err) require.Equal(t, a, job.GroupKey) edited := *bFiles[0].Meta edited.Title = "Edited B" require.NoError(t, store.UpdateMetadata(ctx, bFiles[0].ID, edited, true, placement{})) require.NoError(t, store.SaveRelease(ctx, job, reportA)) afterA, err = store.ReleaseReport(ctx, batch, a) require.NoError(t, err) require.Equal(t, "ready", afterA.State) afterB, err := store.ReleaseReport(ctx, batch, b) require.NoError(t, err) require.Equal(t, "queued", afterB.State) require.NoError(t, store.EnsureReleaseGroups(ctx, batch)) groups, err := store.ReleaseGroups(ctx, batch) require.NoError(t, err) require.Len(t, groups, 2) // Explicit whole-batch cover editing still affects every subgroup. shared := "covers/shared.jpg" require.NoError(t, store.SetCover(ctx, batch, &shared)) aFiles, err = store.AllInReleaseGroup(ctx, batch, a) require.NoError(t, err) require.Equal(t, shared, *aFiles[0].CoverPath) bFiles, err = store.AllInReleaseGroup(ctx, batch, b) require.NoError(t, err) require.Equal(t, shared, *bFiles[0].CoverPath) require.NoError(t, store.ChangeStatus(ctx, bFiles[0].ID, []Status{StatusPendingReview}, StatusWithdrawn)) unused, err := store.UnusedReleaseCovers(ctx, batch) require.NoError(t, err) require.Empty(t, unused, "shared artwork is still needed by group A") require.NoError(t, store.ChangeStatus(ctx, aFiles[0].ID, []Status{StatusPendingReview}, StatusWithdrawn)) unused, err = store.UnusedReleaseCovers(ctx, batch) require.NoError(t, err) require.Contains(t, unused, shared) }