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
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>
269 lines
9.2 KiB
Go
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
|
|
}
|