Files
minstrel/internal/importer/importer_test.go
T
bvandeusenandClaude Opus 5.5 ebbc49b902
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: import with verification, and a Wanted view of its own (M502 #5522)
A finished download is checked against MusicBrainz before anything is
filed: the files are paired with an edition's tracks by position or path,
and AcoustID, tag ids, lengths and titles say whether it is the album.
What is the album is copied into the library as
Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's
MusicBrainz ids, and scanned; the download keeps seeding. What is not is
refused: set aside, removed from the client, and the album searched for
again. A download the check cannot settle waits for a person, who imports
it anyway or refuses it. Tracks the library already holds are skipped.

The wanted list leaves Admin for a top-level Wanted view, admin-only and
web-only, with tabs for Wanted, Activity (downloads and their imports,
the ones needing a person first), Missing files, Held, Following (with the
release-type profiles, moved off Integrations) and Blocked. The search
settings gain "File new music into" when the library has several folders.

The search pass logs why each failed search failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:36:21 -04:00

269 lines
9.2 KiB
Go

package importer
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
const hash = "0123456789abcdef0123456789abcdef01234567"
// fakeClient is a download client holding one finished torrent.
type fakeClient struct {
downloader.Driver
content string
deleted []string
}
func (c *fakeClient) Torrents(_ context.Context, _ ...string) ([]downloader.Torrent, error) {
return []downloader.Torrent{{Hash: hash, Name: "Daft Punk - RAM [FLAC]", Progress: 1, State: "uploading",
ContentPath: c.content}}, nil
}
func (c *fakeClient) Delete(_ context.Context, _ bool, hashes ...string) error {
c.deleted = append(c.deleted, hashes...)
return nil
}
// fakeCatalogue knows RAM as one three-track edition.
type fakeCatalogue struct{}
func (fakeCatalogue) Releases(_ context.Context, _ string) (catalogue.Editions, error) {
return catalogue.Editions{Editions: []catalogue.Edition{{
Release: dbq.MbRelease{Mbid: "rel-ram", Title: "Random Access Memories", Status: "Official", ReleaseDate: "2013-05-17"},
Tracks: []dbq.MbReleaseTrack{
{MediumPosition: 1, MediumFormat: "CD", Position: 1, Title: "Give Life Back to Music", RecordingMbid: "rec-01"},
{MediumPosition: 1, MediumFormat: "CD", Position: 2, Title: "The Game of Love", RecordingMbid: "rec-02"},
{MediumPosition: 1, MediumFormat: "CD", Position: 3, Title: "Giorgio by Moroder", RecordingMbid: "rec-03"},
},
}}}, nil
}
// fakePrinter names each file's recording by its file name: 01.flac is
// rec-01, unless wrong is set.
type fakePrinter struct{ wrong bool }
func (p fakePrinter) Recordings(_ context.Context, path string) ([]string, error) {
if p.wrong {
return []string{"rec-other"}, nil
}
return []string{"rec-" + strings.TrimSuffix(filepath.Base(path), ".flac")}, nil
}
type fakeScanner struct{ scanned []string }
func (s *fakeScanner) ScanFiles(_ context.Context, paths []string) ([]pgtype.UUID, error) {
s.scanned = append(s.scanned, paths...)
return nil, nil
}
type stack struct {
pool *pgxpool.Pool
q *dbq.Queries
im *Importer
client *fakeClient
scanner *fakeScanner
library string
want dbq.Want
grab dbq.Grab
}
// newStack wants RAM, has a finished download of it waiting in a fake
// client, and an importer filing into a temp library.
func newStack(t *testing.T, printer Printer) *stack {
t.Helper()
ctx := context.Background()
pool := newPool(t)
q := dbq.New(pool)
content := t.TempDir()
for _, n := range []string{"01", "02", "03"} {
if err := os.WriteFile(filepath.Join(content, n+".flac"), []byte("not really flac "+n), 0o644); err != nil {
t.Fatal(err)
}
}
w, err := wanted.WantWith(ctx, q, wanted.WantParams{GroupMBID: "rg-ram", ArtistMBID: "mb-dp", ArtistName: "Daft Punk",
Title: "Random Access Memories", PrimaryType: "Album", FirstReleaseDate: "2013-05-17", Reason: wanted.ReasonRequest})
if err != nil {
t.Fatal(err)
}
g, err := acquire.NewMemory(pool).Grabbed(ctx, dbq.InsertGrabParams{WantID: w.ID, InfoHash: hash,
Name: "Daft Punk - RAM [FLAC]", Key: hash, Client: "qbittorrent", SavePath: content, Origin: acquire.OriginSchedule},
pgtype.UUID{}, "")
if err != nil {
t.Fatal(err)
}
s := &stack{pool: pool, q: q, client: &fakeClient{content: content}, scanner: &fakeScanner{},
library: t.TempDir(), want: w, grab: g}
s.im = New(pool, s.scanner, fakeCatalogue{}, printer, []string{s.library}, slog.New(slog.NewTextHandler(io.Discard, nil)))
s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return s.client, nil }
return s
}
func (s *stack) grabNow(t *testing.T) dbq.Grab {
t.Helper()
g, err := s.q.GetGrab(context.Background(), s.grab.ID)
if err != nil {
t.Fatal(err)
}
return g
}
func TestImport_AFinishedDownloadOfTheAlbumIsFiled_Integration(t *testing.T) {
s := newStack(t, fakePrinter{})
rep := s.im.Pass(context.Background())
if rep.Imported != 1 {
t.Fatalf("pass = %+v", rep)
}
g := s.grabNow(t)
if g.ImportState != StateImported || g.EndReason != StateImported || !g.EndedAt.Valid || g.ImportedFiles != 3 {
t.Fatalf("grab = %s %s ended=%v files=%d: %s", g.ImportState, g.EndReason, g.EndedAt.Valid, g.ImportedFiles, g.ImportReason)
}
dir := filepath.Join(s.library, "Daft Punk", "Random Access Memories (2013)")
if g.Destination != dir {
t.Errorf("destination %q", g.Destination)
}
got, err := os.ReadFile(filepath.Join(dir, "Daft Punk - Random Access Memories - 02 - The Game of Love.flac"))
if err != nil || string(got) != "not really flac 02" {
t.Errorf("filed copy: %q %v", got, err)
}
if _, err := os.Stat(filepath.Join(s.client.content, "02.flac")); err != nil {
t.Errorf("the download's own file went: %v", err)
}
if len(s.scanner.scanned) != 3 || len(s.client.deleted) != 0 {
t.Errorf("scanned %v, deleted %v", s.scanner.scanned, s.client.deleted)
}
w, _ := s.q.GetWantByReleaseGroup(context.Background(), "rg-ram")
if !w.SearchedAt.Valid {
t.Error("an imported want is due for search again")
}
if s.im.Pass(context.Background()).Imported != 0 {
t.Error("imported twice")
}
}
func TestImport_TheWrongAlbumIsRefusedBlockedAndSearchedAgain_Integration(t *testing.T) {
s := newStack(t, fakePrinter{wrong: true})
ctx := context.Background()
if err := s.q.MarkWantSearched(ctx, s.want.ID); err != nil {
t.Fatal(err)
}
if rep := s.im.Pass(ctx); rep.Refused != 1 {
t.Fatalf("pass = %+v", rep)
}
g := s.grabNow(t)
if g.ImportState != StateRefused || g.EndReason != StateRefused || !strings.Contains(g.ImportReason, "recordings") {
t.Fatalf("grab = %s %s: %s", g.ImportState, g.EndReason, g.ImportReason)
}
blocks, err := s.q.ListReleaseBlocks(ctx)
if err != nil || len(blocks) != 1 || blocks[0].InfoHash != hash || blocks[0].Reason != acquire.BlockImport {
t.Errorf("blocks = %+v %v", blocks, err)
}
if len(s.client.deleted) != 1 {
t.Errorf("the refused download was not removed: %v", s.client.deleted)
}
w, _ := s.q.GetWantByReleaseGroup(ctx, "rg-ram")
if w.SearchedAt.Valid {
t.Error("a refused want is not due for search")
}
entries, _ := os.ReadDir(s.library)
if len(entries) != 0 {
t.Errorf("the library got %d entries", len(entries))
}
}
func TestImport_ADoubtfulDownloadWaitsForAPersonThenImportsAnyway_Integration(t *testing.T) {
s := newStack(t, nil) // no AcoustID; untagged files: nothing but lengths
ctx := context.Background()
if rep := s.im.Pass(ctx); rep.Review != 1 {
t.Fatalf("pass = %+v", rep)
}
g := s.grabNow(t)
if g.ImportState != StateReview || g.EndedAt.Valid || !strings.Contains(g.ImportReason, "A person decides") {
t.Fatalf("grab = %s ended=%v: %s", g.ImportState, g.EndedAt.Valid, g.ImportReason)
}
if rep := s.im.Pass(ctx); rep.Review+rep.Imported != 0 {
t.Errorf("a held download was looked at again: %+v", rep)
}
if _, err := s.q.ForceGrabImport(ctx, g.ID); err != nil {
t.Fatal(err)
}
if rep := s.im.Pass(ctx); rep.Imported != 1 {
t.Fatalf("after import anyway: %+v", rep)
}
if g := s.grabNow(t); g.ImportState != StateImported || !strings.Contains(g.ImportReason, "anyway") {
t.Errorf("grab = %s: %s", g.ImportState, g.ImportReason)
}
}
func TestImport_APersonRefusesADoubtfulDownload_Integration(t *testing.T) {
s := newStack(t, nil)
ctx := context.Background()
s.im.Pass(ctx)
g, err := s.im.Refuse(ctx, s.grab.ID, pgtype.UUID{})
if err != nil {
t.Fatal(err)
}
if g.ImportState != StateRefused || !g.EndedAt.Valid || len(s.client.deleted) != 1 {
t.Errorf("grab = %s ended=%v deleted=%v", g.ImportState, g.EndedAt.Valid, s.client.deleted)
}
if _, err := s.im.Refuse(ctx, s.grab.ID, pgtype.UUID{}); err == nil {
t.Error("refused twice")
}
}
func TestImport_AnUnfinishedDownloadIsOnlyWatched_Integration(t *testing.T) {
s := newStack(t, fakePrinter{})
s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return &halfClient{s.client}, nil }
if rep := s.im.Pass(context.Background()); rep.Downloading != 1 {
t.Fatalf("pass = %+v", rep)
}
g := s.grabNow(t)
if g.ImportState != StateDownloading || g.Progress < 0.49 || g.Progress > 0.51 {
t.Errorf("grab = %s %.2f", g.ImportState, g.Progress)
}
}
type halfClient struct{ *fakeClient }
func (c *halfClient) Torrents(ctx context.Context, h ...string) ([]downloader.Torrent, error) {
ts, _ := c.fakeClient.Torrents(ctx, h...)
ts[0].Progress, ts[0].State = 0.5, "downloading"
return ts, nil
}