Files
pipu-music/services/api/internal/uploads/release_integration_test.go
T
mrleo1nid 39bf7d4b70
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
feat(uploads): match release groups with MusicBrainz and AcoustID
2026-10-29 20:44:59 +03:00

185 lines
8.1 KiB
Go

//go:build integration
package uploads
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/musicbrainz"
"pipu/api/internal/platform/db"
)
type testReleaseCatalogue struct {
release musicbrainz.Release
searches int
err error
}
func (c *testReleaseCatalogue) Search(context.Context, musicbrainz.Query) ([]musicbrainz.Release, error) {
c.searches++
return []musicbrainz.Release{c.release}, c.err
}
func (c *testReleaseCatalogue) Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error) {
return musicbrainz.Cover{Data: []byte{0xff, 0xd8, 0xff, 1}, MIME: "image/jpeg", Exact: true}, nil
}
func TestReleaseJobsAndAtomicApplication(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, "release-"+author.String())
require.NoError(t, err)
source, err := library.NewStore(pool).SaveSource(ctx, library.Source{ID: uuid.New(), Name: "release-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)
oldCover := "covers/original.jpg"
sum1, sum2 := sha256.Sum256([]byte(uuid.NewString())), sha256.Sum256([]byte(uuid.NewString()))
batch, reserved, err := store.CreateBatch(ctx, author, nil, []NewFile{{ClientID: "1", Name: "one.flac", Size: 10, SHA256: sum1[:]}, {ClientID: "2", Name: "two.flac", Size: 10, SHA256: sum2[:]}}, DefaultSettings, &oldCover)
require.NoError(t, err)
require.Len(t, reserved, 2)
// Isolate from batches produced by other integration tests.
_, err = pool.Exec(ctx, `UPDATE upload_release_jobs SET state='not_found' WHERE batch_id<>$1`, batch)
require.NoError(t, err)
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM upload_batches WHERE id=$1`, batch)
_ = store.ReconcileQuota(ctx)
}()
provider := &testReleaseCatalogue{release: musicbrainz.Release{ID: uuid.NewString(), Artist: "Artist", Title: "Album", Type: "Single", Date: "2024-01-01", Tracks: []musicbrainz.Track{
{Title: "Song 1", Artist: "Artist", Disc: 1, Position: 1, DurationMs: 180000},
{Title: "Song 2", Artist: "Artist", Disc: 1, Position: 2, DurationMs: 190000},
}}}
svc := NewService(store, &previewCatalog{source: source}, previewRoot(t.TempDir()), nil, nil, nil, library.FPCalc{}, Config{StagingDir: t.TempDir()}, slog.New(slog.DiscardHandler))
svc.SetReleaseCatalogue(provider)
review := auth.WithPrincipal(ctx, auth.Principal{UserID: author, Role: auth.RoleAdmin, ReviewsUploads: true})
for i, r := range reserved {
require.NoError(t, store.ChangeStatus(ctx, r.FileID, []Status{StatusUploading}, StatusProcessing))
m := Meta{Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "SONG " + string(rune('1'+i))}
n := i + 1
m.Track = &n
raw, _ := json.Marshal(m)
duration := 180000 + i*10000
require.NoError(t, store.Transition(ctx, r.FileID, []Status{StatusProcessing}, StatusPendingReview, func(tx pgx.Tx) error {
_, e := tx.Exec(ctx, `UPDATE upload_files SET metadata=$2,duration_ms=$3 WHERE id=$1`, r.FileID, raw, duration)
return e
}))
if i == 0 {
_, err = store.ClaimRelease(ctx)
require.ErrorIs(t, err, ErrNotFound, "lookup waits until the whole batch is processed")
}
}
job, err := store.ClaimRelease(ctx)
require.NoError(t, err)
require.Equal(t, batch, job.ID)
files, err := store.AllInBatch(ctx, batch)
require.NoError(t, err)
edited := *files[0].Meta
year := 2025
edited.Year = &year
require.NoError(t, store.UpdateMetadata(ctx, files[0].ID, edited, true, placement{}))
require.NoError(t, store.SaveRelease(ctx, job, ReleaseReport{State: "ready", InputHash: "stale", Candidates: []ReleaseCandidate{}}))
report, err := store.ReleaseReport(ctx, batch)
require.NoError(t, err)
require.Equal(t, "queued", report.State, "an old worker lease cannot overwrite an edit")
require.True(t, svc.lookupNextRelease(ctx))
report, err = svc.GetReleaseReport(review, batch)
require.NoError(t, err)
require.Equal(t, "ready", report.State)
require.True(t, report.Candidates[0].Complete)
_, err = svc.SearchReleases(review, batch, musicbrainz.Query{Audio: true})
require.ErrorContains(t, err, "audio_disabled")
identifier := &testAudioIdentifier{}
svc.SetAudioIdentifier(identifier)
provider.release.Tracks[0].RecordingID, provider.release.Tracks[1].RecordingID = uuid.NewString(), uuid.NewString()
svc.encodedFingerprint = func(_ context.Context, filename string) (string, error) {
if strings.Contains(filename, reserved[0].FileID.String()) {
identifier.id = provider.release.Tracks[0].RecordingID
} else {
identifier.id = provider.release.Tracks[1].RecordingID
}
return "encoded", nil
}
_, err = svc.SearchReleases(review, batch, musicbrainz.Query{Audio: true})
require.NoError(t, err)
require.True(t, svc.lookupNextRelease(ctx))
report, err = svc.GetReleaseReport(review, batch)
require.NoError(t, err)
require.True(t, report.AudioAvailable)
require.Len(t, report.AudioResults, 2)
require.Equal(t, "acoustid", report.Candidates[0].Matches[0].Method)
require.True(t, report.Candidates[0].Complete)
before, err := store.AllInBatch(ctx, batch)
require.NoError(t, err)
require.Equal(t, "SONG 1", before[0].Meta.Title)
// Looking up the release never changes tags or cover.
require.Equal(t, oldCover, *before[0].CoverPath)
used, _, err := store.Usage(ctx, author)
require.NoError(t, err)
applied, err := svc.ApplyRelease(review, batch, provider.release.ID, true, false)
require.NoError(t, err)
require.Equal(t, provider.release.ID, *applied.SelectedRelease)
persisted, err := store.ReleaseReport(ctx, batch)
require.NoError(t, err)
require.Len(t, persisted.AudioResults, 2, "metadata update trigger must not erase confirmed recognition")
after, err := store.AllInBatch(ctx, batch)
require.NoError(t, err)
require.Equal(t, "Song 1", after[0].Meta.Title)
require.Equal(t, oldCover, *after[0].CoverPath)
require.Equal(t, StatusPendingReview, after[0].Status)
require.Equal(t, provider.release.ID, after[0].Tags["musicbrainz_albumid"])
require.NotNil(t, after[0].TargetRelPath)
require.Contains(t, *after[0].TargetRelPath, "01 - Song 1.flac")
newUsed, _, err := store.Usage(ctx, author)
require.NoError(t, err)
require.Equal(t, used, newUsed)
_, err = svc.ApplyRelease(review, batch, provider.release.ID, false, true)
require.NoError(t, err)
after, err = store.AllInBatch(ctx, batch)
require.NoError(t, err)
require.NotEqual(t, oldCover, *after[0].CoverPath)
// A queued manual lookup makes the old candidate inapplicable immediately.
_, err = svc.SearchReleases(review, batch, musicbrainz.Query{})
require.NoError(t, err)
_, err = svc.ApplyRelease(review, batch, provider.release.ID, true, false)
require.ErrorContains(t, err, "release_stale")
provider.err = errors.New("provider offline")
require.True(t, svc.lookupNextRelease(ctx))
report, err = store.ReleaseReport(ctx, batch)
require.NoError(t, err)
require.Equal(t, "error", report.State)
_, err = store.ClaimRelease(ctx)
require.ErrorIs(t, err, ErrNotFound, "failures back off instead of blocking upload processing")
_, err = svc.GetReleaseReport(ctx, batch)
require.Error(t, err, "review permission is mandatory")
key := "integration:" + uuid.NewString()
require.NoError(t, store.PutMetadataCache(ctx, key, []byte("cached"), time.Hour))
data, ok, err := store.GetMetadataCache(ctx, key)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "cached", string(data))
require.NoError(t, store.PutMetadataCache(ctx, key, data, -time.Second))
require.NoError(t, store.ExpireMetadataCache(ctx))
_, ok, err = store.GetMetadataCache(ctx, key)
require.NoError(t, err)
require.False(t, ok)
}