package lidarrrequests import ( "context" "encoding/json" "io/fs" "net/http" "net/http/httptest" "strings" "sync" "testing" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/tags" ) // #5241: Lidarr names albums by MusicBrainz release group. A request carrying // a group id completes against the album row whose tags named that group, // once a track of it is on disk. func TestReconciler_AlbumMatchesByReleaseGroup(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) const artistMBID, release, group = "rg-artist-match", "rg-release-match", "rg-group-match" artist := seedArtist(t, q, "RG Artist", artistMBID) album := seedAlbum(t, q, artist.ID, "RG Album", release) if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ ID: album.ID, ReleaseGroupMbid: nilableStr(group), }); err != nil { t.Fatal(err) } _ = seedTrack(t, q, album.ID, artist.ID, "RG One", "/music/rg-match/01.flac") req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "RG Artist", LidarrAlbumMBID: group, AlbumTitle: "RG Album", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatal(err) } if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != album.ID { t.Errorf("status %v matched %v, want completed on %v", got.Status, got.MatchedAlbumID, album.ID) } } // A re-acquisition request names an album whose row never went away. The row // alone must not complete it: nothing has come back until a track is on disk. func TestReconciler_AlbumWithEveryTrackMissingStaysApproved(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) const artistMBID, release = "rg-artist-missing", "rg-release-missing" artist := seedArtist(t, q, "Missing Artist", artistMBID) album := seedAlbum(t, q, artist.ID, "Missing Album", release) tr := seedTrack(t, q, album.ID, artist.ID, "Gone", "/music/rg-missing/01.flac") if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", tr.ID); err != nil { t.Fatal(err) } req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Missing Artist", LidarrAlbumMBID: release, AlbumTitle: "Missing Album", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatal(err) } if got.Status != dbq.LidarrRequestStatusApproved { t.Errorf("status = %v, want approved while every track is missing", got.Status) } } // fakeAlbumLidarr answers album lookups only for knownGroup (Lidarr's metadata // is keyed by release group) and records every add. type fakeAlbumLidarr struct { knownGroup string mu sync.Mutex added []string } func (f *fakeAlbumLidarr) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case strings.HasSuffix(r.URL.Path, "/metadataprofile"): _, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`)) case strings.HasSuffix(r.URL.Path, "/album/lookup"): if r.URL.Query().Get("term") == "lidarr:"+f.knownGroup { _, _ = w.Write([]byte(`[{"foreignAlbumId":"` + f.knownGroup + `","artist":{"id":42}}]`)) return } _, _ = w.Write([]byte(`[]`)) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/album"): var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) f.mu.Lock() f.added = append(f.added, body["foreignAlbumId"].(string)) f.mu.Unlock() w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"id":9}`)) default: w.WriteHeader(http.StatusNotFound) } } // The ~49 requests on the deploy were stored under release ids. The reconciler // resolves the group (MusicBrainz here, since no album row knows it), rewrites // the request, adds it, and caches the group onto the album holding the // release. func TestReconciler_RepointsAReleaseIdRequestAtItsReleaseGroup(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() user := seedUser(t, pool) const artistMBID, release, group = "rg-artist-repoint", "rg-release-repoint", "rg-group-repoint" fake := &fakeAlbumLidarr{knownGroup: group} srv := httptest.NewServer(http.HandlerFunc(fake.handler)) t.Cleanup(srv.Close) if err := lidarrconfig.New(pool).Save(ctx, lidarrconfig.Config{ Enabled: true, BaseURL: srv.URL, APIKey: "k", DefaultQualityProfileID: 7, DefaultRootFolderPath: "/music", }); err != nil { t.Fatal(err) } artist := seedArtist(t, q, "Repoint Artist", artistMBID) album := seedAlbum(t, q, artist.ID, "Repoint Album", release) tr := seedTrack(t, q, album.ID, artist.ID, "Lost", "/music/rg-repoint/01.flac") if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", tr.ID); err != nil { t.Fatal(err) } req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Repoint Artist", LidarrAlbumMBID: release, AlbumTitle: "Repoint Album", }) rec := NewReconciler(pool, lidarrconfig.New(pool), func() *lidarr.Client { return lidarr.NewClient(srv.URL, "k") }, newTestLogger(), nil) asked := 0 rec.releaseGroup = func(_ context.Context, id string) (string, error) { asked++ if id == release { return group, nil } return "", tags.ErrNotFound } if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } got, err := q.GetLidarrRequestByID(ctx, req.ID) if err != nil { t.Fatal(err) } if got.LidarrAlbumMbid == nil || *got.LidarrAlbumMbid != group { t.Errorf("request album mbid = %v, want the release group %s", got.LidarrAlbumMbid, group) } if !got.LidarrAddConfirmedAt.Valid { t.Error("add not confirmed after the repoint") } fake.mu.Lock() added := append([]string(nil), fake.added...) fake.mu.Unlock() if len(added) != 1 || added[0] != group { t.Errorf("Lidarr adds = %v, want [%s]", added, group) } var cached *string if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", album.ID).Scan(&cached); err != nil { t.Fatal(err) } if cached == nil || *cached != group { t.Errorf("album release group = %v, want %s cached", cached, group) } if got.Status != dbq.LidarrRequestStatusApproved { t.Errorf("status = %v, want approved until a track is back", got.Status) } // The next tick finds the group on the request and the album: no second // MusicBrainz question. before := asked if err := rec.tickOnce(ctx); err != nil { t.Fatalf("second tick: %v", err) } if asked != before { t.Errorf("MusicBrainz asked again on the next tick (%d -> %d)", before, asked) } } // markMissing stamps a track missing since well before any request a test makes. func markMissing(t *testing.T, pool *pgxpool.Pool, trackID pgtype.UUID) { t.Helper() if _, err := pool.Exec(context.Background(), "UPDATE tracks SET missing_since = now() - interval '2 days' WHERE id = $1", trackID); err != nil { t.Fatal(err) } } func requestStatus(t *testing.T, q *dbq.Queries, id pgtype.UUID) dbq.LidarrRequest { t.Helper() got, err := q.GetLidarrRequestByID(context.Background(), id) if err != nil { t.Fatal(err) } return got } // #5263: re-acquisition targets albums with SOME tracks missing. The tracks // that never left must not complete the request; it completes once the lost // track is back. func TestReconciler_PartlyMissingAlbumCompletesWhenTheLostTrackReturns(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) const artistMBID, release = "rg-artist-partial", "rg-release-partial" artist := seedArtist(t, q, "Partial Artist", artistMBID) album := seedAlbum(t, q, artist.ID, "Partial Album", release) _ = seedTrack(t, q, album.ID, artist.ID, "Kept", "/music/rg-partial/01.flac") lost := seedTrack(t, q, album.ID, artist.ID, "Lost", "/music/rg-partial/02.flac") markMissing(t, pool, lost.ID) req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Partial Artist", LidarrAlbumMBID: release, AlbumTitle: "Partial Album", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } if got := requestStatus(t, q, req.ID); got.Status != dbq.LidarrRequestStatusApproved { t.Fatalf("status = %v while a track is still missing, want approved", got.Status) } if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = NULL WHERE id = $1", lost.ID); err != nil { t.Fatal(err) } if err := rec.tickOnce(ctx); err != nil { t.Fatalf("second tick: %v", err) } got := requestStatus(t, q, req.ID) if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != album.ID { t.Errorf("status %v matched %v after the track returned, want completed on %v", got.Status, got.MatchedAlbumID, album.ID) } } // Lidarr may fetch a different release of the group, which the scanner files // as its own album row. A track arriving there after the request completes it, // even though the old row's lost tracks stay missing. func TestReconciler_PartlyMissingAlbumCompletesOnAnotherReleaseArriving(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() enableLidarrForPool(t, pool) user := seedUser(t, pool) const artistMBID, oldRelease, newRelease, group = "rg-artist-edition", "rg-release-old", "rg-release-new", "rg-group-edition" artist := seedArtist(t, q, "Edition Artist", artistMBID) old := seedAlbum(t, q, artist.ID, "Edition Album", oldRelease) if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ ID: old.ID, ReleaseGroupMbid: nilableStr(group), }); err != nil { t.Fatal(err) } _ = seedTrack(t, q, old.ID, artist.ID, "Kept", "/music/rg-edition-old/01.flac") lost := seedTrack(t, q, old.ID, artist.ID, "Lost", "/music/rg-edition-old/02.flac") markMissing(t, pool, lost.ID) req := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Edition Artist", LidarrAlbumMBID: group, AlbumTitle: "Edition Album", }) rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) if err := rec.tickOnce(ctx); err != nil { t.Fatalf("tickOnce: %v", err) } if got := requestStatus(t, q, req.ID); got.Status != dbq.LidarrRequestStatusApproved { t.Fatalf("status = %v before anything arrived, want approved", got.Status) } arrived := seedAlbum(t, q, artist.ID, "Edition Album (Deluxe)", newRelease) if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ ID: arrived.ID, ReleaseGroupMbid: nilableStr(group), }); err != nil { t.Fatal(err) } _ = seedTrack(t, q, arrived.ID, artist.ID, "Lost", "/music/rg-edition-new/02.flac") if err := rec.tickOnce(ctx); err != nil { t.Fatalf("second tick: %v", err) } got := requestStatus(t, q, req.ID) if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != arrived.ID { t.Errorf("status %v matched %v, want completed on the arrived release %v", got.Status, got.MatchedAlbumID, arrived.ID) } } // Migration 0071 reopens requests the old check completed against a partly // missing album, and leaves a genuine completion alone. func TestMigration0071_ReopensFalseCompletions(t *testing.T) { pool := newPool(t) q := dbq.New(pool) ctx := context.Background() user := seedUser(t, pool) artist := seedArtist(t, q, "Reopen Artist", "rg-artist-reopen") partial := seedAlbum(t, q, artist.ID, "Partial", "rg-release-reopen-partial") _ = seedTrack(t, q, partial.ID, artist.ID, "Kept", "/music/rg-reopen-partial/01.flac") lost := seedTrack(t, q, partial.ID, artist.ID, "Lost", "/music/rg-reopen-partial/02.flac") markMissing(t, pool, lost.ID) falseReq := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "album", LidarrArtistMBID: "rg-artist-reopen", ArtistName: "Reopen Artist", LidarrAlbumMBID: "rg-release-reopen-partial", AlbumTitle: "Partial", }) genuineReq := seedApprovedRequestDirect(t, q, user, CreateParams{ Kind: "album", LidarrArtistMBID: "rg-artist-reopen", ArtistName: "Reopen Artist", LidarrAlbumMBID: "rg-release-reopen-new", AlbumTitle: "New", }) arrived := seedAlbum(t, q, artist.ID, "New", "rg-release-reopen-new") _ = seedTrack(t, q, arrived.ID, artist.ID, "Fresh", "/music/rg-reopen-new/01.flac") for _, c := range []struct { id pgtype.UUID album pgtype.UUID }{{falseReq.ID, partial.ID}, {genuineReq.ID, arrived.ID}} { if _, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ ID: c.id, MatchedAlbumID: c.album, }); err != nil { t.Fatal(err) } } up, err := fs.ReadFile(db.MigrationsFS(), "0071_reopen_false_reacquisition_completions.up.sql") if err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, string(up)); err != nil { t.Fatalf("migration: %v", err) } got := requestStatus(t, q, falseReq.ID) if got.Status != dbq.LidarrRequestStatusApproved || got.CompletedAt.Valid || got.MatchedAlbumID.Valid { t.Errorf("false completion: status %v completed_at %v matched %v, want approved and cleared", got.Status, got.CompletedAt, got.MatchedAlbumID) } if got := requestStatus(t, q, genuineReq.ID); got.Status != dbq.LidarrRequestStatusCompleted { t.Errorf("genuine completion reopened: status %v", got.Status) } }