feat(uploads): match release groups with MusicBrainz and AcoustID
ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / api / api (push) Successful in 58s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Failing after 6m3s

This commit is contained in:
mrleo1nid committed 2026-10-29 20:44:59 +03:00
1 parent 1e3a149aef
commit 39bf7d4b70
45 files changed
+4792 -417

No files matched your search

@@ -0,0 +1,153 @@
//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)
}