- {#if state === 'held'}Nothing on the list has been held yet.
- {:else if state === 'missing'}No missing albums are wanted back.
+ {#if list === 'held'}Nothing on the list has been held yet.
+ {:else if list === 'missing'}No missing albums are wanted back.
{:else}Nothing is wanted.{/if}
- {#if state === 'missing'}
+ {#if list === 'missing'}
An album whose files stay missing past the grace window on the Missing files page lands here.
{:else}
A request you approve, a new release from a followed artist, or an album whose files went
diff --git a/web/src/lib/components/wanted/WantList.test.ts b/web/src/lib/components/wanted/WantList.test.ts
index 47e6ae09..7ca7a13d 100644
--- a/web/src/lib/components/wanted/WantList.test.ts
+++ b/web/src/lib/components/wanted/WantList.test.ts
@@ -83,7 +83,7 @@ function setup(
vi.mocked(createCandidatesQuery).mockReturnValue(
mockQuery({ data: [] }) as unknown as ReturnType
);
- return render(WantList, { state });
+ return render(WantList, { list: state });
}
describe('WantList', () => {
diff --git a/web/src/routes/wanted/+page.svelte b/web/src/routes/wanted/+page.svelte
index 96b2637e..9d6f7d36 100644
--- a/web/src/routes/wanted/+page.svelte
+++ b/web/src/routes/wanted/+page.svelte
@@ -5,4 +5,4 @@
{pageTitle('Wanted')}
-
+
diff --git a/web/src/routes/wanted/held/+page.svelte b/web/src/routes/wanted/held/+page.svelte
index dd44902b..652d8b20 100644
--- a/web/src/routes/wanted/held/+page.svelte
+++ b/web/src/routes/wanted/held/+page.svelte
@@ -5,4 +5,4 @@
{pageTitle('Wanted · Held')}
-
+
diff --git a/web/src/routes/wanted/missing/+page.svelte b/web/src/routes/wanted/missing/+page.svelte
index f2b7e6df..35163ed2 100644
--- a/web/src/routes/wanted/missing/+page.svelte
+++ b/web/src/routes/wanted/missing/+page.svelte
@@ -5,4 +5,4 @@
{pageTitle('Wanted · Missing files')}
-
+
--
2.54.0
From d9e00d8bc2ebc3f394e596ea57f808db52673c4f Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 10:53:43 -0400
Subject: [PATCH 015/117] fix: the integrations test's library folder isn't
/music, which a Lidarr root folder there is named (M502 #5522)
Co-Authored-By: Claude Opus 5.5
---
web/src/routes/admin/integrations/integrations.test.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts
index 5bd63952..5b0f3ee8 100644
--- a/web/src/routes/admin/integrations/integrations.test.ts
+++ b/web/src/routes/admin/integrations/integrations.test.ts
@@ -58,7 +58,8 @@ vi.mock('#lib/api/wanted.js', async () => {
last_pass_at: null,
last_pass: {},
import_root: '',
- scan_paths: ['/music']
+ // Not "/music": a Lidarr root folder on this page is named that.
+ scan_paths: ['/library']
}
})
),
--
2.54.0
From 8245f4406e667bec103a8cbd10ba67d47e62b9f8 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 15:27:22 -0400
Subject: [PATCH 016/117] =?UTF-8?q?feat:=20download=20lifecycle=20?=
=?UTF-8?q?=E2=80=94=20stalled=20and=20failed=20downloads=20are=20given=20?=
=?UTF-8?q?up=20and=20searched=20again;=20imported=20ones=20seed,=20then?=
=?UTF-8?q?=20go=20(M502=20#5523)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ported from Fabled Librarian's System 104. A new lifecycle poller, every
ten minutes:
- a download the client fails is given up at once;
- one nobody shares, or still fetching its file list, past the stall
setting (6 h) is asked for again three times, then given up;
- giving up removes the download with its files, holds the release back
for a week (release_blocks.expires_at), ends the grab and searches the
album again, with no limit per album;
- a running download the client stops listing for half an hour ends and is
searched again, not held back; an empty list is not taken as gone;
- an imported download seeds to ratio 2.0 or 14 days, then is removed;
- a download the client would not remove is asked again every pass.
Every condition and act is one open row per (client, download, condition)
in download_interventions. Activity shows what is wrong with a download and
how often it was retried, why one ended, its seeding, and a banner for a
client that does not answer. A running download can be given up by hand
(set aside for good). The seed ratio, seed days and stall hours are on the
search settings card. The download client lookup moves to
acquire.DownloadClientFor, shared by the importer and the lifecycle.
Co-Authored-By: Claude Opus 5.5
---
cmd/minstrel/main.go | 6 +
internal/acquire/memory.go | 16 +-
internal/acquire/settings.go | 16 +
internal/api/admin_import.go | 96 ++-
internal/api/admin_search.go | 18 +-
internal/api/admin_wanted.go | 4 +-
internal/api/api.go | 6 +
internal/db/dbq/imports.sql.go | 116 ++-
internal/db/dbq/lifecycle.sql.go | 389 +++++++++++
internal/db/dbq/models.go | 24 +
internal/db/dbq/search.sql.go | 49 +-
internal/db/dbq/wanted.sql.go | 7 +-
.../0084_download_lifecycle.down.sql | 23 +
.../migrations/0084_download_lifecycle.up.sql | 90 +++
internal/db/queries/imports.sql | 18 +-
internal/db/queries/lifecycle.sql | 96 +++
internal/db/queries/search.sql | 20 +-
internal/db/queries/wanted.sql | 5 +-
internal/dbtest/reset.go | 2 +
internal/importer/importer.go | 22 +-
internal/lifecycle/lifecycle.go | 659 ++++++++++++++++++
internal/lifecycle/lifecycle_test.go | 441 ++++++++++++
web/src/lib/api/types.ts | 37 +-
web/src/lib/api/wanted.ts | 9 +
.../lib/components/SearchSettingsCard.svelte | 87 ++-
.../lib/components/SearchSettingsCard.test.ts | 24 +-
.../lib/components/wanted/BlockList.svelte | 12 +-
.../lib/components/wanted/BlockList.test.ts | 7 +
.../lib/components/wanted/DownloadList.svelte | 81 ++-
.../components/wanted/DownloadList.test.ts | 63 +-
web/src/lib/components/wanted/WantList.svelte | 5 +-
.../lib/components/wanted/WantList.test.ts | 11 +
web/src/lib/styles/error-copy.json | 4 +-
.../admin/integrations/integrations.test.ts | 5 +-
34 files changed, 2362 insertions(+), 106 deletions(-)
create mode 100644 internal/db/dbq/lifecycle.sql.go
create mode 100644 internal/db/migrations/0084_download_lifecycle.down.sql
create mode 100644 internal/db/migrations/0084_download_lifecycle.up.sql
create mode 100644 internal/db/queries/lifecycle.sql
create mode 100644 internal/lifecycle/lifecycle.go
create mode 100644 internal/lifecycle/lifecycle_test.go
diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go
index 028f6a82..b8add60c 100644
--- a/cmd/minstrel/main.go
+++ b/cmd/minstrel/main.go
@@ -24,6 +24,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/gc"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
@@ -341,6 +342,11 @@ func run() error {
}
go importer.New(pool, scanner, cat, printer, cfg.Library.ScanPaths, logger.With("component", "import")).Run(ctx)
+ // Download lifecycle (M502 #5523): every ten minutes, a download that
+ // failed or that nobody shares is retried, then given up and its album
+ // searched again; an imported one seeds, then is removed.
+ go lifecycle.New(pool, logger.With("component", "lifecycle")).Run(ctx)
+
// Duplicate resolver (M498, M502): classifies the sweep's groups and, with
// the operator's auto-resolve on, merges the copies that are safe to merge.
// Started after SetNotifier so its summaries reach admins. See
diff --git a/internal/acquire/memory.go b/internal/acquire/memory.go
index bf8ab8f7..9f408de8 100644
--- a/internal/acquire/memory.go
+++ b/internal/acquire/memory.go
@@ -21,6 +21,9 @@ const (
BlockFiles = "files"
// BlockImport: the import found its files were not the album (#5522).
BlockImport = "import"
+ // BlockStalled: given up on because it would not finish (#5523); held
+ // back until the block's expires_at.
+ BlockStalled = "stalled"
)
// Grab origins, matching grabs_origin_check.
@@ -92,11 +95,22 @@ func (m *Memory) blocked(ctx context.Context) map[string]string {
// BlockSentence is what a blocked candidate says.
func BlockSentence(reason, detail string) string {
- if reason == BlockFiles {
+ switch reason {
+ case BlockFiles:
if detail != "" {
return "Set aside for what its files hold: " + detail
}
return "Set aside for what its files hold."
+ case BlockImport:
+ if detail != "" {
+ return "Refused on import: " + detail
+ }
+ return "Refused on import: its files were not the album."
+ case BlockStalled:
+ if detail != "" {
+ return "Given up on for now: " + detail
+ }
+ return "Given up on for now: it would not finish."
}
return "You set this release aside."
}
diff --git a/internal/acquire/settings.go b/internal/acquire/settings.go
index a7a9c9ea..01d4b92a 100644
--- a/internal/acquire/settings.go
+++ b/internal/acquire/settings.go
@@ -12,8 +12,11 @@ package acquire
import (
"context"
+ "errors"
+ "fmt"
"time"
+ "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -154,6 +157,19 @@ func DownloadClient(row dbq.AcquisitionService) (downloader.Driver, error) {
return downloader.Mapped(d, downloader.PathMap{Local: row.LocalPath, Remote: row.RemotePath}), nil
}
+// DownloadClientFor builds the download client a grab went to, by the code
+// of its acquisition_services row.
+func DownloadClientFor(ctx context.Context, pool *pgxpool.Pool, code string) (downloader.Driver, error) {
+ row, err := dbq.New(pool).GetAcquisitionService(ctx, code)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, fmt.Errorf("the download client %q is no longer set up", code)
+ }
+ return nil, err
+ }
+ return DownloadClient(row)
+}
+
// Sources builds every enabled search source.
func Sources(ctx context.Context, pool *pgxpool.Pool) ([]Source, error) {
rows, err := dbq.New(pool).ListUsableAcquisitionServices(ctx, JobSearch)
diff --git a/internal/api/admin_import.go b/internal/api/admin_import.go
index 71675013..3535ab84 100644
--- a/internal/api/admin_import.go
+++ b/internal/api/admin_import.go
@@ -10,6 +10,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
)
// Downloads and their imports (M502 #5522): the Wanted view's Activity tab,
@@ -40,6 +41,24 @@ type downloadView struct {
Files []string `json:"files"`
Skipped int `json:"skipped"`
RehomedFrom string `json:"rehomed_from,omitempty"`
+ // EndDetail says why an ended download ended (#5523).
+ EndDetail string `json:"end_detail"`
+ // Seeding: imported and still sharing; Ratio its share ratio as last
+ // read; SeedReleasedAt when it was removed once it had shared enough.
+ Seeding bool `json:"seeding"`
+ Ratio float32 `json:"ratio"`
+ SeedReleasedAt *string `json:"seed_released_at"`
+ // Condition is what is wrong with it now, if anything.
+ Condition *conditionView `json:"condition"`
+}
+
+// conditionView is an open row of the intervention ledger: what was seen,
+// since when, and how many times it was retried.
+type conditionView struct {
+ Trigger string `json:"trigger"`
+ Detail string `json:"detail"`
+ Retries int32 `json:"retries"`
+ Since string `json:"since"`
}
func downloadFrom(g dbq.ListGrabsRow) downloadView {
@@ -48,6 +67,18 @@ func downloadFrom(g dbq.ListGrabsRow) downloadView {
Origin: g.Origin, GrabbedAt: formatTimestamp(g.GrabbedAt), EndReason: g.EndReason,
Progress: g.Progress, ImportState: g.ImportState, ImportReason: g.ImportReason,
ImportedFiles: g.ImportedFiles, Destination: g.Destination, Files: []string{},
+ EndDetail: g.EndDetail, Seeding: g.Seeding, Ratio: g.Ratio,
+ }
+ if g.SeedReleasedAt.Valid {
+ at := formatTimestamp(g.SeedReleasedAt)
+ v.SeedReleasedAt = &at
+ }
+ if g.Condition != nil {
+ v.Condition = &conditionView{Trigger: *g.Condition, Detail: deref(g.ConditionDetail),
+ Since: formatTimestamp(g.ConditionSince)}
+ if g.ConditionRetries != nil {
+ v.Condition.Retries = *g.ConditionRetries
+ }
}
if g.WantID.Valid {
id := uuidToString(g.WantID)
@@ -82,6 +113,14 @@ type downloadsPage struct {
Items []downloadView `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
+ // ClientsDown are the download clients not answering now (#5523).
+ ClientsDown []clientDownView `json:"clients_down"`
+}
+
+type clientDownView struct {
+ Client string `json:"client"`
+ Detail string `json:"detail"`
+ Since string `json:"since"`
}
// handleListDownloads implements GET /api/admin/downloads?offset=: running
@@ -97,13 +136,47 @@ func (h *handlers) handleListDownloads(w http.ResponseWriter, r *http.Request) {
writeErrWithLog(w, h.logger, "admin downloads: list", apierror.Internal(err))
return
}
- out := downloadsPage{Items: make([]downloadView, 0, len(rows)), Limit: limit, Offset: offset}
+ down, err := dbq.New(h.pool).ListClientsDown(r.Context())
+ if err != nil {
+ writeErrWithLog(w, h.logger, "admin downloads: clients down", apierror.Internal(err))
+ return
+ }
+ out := downloadsPage{Items: make([]downloadView, 0, len(rows)), Limit: limit, Offset: offset,
+ ClientsDown: make([]clientDownView, 0, len(down))}
for _, g := range rows {
out.Items = append(out.Items, downloadFrom(g))
}
+ for _, c := range down {
+ out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail,
+ Since: formatTimestamp(c.FirstSeen)})
+ }
writeJSON(w, http.StatusOK, out)
}
+// handleAbandonDownload implements POST /api/admin/downloads/{id}/abandon: a
+// running download is given up, its release set aside, the download removed
+// and the album searched again.
+func (h *handlers) handleAbandonDownload(w http.ResponseWriter, r *http.Request) {
+ admin, ok := requireUser(w, r)
+ if !ok {
+ return
+ }
+ id, ok := requireURLUUID(w, r, "id")
+ if !ok {
+ return
+ }
+ if _, err := h.lifecycle.Abandon(r.Context(), id, admin.ID); err != nil {
+ switch {
+ case errors.Is(err, pgx.ErrNoRows), errors.Is(err, lifecycle.ErrNotAbandonable):
+ writeErr(w, apierror.Conflict("not_running", "that download is no longer running"))
+ default:
+ writeErrWithLog(w, h.logger, "admin downloads: abandon", apierror.Internal(err))
+ }
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
// handleImportAnyway implements POST /api/admin/downloads/{id}/import: a
// download the check held for a person (or one that failed) is imported on
// the next pass whatever the check says.
@@ -160,13 +233,16 @@ func (h *handlers) writeImportErr(w http.ResponseWriter, what string, err error)
}
type releaseBlockView struct {
- InfoHash string `json:"info_hash"`
- Name string `json:"name"`
- Reason string `json:"reason"`
- Detail string `json:"detail"`
- BlockedAt string `json:"blocked_at"`
- WantTitle string `json:"want_title"`
- WantArtist string `json:"want_artist"`
+ InfoHash string `json:"info_hash"`
+ Name string `json:"name"`
+ Reason string `json:"reason"`
+ Detail string `json:"detail"`
+ BlockedAt string `json:"blocked_at"`
+ // ExpiresAt is when a release given up on may be taken again; null is
+ // for good (#5523).
+ ExpiresAt *string `json:"expires_at"`
+ WantTitle string `json:"want_title"`
+ WantArtist string `json:"want_artist"`
}
type releaseBlocksPage struct {
@@ -193,6 +269,10 @@ func (h *handlers) handleListReleaseBlocks(w http.ResponseWriter, r *http.Reques
for _, b := range rows {
v := releaseBlockView{InfoHash: b.InfoHash, Name: b.Name, Reason: b.Reason, Detail: b.Detail,
BlockedAt: formatTimestamp(b.BlockedAt)}
+ if b.ExpiresAt.Valid {
+ at := formatTimestamp(b.ExpiresAt)
+ v.ExpiresAt = &at
+ }
if b.WantTitle != nil {
v.WantTitle = *b.WantTitle
}
diff --git a/internal/api/admin_search.go b/internal/api/admin_search.go
index 84a17101..35540523 100644
--- a/internal/api/admin_search.go
+++ b/internal/api/admin_search.go
@@ -37,6 +37,12 @@ type searchSettingsView struct {
// is the first of ScanPaths, the library's folders.
ImportRoot string `json:"import_root"`
ScanPaths []string `json:"scan_paths"`
+ // The download lifecycle (#5523): seed an imported download to this
+ // ratio or for this many days; give up on one nobody shares after this
+ // many hours. 0 is: remove once imported, no time limit, never.
+ SeedRatio float32 `json:"seed_ratio"`
+ SeedDays int32 `json:"seed_days"`
+ StallHours int32 `json:"stall_hours"`
}
func searchSettings(s dbq.AcquisitionSetting, roots []string) searchSettingsView {
@@ -44,6 +50,7 @@ func searchSettings(s dbq.AcquisitionSetting, roots []string) searchSettingsView
AutoGrab: s.AutoGrab, SearchIntervalHours: s.SearchIntervalHours,
AllowedQualities: s.AllowedQualities, Tiers: release.Tiers, LastPass: s.LastPass,
ImportRoot: s.ImportRoot, ScanPaths: roots,
+ SeedRatio: s.SeedRatio, SeedDays: s.SeedDays, StallHours: s.StallHours,
}
if v.ScanPaths == nil {
v.ScanPaths = []string{}
@@ -76,6 +83,9 @@ type putSearchSettingsBody struct {
SearchIntervalHours int32 `json:"search_interval_hours"`
AllowedQualities []string `json:"allowed_qualities"`
ImportRoot string `json:"import_root"`
+ SeedRatio float32 `json:"seed_ratio"`
+ SeedDays int32 `json:"seed_days"`
+ StallHours int32 `json:"stall_hours"`
}
// handlePutSearchSettings implements PUT /api/admin/acquisition/settings.
@@ -102,9 +112,15 @@ func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Reques
writeErr(w, apierror.BadRequest("bad_import_root", "file new music into one of the library's folders"))
return
}
+ if body.SeedRatio < 0 || body.SeedRatio > 100 || body.SeedDays < 0 || body.SeedDays > 365 ||
+ body.StallHours < 0 || body.StallHours > 720 {
+ writeErr(w, apierror.BadRequest("bad_lifecycle",
+ "seed ratio must be 0 to 100, seed days 0 to 365 and stall hours 0 to 720"))
+ return
+ }
s, err := dbq.New(h.pool).UpdateAcquisitionSettings(r.Context(), dbq.UpdateAcquisitionSettingsParams{
AutoGrab: body.AutoGrab, SearchIntervalHours: body.SearchIntervalHours, AllowedQualities: body.AllowedQualities,
- ImportRoot: body.ImportRoot,
+ ImportRoot: body.ImportRoot, SeedRatio: body.SeedRatio, SeedDays: body.SeedDays, StallHours: body.StallHours,
})
if err != nil {
writeErrWithLog(w, h.logger, "admin search settings: put", apierror.Internal(err))
diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go
index d5cc061f..8dec9100 100644
--- a/internal/api/admin_wanted.go
+++ b/internal/api/admin_wanted.go
@@ -62,6 +62,7 @@ type wantGrabView struct {
Progress float32 `json:"progress"`
ImportState string `json:"import_state"`
ImportReason string `json:"import_reason"`
+ EndDetail string `json:"end_detail"`
ImportedAt *string `json:"imported_at"`
ImportedFiles int32 `json:"imported_files"`
}
@@ -164,7 +165,8 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
v.Grab = &wantGrabView{ID: uuidToString(row.GrabID), Name: *row.GrabName,
GrabbedAt: formatTimestamp(row.GrabbedAt), Live: !row.GrabEndedAt.Valid,
Origin: deref(row.GrabOrigin), Quality: deref(row.GrabQuality), EndReason: deref(row.GrabEndReason),
- ImportState: deref(row.GrabImportState), ImportReason: deref(row.GrabImportReason)}
+ ImportState: deref(row.GrabImportState), ImportReason: deref(row.GrabImportReason),
+ EndDetail: deref(row.GrabEndDetail)}
if row.GrabProgress != nil {
v.Grab.Progress = *row.GrabProgress
}
diff --git a/internal/api/api.go b/internal/api/api.go
index fc4d6133..1b7a0225 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -23,6 +23,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
@@ -80,6 +81,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
wanted: wanted.New(pool, cat, logger),
searcher: wanted.NewSearcher(pool, cat, logger),
importer: importer.New(pool, scanner, cat, nil, scannerRoots(scanner), logger),
+ lifecycle: lifecycle.New(pool, logger),
catalogueSearches: newSearchCache(),
catalogueArt: catalogue.NewArt(dataDir, artSources(coverSettings)),
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
@@ -264,6 +266,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/downloads/{id}/import", h.handleImportAnyway)
admin.Post("/downloads/{id}/retry", h.handleRetryImport)
admin.Post("/downloads/{id}/refuse", h.handleRefuseDownload)
+ admin.Post("/downloads/{id}/abandon", h.handleAbandonDownload)
admin.Get("/follows", h.handleListFollows)
admin.Get("/follows/{mbid}", h.handleGetFollow)
admin.Put("/follows/{mbid}", h.handlePutFollow)
@@ -437,6 +440,9 @@ type handlers struct {
// importer refuses a download at a person's word (#5522). The import
// loop itself runs in main; this one only acts on a request.
importer *importer.Importer
+ // lifecycle gives up on a download at a person's word (#5523); its loop
+ // runs in main.
+ lifecycle *lifecycle.Service
// catalogueSearches reuses recent MusicBrainz answers; nil asks every
// time, which is what handler tests get.
catalogueSearches *searchCache
diff --git a/internal/db/dbq/imports.sql.go b/internal/db/dbq/imports.sql.go
index df96ed4d..1d1bb0c6 100644
--- a/internal/db/dbq/imports.sql.go
+++ b/internal/db/dbq/imports.sql.go
@@ -16,7 +16,7 @@ UPDATE grabs
SET import_state = 'importing', import_attempts = import_attempts + 1,
import_updated_at = now(), progress = 1
WHERE id = $1 AND ended_at IS NULL AND import_state IN ('', 'downloading', 'waiting')
-RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
+RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination, seeding, ratio, seed_released_at
`
// Takes a finished download for import. The update is the lock: a second
@@ -52,6 +52,9 @@ func (q *Queries) ClaimGrabImport(ctx context.Context, id pgtype.UUID) (Grab, er
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
)
return i, err
}
@@ -60,7 +63,7 @@ const forceGrabImport = `-- name: ForceGrabImport :one
UPDATE grabs
SET import_state = '', import_forced = true, import_reason = '', import_updated_at = now()
WHERE id = $1 AND ended_at IS NULL AND import_state IN ('review', 'failed')
-RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
+RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination, seeding, ratio, seed_released_at
`
// "Import anyway": the next pass imports the download whatever the check said.
@@ -95,12 +98,15 @@ func (q *Queries) ForceGrabImport(ctx context.Context, id pgtype.UUID) (Grab, er
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
)
return i, err
}
const getGrab = `-- name: GetGrab :one
-SELECT id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination FROM grabs WHERE id = $1
+SELECT id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination, seeding, ratio, seed_released_at FROM grabs WHERE id = $1
`
func (q *Queries) GetGrab(ctx context.Context, id pgtype.UUID) (Grab, error) {
@@ -134,6 +140,9 @@ func (q *Queries) GetGrab(ctx context.Context, id pgtype.UUID) (Grab, error) {
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
)
return i, err
}
@@ -202,9 +211,16 @@ const listGrabs = `-- name: ListGrabs :many
SELECT g.id, g.want_id, g.name, g.indexer, g.client, g.quality, g.origin, g.forced,
g.grabbed_at, g.ended_at, g.end_reason, g.end_detail, g.progress, g.import_state,
g.import_reason, g.import_detail, g.import_updated_at, g.imported_at, g.imported_files,
- g.destination, w.title AS want_title, w.artist_name AS want_artist
+ g.destination, g.seeding, g.ratio, g.seed_released_at,
+ w.title AS want_title, w.artist_name AS want_artist,
+ di.trigger AS condition, di.detail AS condition_detail, di.remediations AS condition_retries,
+ di.first_seen AS condition_since
FROM grabs g
LEFT JOIN wants w ON w.id = g.want_id
+ LEFT JOIN download_interventions di
+ ON di.id = (SELECT x.id FROM download_interventions x
+ WHERE x.grab_id = g.id AND x.outcome = 'open'
+ ORDER BY x.last_seen DESC LIMIT 1)
ORDER BY (g.ended_at IS NULL AND g.import_state IN ('review', 'failed')) DESC,
g.ended_at IS NULL DESC, g.grabbed_at DESC, g.id
LIMIT $2 OFFSET $1
@@ -216,32 +232,40 @@ type ListGrabsParams struct {
}
type ListGrabsRow struct {
- ID pgtype.UUID
- WantID pgtype.UUID
- Name string
- Indexer string
- Client string
- Quality string
- Origin string
- Forced bool
- GrabbedAt pgtype.Timestamptz
- EndedAt pgtype.Timestamptz
- EndReason string
- EndDetail string
- Progress float32
- ImportState string
- ImportReason string
- ImportDetail []byte
- ImportUpdatedAt pgtype.Timestamptz
- ImportedAt pgtype.Timestamptz
- ImportedFiles int32
- Destination string
- WantTitle *string
- WantArtist *string
+ ID pgtype.UUID
+ WantID pgtype.UUID
+ Name string
+ Indexer string
+ Client string
+ Quality string
+ Origin string
+ Forced bool
+ GrabbedAt pgtype.Timestamptz
+ EndedAt pgtype.Timestamptz
+ EndReason string
+ EndDetail string
+ Progress float32
+ ImportState string
+ ImportReason string
+ ImportDetail []byte
+ ImportUpdatedAt pgtype.Timestamptz
+ ImportedAt pgtype.Timestamptz
+ ImportedFiles int32
+ Destination string
+ Seeding bool
+ Ratio float32
+ SeedReleasedAt pgtype.Timestamptz
+ WantTitle *string
+ WantArtist *string
+ Condition *string
+ ConditionDetail *string
+ ConditionRetries *int32
+ ConditionSince pgtype.Timestamptz
}
// The Wanted view's Activity tab: downloads waiting on a person first, then
-// the rest still running, then the ended ones newest first.
+// the rest still running, then the ended ones newest first. Each carries
+// what is wrong with it now, if anything (#5523).
func (q *Queries) ListGrabs(ctx context.Context, arg ListGrabsParams) ([]ListGrabsRow, error) {
rows, err := q.db.Query(ctx, listGrabs, arg.Skip, arg.MaxRows)
if err != nil {
@@ -272,8 +296,15 @@ func (q *Queries) ListGrabs(ctx context.Context, arg ListGrabsParams) ([]ListGra
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
&i.WantTitle,
&i.WantArtist,
+ &i.Condition,
+ &i.ConditionDetail,
+ &i.ConditionRetries,
+ &i.ConditionSince,
); err != nil {
return nil, err
}
@@ -287,7 +318,7 @@ func (q *Queries) ListGrabs(ctx context.Context, arg ListGrabsParams) ([]ListGra
const listLiveGrabs = `-- name: ListLiveGrabs :many
-SELECT g.id, g.want_id, g.info_hash, g.name, g.key, g.indexer, g.source, g.client, g.save_path, g.quality, g.origin, g.forced, g.grabbed_by, g.grabbed_at, g.ended_at, g.end_reason, g.end_detail, g.progress, g.import_state, g.import_reason, g.import_detail, g.import_forced, g.import_attempts, g.import_updated_at, g.imported_at, g.imported_files, g.destination, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title AS want_title,
+SELECT g.id, g.want_id, g.info_hash, g.name, g.key, g.indexer, g.source, g.client, g.save_path, g.quality, g.origin, g.forced, g.grabbed_by, g.grabbed_at, g.ended_at, g.end_reason, g.end_detail, g.progress, g.import_state, g.import_reason, g.import_detail, g.import_forced, g.import_attempts, g.import_updated_at, g.imported_at, g.imported_files, g.destination, g.seeding, g.ratio, g.seed_released_at, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title AS want_title,
w.reason AS want_reason, w.album_id AS want_album_id
FROM grabs g
LEFT JOIN wants w ON w.id = g.want_id
@@ -323,6 +354,9 @@ type ListLiveGrabsRow struct {
ImportedAt pgtype.Timestamptz
ImportedFiles int32
Destination string
+ Seeding bool
+ Ratio float32
+ SeedReleasedAt pgtype.Timestamptz
ReleaseGroupMbid *string
ArtistMbid *string
ArtistName *string
@@ -370,6 +404,9 @@ func (q *Queries) ListLiveGrabs(ctx context.Context) ([]ListLiveGrabsRow, error)
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
@@ -388,10 +425,11 @@ func (q *Queries) ListLiveGrabs(ctx context.Context) ([]ListLiveGrabsRow, error)
}
const listReleaseBlocksPage = `-- name: ListReleaseBlocksPage :many
-SELECT b.info_hash, b.name, b.reason, b.detail, b.blocked_at, w.title AS want_title,
+SELECT b.info_hash, b.name, b.reason, b.detail, b.blocked_at, b.expires_at, w.title AS want_title,
w.artist_name AS want_artist
FROM release_blocks b
LEFT JOIN wants w ON w.id = b.want_id
+ WHERE b.expires_at IS NULL OR b.expires_at > now()
ORDER BY b.blocked_at DESC, b.info_hash
LIMIT $2 OFFSET $1
`
@@ -407,6 +445,7 @@ type ListReleaseBlocksPageRow struct {
Reason string
Detail string
BlockedAt pgtype.Timestamptz
+ ExpiresAt pgtype.Timestamptz
WantTitle *string
WantArtist *string
}
@@ -426,6 +465,7 @@ func (q *Queries) ListReleaseBlocksPage(ctx context.Context, arg ListReleaseBloc
&i.Reason,
&i.Detail,
&i.BlockedAt,
+ &i.ExpiresAt,
&i.WantTitle,
&i.WantArtist,
); err != nil {
@@ -522,13 +562,16 @@ UPDATE grabs
import_updated_at = now(),
import_forced = false,
imported_at = CASE WHEN $1::text = 'imported' THEN now() ELSE imported_at END,
+ -- The files were copied, so an imported download goes on sharing
+ -- until the lifecycle removes it (#5523).
+ seeding = $1::text = 'imported',
imported_files = $4,
destination = $5,
ended_at = CASE WHEN $6::text <> '' THEN now() ELSE ended_at END,
end_reason = $6,
end_detail = CASE WHEN $6::text <> '' THEN $2::text ELSE end_detail END
WHERE id = $7
-RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
+RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination, seeding, ratio, seed_released_at
`
type RecordImportParams struct {
@@ -582,6 +625,9 @@ func (q *Queries) RecordImport(ctx context.Context, arg RecordImportParams) (Gra
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
)
return i, err
}
@@ -591,7 +637,7 @@ UPDATE grabs g
SET want_id = $1
WHERE g.id = $2 AND g.ended_at IS NULL
AND NOT EXISTS (SELECT 1 FROM grabs o WHERE o.want_id = $1 AND o.ended_at IS NULL)
-RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
+RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination, seeding, ratio, seed_released_at
`
type RehomeGrabParams struct {
@@ -632,6 +678,9 @@ func (q *Queries) RehomeGrab(ctx context.Context, arg RehomeGrabParams) (Grab, e
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
)
return i, err
}
@@ -640,7 +689,7 @@ const retryGrabImport = `-- name: RetryGrabImport :one
UPDATE grabs
SET import_state = '', import_reason = '', import_updated_at = now()
WHERE id = $1 AND ended_at IS NULL AND import_state = 'failed'
-RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
+RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination, seeding, ratio, seed_released_at
`
func (q *Queries) RetryGrabImport(ctx context.Context, id pgtype.UUID) (Grab, error) {
@@ -674,6 +723,9 @@ func (q *Queries) RetryGrabImport(ctx context.Context, id pgtype.UUID) (Grab, er
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
)
return i, err
}
diff --git a/internal/db/dbq/lifecycle.sql.go b/internal/db/dbq/lifecycle.sql.go
new file mode 100644
index 00000000..7e02d817
--- /dev/null
+++ b/internal/db/dbq/lifecycle.sql.go
@@ -0,0 +1,389 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: lifecycle.sql
+
+package dbq
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const countRemediation = `-- name: CountRemediation :one
+UPDATE download_interventions
+ SET remediations = remediations + 1,
+ action = $1,
+ last_seen = now(),
+ outcome = CASE WHEN $1::text IN ('given_up', 'removed') THEN 'resolved' ELSE outcome END,
+ settled_at = CASE WHEN $1::text IN ('given_up', 'removed') THEN now() ELSE settled_at END
+ WHERE client = $2 AND info_hash = $3
+ AND trigger = $4 AND outcome = 'open'
+RETURNING remediations
+`
+
+type CountRemediationParams struct {
+ Action string
+ Client string
+ InfoHash string
+ Trigger string
+}
+
+// Minstrel acted on an open condition. A retry climbs the ladder; giving up
+// or removing ends the condition, since what it was about is gone. Written
+// before the act, so a retry that happened is never uncounted.
+func (q *Queries) CountRemediation(ctx context.Context, arg CountRemediationParams) (int32, error) {
+ row := q.db.QueryRow(ctx, countRemediation,
+ arg.Action,
+ arg.Client,
+ arg.InfoHash,
+ arg.Trigger,
+ )
+ var remediations int32
+ err := row.Scan(&remediations)
+ return remediations, err
+}
+
+const deleteExpiredBlocks = `-- name: DeleteExpiredBlocks :execrows
+DELETE FROM release_blocks WHERE expires_at IS NOT NULL AND expires_at <= now()
+`
+
+// A release given up on is held back for a while; after that the search may
+// take it again.
+func (q *Queries) DeleteExpiredBlocks(ctx context.Context) (int64, error) {
+ result, err := q.db.Exec(ctx, deleteExpiredBlocks)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const giveUpGrab = `-- name: GiveUpGrab :one
+UPDATE grabs
+ SET ended_at = now(), end_reason = $1, end_detail = $2,
+ import_updated_at = now()
+ WHERE id = $3 AND ended_at IS NULL AND import_state = ANY($4::text[])
+RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination, seeding, ratio, seed_released_at
+`
+
+type GiveUpGrabParams struct {
+ EndReason string
+ EndDetail string
+ ID pgtype.UUID
+ States []string
+}
+
+// Ends a running grab, given up on. Only from the states named: the update
+// is the lock against an import that claimed the download meanwhile.
+func (q *Queries) GiveUpGrab(ctx context.Context, arg GiveUpGrabParams) (Grab, error) {
+ row := q.db.QueryRow(ctx, giveUpGrab,
+ arg.EndReason,
+ arg.EndDetail,
+ arg.ID,
+ arg.States,
+ )
+ var i Grab
+ err := row.Scan(
+ &i.ID,
+ &i.WantID,
+ &i.InfoHash,
+ &i.Name,
+ &i.Key,
+ &i.Indexer,
+ &i.Source,
+ &i.Client,
+ &i.SavePath,
+ &i.Quality,
+ &i.Origin,
+ &i.Forced,
+ &i.GrabbedBy,
+ &i.GrabbedAt,
+ &i.EndedAt,
+ &i.EndReason,
+ &i.EndDetail,
+ &i.Progress,
+ &i.ImportState,
+ &i.ImportReason,
+ &i.ImportDetail,
+ &i.ImportForced,
+ &i.ImportAttempts,
+ &i.ImportUpdatedAt,
+ &i.ImportedAt,
+ &i.ImportedFiles,
+ &i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
+ )
+ return i, err
+}
+
+const listClientsDown = `-- name: ListClientsDown :many
+SELECT client, detail, first_seen FROM download_interventions
+ WHERE trigger = 'client_unreachable' AND outcome = 'open'
+ ORDER BY client
+`
+
+type ListClientsDownRow struct {
+ Client string
+ Detail string
+ FirstSeen pgtype.Timestamptz
+}
+
+// Download clients not answering now, for the Activity tab.
+func (q *Queries) ListClientsDown(ctx context.Context) ([]ListClientsDownRow, error) {
+ rows, err := q.db.Query(ctx, listClientsDown)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListClientsDownRow
+ for rows.Next() {
+ var i ListClientsDownRow
+ if err := rows.Scan(&i.Client, &i.Detail, &i.FirstSeen); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listLeftovers = `-- name: ListLeftovers :many
+SELECT id, client, info_hash, grab_id, name, trigger, action, outcome, detail, observations, remediations, first_seen, last_seen, settled_at FROM download_interventions
+ WHERE trigger = 'not_removed' AND outcome = 'open' AND info_hash <> ''
+ ORDER BY first_seen
+`
+
+// Downloads given up on that the client would not remove; each pass asks
+// again.
+func (q *Queries) ListLeftovers(ctx context.Context) ([]DownloadIntervention, error) {
+ rows, err := q.db.Query(ctx, listLeftovers)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []DownloadIntervention
+ for rows.Next() {
+ var i DownloadIntervention
+ if err := rows.Scan(
+ &i.ID,
+ &i.Client,
+ &i.InfoHash,
+ &i.GrabID,
+ &i.Name,
+ &i.Trigger,
+ &i.Action,
+ &i.Outcome,
+ &i.Detail,
+ &i.Observations,
+ &i.Remediations,
+ &i.FirstSeen,
+ &i.LastSeen,
+ &i.SettledAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listSeedingGrabs = `-- name: ListSeedingGrabs :many
+SELECT id, client, info_hash, name, imported_at FROM grabs
+ WHERE seeding AND seed_released_at IS NULL
+ ORDER BY imported_at, id
+`
+
+type ListSeedingGrabsRow struct {
+ ID pgtype.UUID
+ Client string
+ InfoHash string
+ Name string
+ ImportedAt pgtype.Timestamptz
+}
+
+// Imported downloads still sharing, oldest import first.
+func (q *Queries) ListSeedingGrabs(ctx context.Context) ([]ListSeedingGrabsRow, error) {
+ rows, err := q.db.Query(ctx, listSeedingGrabs)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListSeedingGrabsRow
+ for rows.Next() {
+ var i ListSeedingGrabsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Client,
+ &i.InfoHash,
+ &i.Name,
+ &i.ImportedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const liveGrabOnHash = `-- name: LiveGrabOnHash :one
+SELECT EXISTS (SELECT 1 FROM grabs WHERE info_hash = $1 AND ended_at IS NULL)::bool
+`
+
+func (q *Queries) LiveGrabOnHash(ctx context.Context, infoHash string) (bool, error) {
+ row := q.db.QueryRow(ctx, liveGrabOnHash, infoHash)
+ var column_1 bool
+ err := row.Scan(&column_1)
+ return column_1, err
+}
+
+const raiseIntervention = `-- name: RaiseIntervention :one
+
+INSERT INTO download_interventions (client, info_hash, grab_id, name, trigger, detail)
+VALUES ($1, $2, $3, $4,
+ $5, $6)
+ON CONFLICT (client, info_hash, trigger) WHERE outcome = 'open' DO UPDATE SET
+ observations = download_interventions.observations + 1,
+ last_seen = now(),
+ detail = EXCLUDED.detail,
+ grab_id = COALESCE(EXCLUDED.grab_id, download_interventions.grab_id),
+ name = CASE WHEN EXCLUDED.name <> '' THEN EXCLUDED.name ELSE download_interventions.name END
+RETURNING id, client, info_hash, grab_id, name, trigger, action, outcome, detail, observations, remediations, first_seen, last_seen, settled_at
+`
+
+type RaiseInterventionParams struct {
+ Client string
+ InfoHash string
+ GrabID pgtype.UUID
+ Name string
+ Trigger string
+ Detail string
+}
+
+// The download lifecycle (M502 #5523): the intervention ledger, giving a
+// download up, and seeding after import.
+// A condition seen, or seen again: a second pass lands on the open row, so
+// a download stuck for a week is one row that says how long. The name only
+// improves, and the detail is the latest word.
+func (q *Queries) RaiseIntervention(ctx context.Context, arg RaiseInterventionParams) (DownloadIntervention, error) {
+ row := q.db.QueryRow(ctx, raiseIntervention,
+ arg.Client,
+ arg.InfoHash,
+ arg.GrabID,
+ arg.Name,
+ arg.Trigger,
+ arg.Detail,
+ )
+ var i DownloadIntervention
+ err := row.Scan(
+ &i.ID,
+ &i.Client,
+ &i.InfoHash,
+ &i.GrabID,
+ &i.Name,
+ &i.Trigger,
+ &i.Action,
+ &i.Outcome,
+ &i.Detail,
+ &i.Observations,
+ &i.Remediations,
+ &i.FirstSeen,
+ &i.LastSeen,
+ &i.SettledAt,
+ )
+ return i, err
+}
+
+const releaseSeed = `-- name: ReleaseSeed :exec
+UPDATE grabs SET seed_released_at = now() WHERE id = $1 AND seed_released_at IS NULL
+`
+
+// The seeding download was removed, or found gone.
+func (q *Queries) ReleaseSeed(ctx context.Context, id pgtype.UUID) error {
+ _, err := q.db.Exec(ctx, releaseSeed, id)
+ return err
+}
+
+const setGrabRatio = `-- name: SetGrabRatio :exec
+UPDATE grabs SET ratio = $1 WHERE id = $2
+`
+
+type SetGrabRatioParams struct {
+ Ratio float32
+ ID pgtype.UUID
+}
+
+func (q *Queries) SetGrabRatio(ctx context.Context, arg SetGrabRatioParams) error {
+ _, err := q.db.Exec(ctx, setGrabRatio, arg.Ratio, arg.ID)
+ return err
+}
+
+const settleEndedGrabs = `-- name: SettleEndedGrabs :execrows
+UPDATE download_interventions di
+ SET outcome = 'resolved', settled_at = now(), last_seen = now()
+ FROM grabs g
+ WHERE g.id = di.grab_id AND g.ended_at IS NOT NULL
+ AND di.outcome = 'open' AND di.trigger <> 'not_removed'
+`
+
+// What was wrong with a download whose grab has ended (imported, refused,
+// given up) is over, except a download left in the client to remove.
+func (q *Queries) SettleEndedGrabs(ctx context.Context) (int64, error) {
+ result, err := q.db.Exec(ctx, settleEndedGrabs)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const settleIntervention = `-- name: SettleIntervention :execrows
+UPDATE download_interventions
+ SET outcome = 'resolved', settled_at = now(), last_seen = now()
+ WHERE client = $1 AND info_hash = $2
+ AND trigger = $3 AND outcome = 'open'
+`
+
+type SettleInterventionParams struct {
+ Client string
+ InfoHash string
+ Trigger string
+}
+
+// The condition cleared.
+func (q *Queries) SettleIntervention(ctx context.Context, arg SettleInterventionParams) (int64, error) {
+ result, err := q.db.Exec(ctx, settleIntervention, arg.Client, arg.InfoHash, arg.Trigger)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const settleInterventionsFor = `-- name: SettleInterventionsFor :execrows
+UPDATE download_interventions
+ SET outcome = 'resolved', settled_at = now(), last_seen = now()
+ WHERE client = $1 AND info_hash = $2 AND outcome = 'open'
+`
+
+type SettleInterventionsForParams struct {
+ Client string
+ InfoHash string
+}
+
+// Everything open about one download is over: it is gone from the client.
+func (q *Queries) SettleInterventionsFor(ctx context.Context, arg SettleInterventionsForParams) (int64, error) {
+ result, err := q.db.Exec(ctx, settleInterventionsFor, arg.Client, arg.InfoHash)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go
index c42a4152..d656ee35 100644
--- a/internal/db/dbq/models.go
+++ b/internal/db/dbq/models.go
@@ -226,6 +226,9 @@ type AcquisitionSetting struct {
LastPass []byte
UpdatedAt pgtype.Timestamptz
ImportRoot string
+ SeedRatio float32
+ SeedDays int32
+ StallHours int32
}
type Album struct {
@@ -355,6 +358,23 @@ type DiscoverTuning struct {
UpdatedAt pgtype.Timestamptz
}
+type DownloadIntervention struct {
+ ID pgtype.UUID
+ Client string
+ InfoHash string
+ GrabID pgtype.UUID
+ Name string
+ Trigger string
+ Action string
+ Outcome string
+ Detail string
+ Observations int32
+ Remediations int32
+ FirstSeen pgtype.Timestamptz
+ LastSeen pgtype.Timestamptz
+ SettledAt pgtype.Timestamptz
+}
+
type DuplicateGroup struct {
ID pgtype.UUID
MemberKey string
@@ -459,6 +479,9 @@ type Grab struct {
ImportedAt pgtype.Timestamptz
ImportedFiles int32
Destination string
+ Seeding bool
+ Ratio float32
+ SeedReleasedAt pgtype.Timestamptz
}
type LibraryChange struct {
@@ -701,6 +724,7 @@ type ReleaseBlock struct {
WantID pgtype.UUID
BlockedBy pgtype.UUID
BlockedAt pgtype.Timestamptz
+ ExpiresAt pgtype.Timestamptz
}
type ReleaseTypeProfile struct {
diff --git a/internal/db/dbq/search.sql.go b/internal/db/dbq/search.sql.go
index 9ecde58f..aa30dc94 100644
--- a/internal/db/dbq/search.sql.go
+++ b/internal/db/dbq/search.sql.go
@@ -12,11 +12,13 @@ import (
)
const blockRelease = `-- name: BlockRelease :exec
-INSERT INTO release_blocks (info_hash, name, reason, detail, want_id, blocked_by)
+INSERT INTO release_blocks (info_hash, name, reason, detail, want_id, blocked_by, expires_at)
VALUES ($1, $2, $3, $4,
- $5, $6)
+ $5, $6, $7)
ON CONFLICT (info_hash) DO UPDATE
- SET reason = EXCLUDED.reason, detail = EXCLUDED.detail, blocked_at = now()
+ SET reason = EXCLUDED.reason, detail = EXCLUDED.detail, blocked_at = now(),
+ expires_at = EXCLUDED.expires_at, blocked_by = EXCLUDED.blocked_by
+ WHERE release_blocks.expires_at IS NOT NULL OR EXCLUDED.expires_at IS NULL
`
type BlockReleaseParams struct {
@@ -26,8 +28,12 @@ type BlockReleaseParams struct {
Detail string
WantID pgtype.UUID
BlockedBy pgtype.UUID
+ ExpiresAt pgtype.Timestamptz
}
+// A release set aside. expires_at NULL is for good. A block for good is never
+// shortened by a timed one: only a timed block, or another for good, is
+// replaced.
func (q *Queries) BlockRelease(ctx context.Context, arg BlockReleaseParams) error {
_, err := q.db.Exec(ctx, blockRelease,
arg.InfoHash,
@@ -36,6 +42,7 @@ func (q *Queries) BlockRelease(ctx context.Context, arg BlockReleaseParams) erro
arg.Detail,
arg.WantID,
arg.BlockedBy,
+ arg.ExpiresAt,
)
return err
}
@@ -89,7 +96,7 @@ func (q *Queries) EndGrab(ctx context.Context, arg EndGrabParams) error {
const getAcquisitionSettings = `-- name: GetAcquisitionSettings :one
-SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root FROM acquisition_settings WHERE id = true
+SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours FROM acquisition_settings WHERE id = true
`
// Searching for wants and grabbing releases (M502 #5521).
@@ -105,12 +112,15 @@ func (q *Queries) GetAcquisitionSettings(ctx context.Context) (AcquisitionSettin
&i.LastPass,
&i.UpdatedAt,
&i.ImportRoot,
+ &i.SeedRatio,
+ &i.SeedDays,
+ &i.StallHours,
)
return i, err
}
const getLiveGrab = `-- name: GetLiveGrab :one
-SELECT id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination FROM grabs WHERE want_id = $1 AND ended_at IS NULL
+SELECT id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination, seeding, ratio, seed_released_at FROM grabs WHERE want_id = $1 AND ended_at IS NULL
`
func (q *Queries) GetLiveGrab(ctx context.Context, wantID pgtype.UUID) (Grab, error) {
@@ -144,6 +154,9 @@ func (q *Queries) GetLiveGrab(ctx context.Context, wantID pgtype.UUID) (Grab, er
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
)
return i, err
}
@@ -230,7 +243,7 @@ INSERT INTO grabs (want_id, info_hash, name, key, indexer, source, client, save_
VALUES ($1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12)
-RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
+RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination, seeding, ratio, seed_released_at
`
type InsertGrabParams struct {
@@ -292,6 +305,9 @@ func (q *Queries) InsertGrab(ctx context.Context, arg InsertGrabParams) (Grab, e
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
+ &i.Seeding,
+ &i.Ratio,
+ &i.SeedReleasedAt,
)
return i, err
}
@@ -350,9 +366,13 @@ func (q *Queries) InsertWantCandidate(ctx context.Context, arg InsertWantCandida
}
const listReleaseBlocks = `-- name: ListReleaseBlocks :many
-SELECT info_hash, name, reason, detail, want_id, blocked_by, blocked_at FROM release_blocks ORDER BY blocked_at DESC
+SELECT info_hash, name, reason, detail, want_id, blocked_by, blocked_at, expires_at FROM release_blocks
+ WHERE expires_at IS NULL OR expires_at > now()
+ ORDER BY blocked_at DESC
`
+// Every release set aside and still held back: a stalled one lifts by itself
+// at expires_at (#5523).
func (q *Queries) ListReleaseBlocks(ctx context.Context) ([]ReleaseBlock, error) {
rows, err := q.db.Query(ctx, listReleaseBlocks)
if err != nil {
@@ -370,6 +390,7 @@ func (q *Queries) ListReleaseBlocks(ctx context.Context) ([]ReleaseBlock, error)
&i.WantID,
&i.BlockedBy,
&i.BlockedAt,
+ &i.ExpiresAt,
); err != nil {
return nil, err
}
@@ -582,9 +603,12 @@ UPDATE acquisition_settings
search_interval_hours = $2,
allowed_qualities = $3,
import_root = $4,
+ seed_ratio = $5,
+ seed_days = $6,
+ stall_hours = $7,
updated_at = now()
WHERE id = true
-RETURNING id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root
+RETURNING id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours
`
type UpdateAcquisitionSettingsParams struct {
@@ -592,6 +616,9 @@ type UpdateAcquisitionSettingsParams struct {
SearchIntervalHours int32
AllowedQualities []string
ImportRoot string
+ SeedRatio float32
+ SeedDays int32
+ StallHours int32
}
func (q *Queries) UpdateAcquisitionSettings(ctx context.Context, arg UpdateAcquisitionSettingsParams) (AcquisitionSetting, error) {
@@ -600,6 +627,9 @@ func (q *Queries) UpdateAcquisitionSettings(ctx context.Context, arg UpdateAcqui
arg.SearchIntervalHours,
arg.AllowedQualities,
arg.ImportRoot,
+ arg.SeedRatio,
+ arg.SeedDays,
+ arg.StallHours,
)
var i AcquisitionSetting
err := row.Scan(
@@ -611,6 +641,9 @@ func (q *Queries) UpdateAcquisitionSettings(ctx context.Context, arg UpdateAcqui
&i.LastPass,
&i.UpdatedAt,
&i.ImportRoot,
+ &i.SeedRatio,
+ &i.SeedDays,
+ &i.StallHours,
)
return i, err
}
diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go
index 3f467f48..7e7df749 100644
--- a/internal/db/dbq/wanted.sql.go
+++ b/internal/db/dbq/wanted.sql.go
@@ -30,7 +30,8 @@ SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::big
(SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity,
(SELECT COUNT(*) FROM grabs
WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::bigint AS attention,
- (SELECT COUNT(*) FROM release_blocks)::bigint AS blocked
+ (SELECT COUNT(*) FROM release_blocks b
+ WHERE b.expires_at IS NULL OR b.expires_at > now())::bigint AS blocked
FROM wants
`
@@ -537,7 +538,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
gr.id AS grab_id, gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin,
gr.quality AS grab_quality, gr.ended_at AS grab_ended_at, gr.end_reason AS grab_end_reason,
gr.progress AS grab_progress, gr.import_state AS grab_import_state,
- gr.import_reason AS grab_import_reason, gr.imported_at AS grab_imported_at,
+ gr.import_reason AS grab_import_reason, gr.end_detail AS grab_end_detail, gr.imported_at AS grab_imported_at,
gr.imported_files AS grab_imported_files
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
@@ -590,6 +591,7 @@ type ListWantsRow struct {
GrabProgress *float32
GrabImportState *string
GrabImportReason *string
+ GrabEndDetail *string
GrabImportedAt pgtype.Timestamptz
GrabImportedFiles *int32
}
@@ -636,6 +638,7 @@ func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWan
&i.GrabProgress,
&i.GrabImportState,
&i.GrabImportReason,
+ &i.GrabEndDetail,
&i.GrabImportedAt,
&i.GrabImportedFiles,
); err != nil {
diff --git a/internal/db/migrations/0084_download_lifecycle.down.sql b/internal/db/migrations/0084_download_lifecycle.down.sql
new file mode 100644
index 00000000..9e00a4d5
--- /dev/null
+++ b/internal/db/migrations/0084_download_lifecycle.down.sql
@@ -0,0 +1,23 @@
+ALTER TABLE acquisition_settings
+ DROP COLUMN seed_ratio,
+ DROP COLUMN seed_days,
+ DROP COLUMN stall_hours;
+
+ALTER TABLE release_blocks DROP COLUMN expires_at;
+DELETE FROM release_blocks WHERE reason = 'stalled';
+ALTER TABLE release_blocks DROP CONSTRAINT release_blocks_reason_check;
+ALTER TABLE release_blocks ADD CONSTRAINT release_blocks_reason_check
+ CHECK (reason IN ('operator', 'files', 'import'));
+
+DROP INDEX grabs_seeding_idx;
+ALTER TABLE grabs
+ DROP COLUMN seeding,
+ DROP COLUMN ratio,
+ DROP COLUMN seed_released_at;
+
+UPDATE grabs SET end_reason = 'superseded' WHERE end_reason IN ('stalled', 'failed', 'abandoned', 'gone');
+ALTER TABLE grabs DROP CONSTRAINT grabs_end_reason_check;
+ALTER TABLE grabs ADD CONSTRAINT grabs_end_reason_check
+ CHECK (end_reason IN ('', 'superseded', 'imported', 'refused', 'held'));
+
+DROP TABLE download_interventions;
diff --git a/internal/db/migrations/0084_download_lifecycle.up.sql b/internal/db/migrations/0084_download_lifecycle.up.sql
new file mode 100644
index 00000000..067bf26f
--- /dev/null
+++ b/internal/db/migrations/0084_download_lifecycle.up.sql
@@ -0,0 +1,90 @@
+-- Download lifecycle (M502 #5523), ported from Fabled Librarian's System 104:
+-- a download that stalls or fails is given up and its album searched again;
+-- an imported download seeds, then is removed.
+--
+-- download_interventions what was seen wrong with a download or a client,
+-- what Minstrel did about it, and whether it
+-- cleared. One open row per (client, download,
+-- condition), updated in place.
+-- grabs (columns) a grab also ends by being given up (stalled,
+-- failed), set aside by a person, or leaving the
+-- client; an imported one records its seeding.
+-- release_blocks a release given up on is held back for a while,
+-- not for ever: expires_at.
+-- acquisition_settings how long to seed, and how long a download may sit
+-- with nobody sharing it.
+CREATE TABLE download_interventions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ -- The acquisition_services code of the download client.
+ client text NOT NULL,
+ -- The torrent, or '' for a condition of the client itself.
+ info_hash text NOT NULL DEFAULT '',
+ grab_id uuid REFERENCES grabs (id) ON DELETE SET NULL,
+ name text NOT NULL DEFAULT '',
+ -- no_peers nobody is sharing an unfinished download.
+ -- no_metadata still fetching the torrent's file list.
+ -- client_error the client gave up on it, in its own words.
+ -- client_unreachable the client does not answer, or its engine is down.
+ -- gone the client no longer lists a running download.
+ -- not_removed given up on, and the client would not remove it.
+ trigger text NOT NULL CONSTRAINT download_interventions_trigger_check CHECK (trigger IN (
+ 'no_peers', 'no_metadata', 'client_error', 'client_unreachable', 'gone', 'not_removed')),
+ -- escalated seen and shown; nothing done yet.
+ -- retried asked the trackers again and re-read the files.
+ -- given_up the download was removed and its album searched again.
+ -- removed a download left behind was removed.
+ action text NOT NULL DEFAULT 'escalated'
+ CONSTRAINT download_interventions_action_check CHECK (action IN ('escalated', 'retried', 'given_up', 'removed')),
+ outcome text NOT NULL DEFAULT 'open'
+ CONSTRAINT download_interventions_outcome_check CHECK (outcome IN ('open', 'resolved')),
+ detail text NOT NULL DEFAULT '',
+ -- How many passes have seen it, and how many times Minstrel acted on it.
+ -- The second is the retry ladder's rung, kept here so a restart does not
+ -- hand a stuck download fresh retries.
+ observations integer NOT NULL DEFAULT 1,
+ remediations integer NOT NULL DEFAULT 0,
+ first_seen timestamptz NOT NULL DEFAULT now(),
+ last_seen timestamptz NOT NULL DEFAULT now(),
+ settled_at timestamptz,
+ CONSTRAINT download_interventions_settled_together CHECK ((outcome = 'open') = (settled_at IS NULL))
+);
+
+-- One open row per condition: a pass that sees it again lands on the same row.
+CREATE UNIQUE INDEX download_interventions_one_open
+ ON download_interventions (client, info_hash, trigger) WHERE outcome = 'open';
+CREATE INDEX download_interventions_grab_idx ON download_interventions (grab_id) WHERE grab_id IS NOT NULL;
+
+ALTER TABLE grabs DROP CONSTRAINT grabs_end_reason_check;
+ALTER TABLE grabs ADD CONSTRAINT grabs_end_reason_check
+ CHECK (end_reason IN ('', 'superseded', 'imported', 'refused', 'held', 'stalled', 'failed', 'abandoned', 'gone'));
+
+-- An imported download goes on sharing (the files were copied) until its
+-- ratio reaches the setting or its time is up; seed_released_at is when it
+-- was removed, or found gone.
+ALTER TABLE grabs
+ ADD COLUMN seeding boolean NOT NULL DEFAULT false,
+ ADD COLUMN ratio real NOT NULL DEFAULT 0,
+ ADD COLUMN seed_released_at timestamptz;
+
+CREATE INDEX grabs_seeding_idx ON grabs (imported_at) WHERE seeding AND seed_released_at IS NULL;
+
+-- stalled: given up on because it would not finish; held back until
+-- expires_at. NULL is for good.
+ALTER TABLE release_blocks DROP CONSTRAINT release_blocks_reason_check;
+ALTER TABLE release_blocks ADD CONSTRAINT release_blocks_reason_check
+ CHECK (reason IN ('operator', 'files', 'import', 'stalled'));
+ALTER TABLE release_blocks ADD COLUMN expires_at timestamptz;
+
+-- FL's rulings: seed to ratio 2.0 or for 14 days at most; a download nobody
+-- has shared for 6 hours is retried, then given up.
+-- seed_ratio 0 the download is removed once imported.
+-- seed_days 0 no time limit: only the ratio ends it.
+-- stall_hours 0 never give up on a download for want of peers (a failed
+-- one is still given up).
+ALTER TABLE acquisition_settings
+ ADD COLUMN seed_ratio real NOT NULL DEFAULT 2.0
+ CONSTRAINT acquisition_settings_seed_ratio_check CHECK (seed_ratio BETWEEN 0 AND 100),
+ ADD COLUMN seed_days integer NOT NULL DEFAULT 14
+ CONSTRAINT acquisition_settings_seed_days_check CHECK (seed_days BETWEEN 0 AND 365),
+ ADD COLUMN stall_hours integer NOT NULL DEFAULT 6
+ CONSTRAINT acquisition_settings_stall_hours_check CHECK (stall_hours BETWEEN 0 AND 720);
diff --git a/internal/db/queries/imports.sql b/internal/db/queries/imports.sql
index 28bf0ef0..ffc67643 100644
--- a/internal/db/queries/imports.sql
+++ b/internal/db/queries/imports.sql
@@ -51,6 +51,9 @@ UPDATE grabs
import_updated_at = now(),
import_forced = false,
imported_at = CASE WHEN sqlc.arg(import_state)::text = 'imported' THEN now() ELSE imported_at END,
+ -- The files were copied, so an imported download goes on sharing
+ -- until the lifecycle removes it (#5523).
+ seeding = sqlc.arg(import_state)::text = 'imported',
imported_files = sqlc.arg(imported_files),
destination = sqlc.arg(destination),
ended_at = CASE WHEN sqlc.arg(end_reason)::text <> '' THEN now() ELSE ended_at END,
@@ -110,21 +113,30 @@ SELECT DISTINCT COALESCE(t.disc_number, 1)::int AS disc, t.track_number::int AS
-- name: ListGrabs :many
-- The Wanted view's Activity tab: downloads waiting on a person first, then
--- the rest still running, then the ended ones newest first.
+-- the rest still running, then the ended ones newest first. Each carries
+-- what is wrong with it now, if anything (#5523).
SELECT g.id, g.want_id, g.name, g.indexer, g.client, g.quality, g.origin, g.forced,
g.grabbed_at, g.ended_at, g.end_reason, g.end_detail, g.progress, g.import_state,
g.import_reason, g.import_detail, g.import_updated_at, g.imported_at, g.imported_files,
- g.destination, w.title AS want_title, w.artist_name AS want_artist
+ g.destination, g.seeding, g.ratio, g.seed_released_at,
+ w.title AS want_title, w.artist_name AS want_artist,
+ di.trigger AS condition, di.detail AS condition_detail, di.remediations AS condition_retries,
+ di.first_seen AS condition_since
FROM grabs g
LEFT JOIN wants w ON w.id = g.want_id
+ LEFT JOIN download_interventions di
+ ON di.id = (SELECT x.id FROM download_interventions x
+ WHERE x.grab_id = g.id AND x.outcome = 'open'
+ ORDER BY x.last_seen DESC LIMIT 1)
ORDER BY (g.ended_at IS NULL AND g.import_state IN ('review', 'failed')) DESC,
g.ended_at IS NULL DESC, g.grabbed_at DESC, g.id
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: ListReleaseBlocksPage :many
-SELECT b.info_hash, b.name, b.reason, b.detail, b.blocked_at, w.title AS want_title,
+SELECT b.info_hash, b.name, b.reason, b.detail, b.blocked_at, b.expires_at, w.title AS want_title,
w.artist_name AS want_artist
FROM release_blocks b
LEFT JOIN wants w ON w.id = b.want_id
+ WHERE b.expires_at IS NULL OR b.expires_at > now()
ORDER BY b.blocked_at DESC, b.info_hash
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
diff --git a/internal/db/queries/lifecycle.sql b/internal/db/queries/lifecycle.sql
new file mode 100644
index 00000000..ca634f2a
--- /dev/null
+++ b/internal/db/queries/lifecycle.sql
@@ -0,0 +1,96 @@
+-- The download lifecycle (M502 #5523): the intervention ledger, giving a
+-- download up, and seeding after import.
+
+-- name: RaiseIntervention :one
+-- A condition seen, or seen again: a second pass lands on the open row, so
+-- a download stuck for a week is one row that says how long. The name only
+-- improves, and the detail is the latest word.
+INSERT INTO download_interventions (client, info_hash, grab_id, name, trigger, detail)
+VALUES (sqlc.arg(client), sqlc.arg(info_hash), sqlc.narg(grab_id), sqlc.arg(name),
+ sqlc.arg(trigger), sqlc.arg(detail))
+ON CONFLICT (client, info_hash, trigger) WHERE outcome = 'open' DO UPDATE SET
+ observations = download_interventions.observations + 1,
+ last_seen = now(),
+ detail = EXCLUDED.detail,
+ grab_id = COALESCE(EXCLUDED.grab_id, download_interventions.grab_id),
+ name = CASE WHEN EXCLUDED.name <> '' THEN EXCLUDED.name ELSE download_interventions.name END
+RETURNING *;
+
+-- name: SettleIntervention :execrows
+-- The condition cleared.
+UPDATE download_interventions
+ SET outcome = 'resolved', settled_at = now(), last_seen = now()
+ WHERE client = sqlc.arg(client) AND info_hash = sqlc.arg(info_hash)
+ AND trigger = sqlc.arg(trigger) AND outcome = 'open';
+
+-- name: SettleInterventionsFor :execrows
+-- Everything open about one download is over: it is gone from the client.
+UPDATE download_interventions
+ SET outcome = 'resolved', settled_at = now(), last_seen = now()
+ WHERE client = sqlc.arg(client) AND info_hash = sqlc.arg(info_hash) AND outcome = 'open';
+
+-- name: SettleEndedGrabs :execrows
+-- What was wrong with a download whose grab has ended (imported, refused,
+-- given up) is over, except a download left in the client to remove.
+UPDATE download_interventions di
+ SET outcome = 'resolved', settled_at = now(), last_seen = now()
+ FROM grabs g
+ WHERE g.id = di.grab_id AND g.ended_at IS NOT NULL
+ AND di.outcome = 'open' AND di.trigger <> 'not_removed';
+
+-- name: CountRemediation :one
+-- Minstrel acted on an open condition. A retry climbs the ladder; giving up
+-- or removing ends the condition, since what it was about is gone. Written
+-- before the act, so a retry that happened is never uncounted.
+UPDATE download_interventions
+ SET remediations = remediations + 1,
+ action = sqlc.arg(action),
+ last_seen = now(),
+ outcome = CASE WHEN sqlc.arg(action)::text IN ('given_up', 'removed') THEN 'resolved' ELSE outcome END,
+ settled_at = CASE WHEN sqlc.arg(action)::text IN ('given_up', 'removed') THEN now() ELSE settled_at END
+ WHERE client = sqlc.arg(client) AND info_hash = sqlc.arg(info_hash)
+ AND trigger = sqlc.arg(trigger) AND outcome = 'open'
+RETURNING remediations;
+
+-- name: ListLeftovers :many
+-- Downloads given up on that the client would not remove; each pass asks
+-- again.
+SELECT * FROM download_interventions
+ WHERE trigger = 'not_removed' AND outcome = 'open' AND info_hash <> ''
+ ORDER BY first_seen;
+
+-- name: ListClientsDown :many
+-- Download clients not answering now, for the Activity tab.
+SELECT client, detail, first_seen FROM download_interventions
+ WHERE trigger = 'client_unreachable' AND outcome = 'open'
+ ORDER BY client;
+
+-- name: LiveGrabOnHash :one
+SELECT EXISTS (SELECT 1 FROM grabs WHERE info_hash = sqlc.arg(info_hash) AND ended_at IS NULL)::bool;
+
+-- name: GiveUpGrab :one
+-- Ends a running grab, given up on. Only from the states named: the update
+-- is the lock against an import that claimed the download meanwhile.
+UPDATE grabs
+ SET ended_at = now(), end_reason = sqlc.arg(end_reason), end_detail = sqlc.arg(end_detail),
+ import_updated_at = now()
+ WHERE id = sqlc.arg(id) AND ended_at IS NULL AND import_state = ANY(sqlc.arg(states)::text[])
+RETURNING *;
+
+-- name: ListSeedingGrabs :many
+-- Imported downloads still sharing, oldest import first.
+SELECT id, client, info_hash, name, imported_at FROM grabs
+ WHERE seeding AND seed_released_at IS NULL
+ ORDER BY imported_at, id;
+
+-- name: SetGrabRatio :exec
+UPDATE grabs SET ratio = sqlc.arg(ratio) WHERE id = sqlc.arg(id);
+
+-- name: ReleaseSeed :exec
+-- The seeding download was removed, or found gone.
+UPDATE grabs SET seed_released_at = now() WHERE id = $1 AND seed_released_at IS NULL;
+
+-- name: DeleteExpiredBlocks :execrows
+-- A release given up on is held back for a while; after that the search may
+-- take it again.
+DELETE FROM release_blocks WHERE expires_at IS NOT NULL AND expires_at <= now();
diff --git a/internal/db/queries/search.sql b/internal/db/queries/search.sql
index 9dc88be8..30677414 100644
--- a/internal/db/queries/search.sql
+++ b/internal/db/queries/search.sql
@@ -9,6 +9,9 @@ UPDATE acquisition_settings
search_interval_hours = sqlc.arg(search_interval_hours),
allowed_qualities = sqlc.arg(allowed_qualities),
import_root = sqlc.arg(import_root),
+ seed_ratio = sqlc.arg(seed_ratio),
+ seed_days = sqlc.arg(seed_days),
+ stall_hours = sqlc.arg(stall_hours),
updated_at = now()
WHERE id = true
RETURNING *;
@@ -109,14 +112,23 @@ UPDATE grabs
WHERE id = sqlc.arg(id) AND ended_at IS NULL;
-- name: ListReleaseBlocks :many
-SELECT * FROM release_blocks ORDER BY blocked_at DESC;
+-- Every release set aside and still held back: a stalled one lifts by itself
+-- at expires_at (#5523).
+SELECT * FROM release_blocks
+ WHERE expires_at IS NULL OR expires_at > now()
+ ORDER BY blocked_at DESC;
-- name: BlockRelease :exec
-INSERT INTO release_blocks (info_hash, name, reason, detail, want_id, blocked_by)
+-- A release set aside. expires_at NULL is for good. A block for good is never
+-- shortened by a timed one: only a timed block, or another for good, is
+-- replaced.
+INSERT INTO release_blocks (info_hash, name, reason, detail, want_id, blocked_by, expires_at)
VALUES (sqlc.arg(info_hash), sqlc.arg(name), sqlc.arg(reason), sqlc.arg(detail),
- sqlc.narg(want_id), sqlc.narg(blocked_by))
+ sqlc.narg(want_id), sqlc.narg(blocked_by), sqlc.narg(expires_at))
ON CONFLICT (info_hash) DO UPDATE
- SET reason = EXCLUDED.reason, detail = EXCLUDED.detail, blocked_at = now();
+ SET reason = EXCLUDED.reason, detail = EXCLUDED.detail, blocked_at = now(),
+ expires_at = EXCLUDED.expires_at, blocked_by = EXCLUDED.blocked_by
+ WHERE release_blocks.expires_at IS NOT NULL OR EXCLUDED.expires_at IS NULL;
-- name: UnblockRelease :execrows
DELETE FROM release_blocks WHERE info_hash = $1;
diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql
index d52d47a6..5da52d33 100644
--- a/internal/db/queries/wanted.sql
+++ b/internal/db/queries/wanted.sql
@@ -194,7 +194,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
gr.id AS grab_id, gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin,
gr.quality AS grab_quality, gr.ended_at AS grab_ended_at, gr.end_reason AS grab_end_reason,
gr.progress AS grab_progress, gr.import_state AS grab_import_state,
- gr.import_reason AS grab_import_reason, gr.imported_at AS grab_imported_at,
+ gr.import_reason AS grab_import_reason, gr.end_detail AS grab_end_detail, gr.imported_at AS grab_imported_at,
gr.imported_files AS grab_imported_files
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
@@ -219,5 +219,6 @@ SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::big
(SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity,
(SELECT COUNT(*) FROM grabs
WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::bigint AS attention,
- (SELECT COUNT(*) FROM release_blocks)::bigint AS blocked
+ (SELECT COUNT(*) FROM release_blocks b
+ WHERE b.expires_at IS NULL OR b.expires_at > now())::bigint AS blocked
FROM wants;
diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go
index 8b138e24..5da0e417 100644
--- a/internal/dbtest/reset.go
+++ b/internal/dbtest/reset.go
@@ -109,6 +109,7 @@ var dataTables = []string{
"mb_release_groups", // M502 #5519
"mb_artists", // M502 #5519
"requests", // M502 #5520 (was lidarr_requests)
+ "download_interventions", // M502 #5523
"release_blocks", // M502 #5521
"grabs", // M502 #5521
"want_candidates", // M502 #5521
@@ -187,6 +188,7 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
if _, err := pool.Exec(ctx, `
UPDATE acquisition_settings
SET auto_grab = DEFAULT, search_interval_hours = DEFAULT, allowed_qualities = DEFAULT, import_root = DEFAULT,
+ seed_ratio = DEFAULT, seed_days = DEFAULT, stall_hours = DEFAULT,
last_pass_at = NULL, last_pass = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset acquisition settings: %v", err)
diff --git a/internal/importer/importer.go b/internal/importer/importer.go
index 5811f03d..1c070fcd 100644
--- a/internal/importer/importer.go
+++ b/internal/importer/importer.go
@@ -13,7 +13,6 @@ import (
"sync"
"time"
- "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
@@ -67,8 +66,8 @@ type Importer struct {
// Roots are the library's folders (the scan paths). New music is filed
// under the one the settings name, or the first.
Roots []string
- // ClientFor builds the download client a grab went to. Defaults to the
- // acquisition_services row of that code.
+ // ClientFor builds the download client a grab went to. Defaults to
+ // acquire.DownloadClientFor.
ClientFor func(ctx context.Context, code string) (downloader.Driver, error)
logger *slog.Logger
wake chan struct{}
@@ -83,19 +82,10 @@ func New(pool *pgxpool.Pool, scanner Scanner, cat Catalogue, printer Printer, ro
}
im := &Importer{pool: pool, Scanner: scanner, Catalogue: cat, Printer: printer, Roots: roots,
logger: logger, wake: make(chan struct{}, 1)}
- im.ClientFor = im.clientFor
- return im
-}
-
-func (im *Importer) clientFor(ctx context.Context, code string) (downloader.Driver, error) {
- row, err := dbq.New(im.pool).GetAcquisitionService(ctx, code)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, fmt.Errorf("the download client %q is no longer set up", code)
- }
- return nil, err
+ im.ClientFor = func(ctx context.Context, code string) (downloader.Driver, error) {
+ return acquire.DownloadClientFor(ctx, pool, code)
}
- return acquire.DownloadClient(row)
+ return im
}
// Wake asks for a pass now: after a grab, or a person's decision.
@@ -201,7 +191,7 @@ func (im *Importer) passClient(ctx context.Context, q *dbq.Queries, code string,
}
t, ok := byHash[indexer.NormalizeHash(g.InfoHash)]
if !ok {
- continue // gone from the client: the download lifecycle's (#5523)
+ continue // gone from the client: the lifecycle settles it (#5523)
}
if !g.WantID.Valid {
wait(g, "Its album is no longer wanted.")
diff --git a/internal/lifecycle/lifecycle.go b/internal/lifecycle/lifecycle.go
new file mode 100644
index 00000000..79ad280a
--- /dev/null
+++ b/internal/lifecycle/lifecycle.go
@@ -0,0 +1,659 @@
+// Package lifecycle looks after a download from the grab to its removal
+// (M502 #5523), ported from Fabled Librarian's internal/health and its seed
+// release (System 104).
+//
+// - A download the client has failed is given up at once.
+// - One nobody has shared, or still fetching its file list, after the
+// stall setting is retried: the trackers asked again and the files
+// re-read, three times a pass apart. Then it is given up.
+// - Giving up removes the download with its files, holds the release back
+// for a week, ends the grab and searches for the album again. There is
+// no limit per album: what stops it churning is the hold on each release.
+// - A running download the client no longer lists, for half an hour, ends
+// and its album is searched again. The release is not held back: nothing
+// was wrong with it.
+// - An imported download seeds (its files were copied) until its share
+// ratio reaches the setting or its days are up, then is removed.
+//
+// Every condition and every act goes in the intervention ledger, one open
+// row per (client, download, condition), so the Activity tab can say what
+// is wrong with a download and what was done.
+package lifecycle
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+ "time"
+
+ "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/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
+)
+
+// Conditions, matching download_interventions_trigger_check.
+const (
+ NoPeers = "no_peers"
+ NoMetadata = "no_metadata"
+ ClientError = "client_error"
+ ClientUnreachable = "client_unreachable"
+ Gone = "gone"
+ NotRemoved = "not_removed"
+)
+
+// Acts, matching download_interventions_action_check.
+const (
+ actRetried = "retried"
+ actGivenUp = "given_up"
+ actRemoved = "removed"
+)
+
+// End reasons a grab is given with, matching grabs_end_reason_check.
+const (
+ EndStalled = "stalled"
+ EndFailed = "failed"
+ EndAbandoned = "abandoned"
+ EndGone = "gone"
+)
+
+const (
+ // interval: three retries a pass apart is half an hour of asking before
+ // a stalled download is given up.
+ interval = 10 * time.Minute
+ passTimeout = 5 * time.Minute
+ // Retries is how many times one stuck download is nudged first.
+ Retries = 3
+ // StallBlock is how long a release given up on is held back.
+ StallBlock = 7 * 24 * time.Hour
+ // goneAfter: a client restarting can answer without a download for a
+ // while; half an hour missing is gone.
+ goneAfter = 30 * time.Minute
+)
+
+// running are the import states of a grab still downloading: the lifecycle
+// acts only on these, so an import under way is never pulled from under.
+var running = []string{"", "downloading", "waiting"}
+
+// Settings are the limits, from acquisition_settings.
+type Settings struct {
+ // SeedRatio is the share ratio an imported download seeds to; 0 removes
+ // it once imported.
+ SeedRatio float64
+ // SeedTime is the longest it seeds; 0 is no limit.
+ SeedTime time.Duration
+ // Patience is how long a download may sit with nobody sharing it before
+ // it is retried, then given up; 0 never gives up for that.
+ Patience time.Duration
+}
+
+// Defaults are FL's rulings.
+var Defaults = Settings{SeedRatio: 2, SeedTime: 14 * 24 * time.Hour, Patience: 6 * time.Hour}
+
+// Service runs the lifecycle passes.
+type Service struct {
+ pool *pgxpool.Pool
+ // ClientFor builds a download client by its code. Defaults to
+ // acquire.DownloadClientFor.
+ ClientFor func(ctx context.Context, code string) (downloader.Driver, error)
+ // Now is the clock; nil is time.Now.
+ Now func() time.Time
+ logger *slog.Logger
+ mu sync.Mutex // one pass at a time
+}
+
+// New returns a Service.
+func New(pool *pgxpool.Pool, logger *slog.Logger) *Service {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &Service{pool: pool, logger: logger,
+ ClientFor: func(ctx context.Context, code string) (downloader.Driver, error) {
+ return acquire.DownloadClientFor(ctx, pool, code)
+ }}
+}
+
+func (s *Service) now() time.Time {
+ if s.Now != nil {
+ return s.Now()
+ }
+ return time.Now()
+}
+
+// Run passes a minute after start, then every ten minutes, until ctx ends.
+// Each pass is bounded, so a client that never answers costs one pass.
+func (s *Service) Run(ctx context.Context) {
+ first := time.NewTimer(time.Minute)
+ defer first.Stop()
+ select {
+ case <-ctx.Done():
+ return
+ case <-first.C:
+ }
+ t := time.NewTicker(interval)
+ defer t.Stop()
+ for {
+ rep := s.Pass(ctx)
+ if rep.Retried+rep.GivenUp+rep.Gone+rep.Released+rep.Removed > 0 || rep.Error != "" {
+ s.logger.Info("lifecycle: pass", "retried", rep.Retried, "given_up", rep.GivenUp, "gone", rep.Gone,
+ "released", rep.Released, "removed", rep.Removed, "raised", rep.Raised, "settled", rep.Settled,
+ "error", rep.Error)
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ }
+ }
+}
+
+// Report is one pass.
+type Report struct {
+ // Raised and Settled count ledger writes; the rest count acts.
+ Raised, Settled int
+ Retried, GivenUp, Gone, Released, Removed int
+ Error string
+}
+
+// Pass looks at every running download, every seeding one and every one
+// left behind, once.
+func (s *Service) Pass(ctx context.Context) Report {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ ctx, cancel := context.WithTimeout(ctx, passTimeout)
+ defer cancel()
+ var rep Report
+ q := dbq.New(s.pool)
+ set := s.settings(ctx, q)
+
+ if _, err := q.DeleteExpiredBlocks(ctx); err != nil {
+ s.logger.Warn("lifecycle: lift expired holds", "err", err)
+ }
+ if n, err := q.SettleEndedGrabs(ctx); err == nil {
+ rep.Settled += int(n)
+ }
+ live, err := q.ListLiveGrabs(ctx)
+ if err != nil {
+ rep.Error = err.Error()
+ return rep
+ }
+ seeds, err := q.ListSeedingGrabs(ctx)
+ if err != nil {
+ rep.Error = err.Error()
+ return rep
+ }
+ left, err := q.ListLeftovers(ctx)
+ if err != nil {
+ rep.Error = err.Error()
+ return rep
+ }
+
+ work := map[string]*clientWork{}
+ var order []string
+ of := func(code string) *clientWork {
+ if w, ok := work[code]; ok {
+ return w
+ }
+ order = append(order, code)
+ work[code] = &clientWork{}
+ return work[code]
+ }
+ for _, g := range live {
+ if g.InfoHash != "" {
+ of(g.Client).live = append(of(g.Client).live, g)
+ }
+ }
+ for _, g := range seeds {
+ if g.InfoHash != "" {
+ of(g.Client).seeds = append(of(g.Client).seeds, g)
+ }
+ }
+ for _, l := range left {
+ of(l.Client).left = append(of(l.Client).left, l)
+ }
+ for _, code := range order {
+ if ctx.Err() != nil {
+ break
+ }
+ s.passClient(ctx, q, code, work[code], set, &rep)
+ }
+ return rep
+}
+
+type clientWork struct {
+ live []dbq.ListLiveGrabsRow
+ seeds []dbq.ListSeedingGrabsRow
+ left []dbq.DownloadIntervention
+}
+
+func (s *Service) settings(ctx context.Context, q *dbq.Queries) Settings {
+ row, err := q.GetAcquisitionSettings(ctx)
+ if err != nil {
+ // A blip keeps the defaults rather than stopping the pass.
+ return Defaults
+ }
+ return Settings{
+ SeedRatio: float64(row.SeedRatio),
+ SeedTime: time.Duration(row.SeedDays) * 24 * time.Hour,
+ Patience: time.Duration(row.StallHours) * time.Hour,
+ }
+}
+
+func (s *Service) passClient(ctx context.Context, q *dbq.Queries, code string, w *clientWork, set Settings, rep *Report) {
+ d, err := s.ClientFor(ctx, code)
+ if err != nil {
+ s.raise(ctx, q, code, "", pgtype.UUID{}, "", ClientUnreachable, firstLine(err.Error()), rep)
+ return
+ }
+ var hashes []string
+ for _, g := range w.live {
+ hashes = append(hashes, g.InfoHash)
+ }
+ for _, g := range w.seeds {
+ hashes = append(hashes, g.InfoHash)
+ }
+ for _, l := range w.left {
+ hashes = append(hashes, l.InfoHash)
+ }
+ ts, err := d.Torrents(ctx, hashes...)
+ if err != nil {
+ // Nothing about any one download is observed through a client that
+ // cannot answer.
+ s.raise(ctx, q, code, "", pgtype.UUID{}, "", ClientUnreachable, firstLine(err.Error()), rep)
+ return
+ }
+ for _, t := range ts {
+ if t.EngineDown != "" {
+ s.raise(ctx, q, code, "", pgtype.UUID{}, "", ClientUnreachable, t.EngineDown, rep)
+ return
+ }
+ }
+ s.settle(ctx, q, code, "", ClientUnreachable, rep)
+
+ byHash := make(map[string]downloader.Torrent, len(ts))
+ for _, t := range ts {
+ byHash[indexer.NormalizeHash(t.Hash)] = t
+ }
+ // ABSENCE IS ONLY A MEASUREMENT FROM A CLIENT THAT LISTS SOMETHING. An
+ // engine switched off, or a store that would not read, answers with an
+ // empty list; then nothing is concluded from what is missing.
+ listed := len(ts) > 0
+ if !listed {
+ all, err := d.Torrents(ctx)
+ listed = err == nil && len(all) > 0
+ }
+
+ for _, l := range w.left {
+ t, ok := byHash[indexer.NormalizeHash(l.InfoHash)]
+ s.removeAgain(ctx, q, d, l, t, ok, listed, rep)
+ }
+ for _, g := range w.live {
+ t, ok := byHash[indexer.NormalizeHash(g.InfoHash)]
+ s.judge(ctx, q, d, g, t, ok, listed, set, rep)
+ }
+ for _, g := range w.seeds {
+ t, ok := byHash[indexer.NormalizeHash(g.InfoHash)]
+ s.seed(ctx, q, d, g, t, ok, set, rep)
+ }
+}
+
+// judge decides what, if anything, is wrong with one running download, and
+// answers each condition either way: raised when true, settled when not.
+func (s *Service) judge(ctx context.Context, q *dbq.Queries, d downloader.Driver, g dbq.ListLiveGrabsRow,
+ t downloader.Torrent, ok, listed bool, set Settings, rep *Report) {
+ if !isRunning(g.ImportState) {
+ return // the importer's, or a person's
+ }
+ if !ok {
+ if !listed {
+ return
+ }
+ row, raised := s.raise(ctx, q, g.Client, g.InfoHash, g.ID, g.Name, Gone,
+ "The download client no longer lists it.", rep)
+ if raised && s.now().Sub(row.FirstSeen.Time) >= goneAfter {
+ s.endGone(ctx, q, g, rep)
+ }
+ return
+ }
+ s.settle(ctx, q, g.Client, g.InfoHash, Gone, rep)
+
+ if t.Done() {
+ // Finished: whatever was wrong while it downloaded is over, and the
+ // import is the importer's.
+ for _, c := range []string{ClientError, NoMetadata, NoPeers} {
+ s.settle(ctx, q, g.Client, g.InfoHash, c, rep)
+ }
+ return
+ }
+
+ // The client's own verdict, given up on at once: it has already made
+ // the decision a retry would ask about (FL #4749).
+ if t.Failed() {
+ why := t.FailReason
+ if why == "" {
+ why = fmt.Sprintf("the download client reports it %q", t.State)
+ }
+ if _, raised := s.raise(ctx, q, g.Client, g.InfoHash, g.ID, g.Name, ClientError, why, rep); raised {
+ s.giveUp(ctx, q, d, g, ClientError, EndFailed, why, rep)
+ }
+ return
+ }
+ s.settle(ctx, q, g.Client, g.InfoHash, ClientError, rep)
+
+ // Below here the stall setting applies, measured from the grab. With it
+ // off nothing is raised and what was raised clears.
+ age := s.now().Sub(g.GrabbedAt.Time)
+ patient := set.Patience > 0 && age > set.Patience
+
+ if patient && t.Fetching() {
+ row, raised := s.raise(ctx, q, g.Client, g.InfoHash, g.ID, g.Name, NoMetadata,
+ fmt.Sprintf("Still fetching its file list after %s.", span(age)), rep)
+ if raised {
+ s.ladder(ctx, q, d, g, NoMetadata, row.Remediations, age, rep)
+ }
+ } else {
+ s.settle(ctx, q, g.Client, g.InfoHash, NoMetadata, rep)
+ }
+ // Not while fetching: a torrent with no file list has no swarm to be
+ // alone in.
+ if patient && !t.Fetching() && t.Alone() {
+ row, raised := s.raise(ctx, q, g.Client, g.InfoHash, g.ID, g.Name, NoPeers,
+ fmt.Sprintf("Nobody has shared it in %s; %.0f%% downloaded.", span(age), t.Progress*100), rep)
+ if raised {
+ s.ladder(ctx, q, d, g, NoPeers, row.Remediations, age, rep)
+ }
+ } else {
+ s.settle(ctx, q, g.Client, g.InfoHash, NoPeers, rep)
+ }
+}
+
+// ladder retries a stuck download until the retries are spent, then gives
+// it up. The rung is the ledger row's, so a restart hands out no more.
+func (s *Service) ladder(ctx context.Context, q *dbq.Queries, d downloader.Driver, g dbq.ListLiveGrabsRow,
+ trigger string, done int32, age time.Duration, rep *Report) {
+ if int(done) < Retries {
+ // Counted before it happens: a retry that happened and was not
+ // counted is a bound that never binds.
+ n, err := q.CountRemediation(ctx, dbq.CountRemediationParams{
+ Client: g.Client, InfoHash: g.InfoHash, Trigger: trigger, Action: actRetried})
+ if err != nil {
+ return
+ }
+ // Exchange schedules its own announces and has no recheck: for it a
+ // retry is the time between passes, and the ladder still climbs.
+ if err := d.Reannounce(ctx, g.InfoHash); err != nil && !errors.Is(err, downloader.ErrUnsupported) {
+ s.logger.Warn("lifecycle: reannounce", "grab", g.Name, "err", err)
+ }
+ if err := d.Recheck(ctx, g.InfoHash); err != nil && !errors.Is(err, downloader.ErrUnsupported) {
+ s.logger.Warn("lifecycle: recheck", "grab", g.Name, "err", err)
+ }
+ s.logger.Info("lifecycle: retried a stalled download", "grab", g.Name, "trigger", trigger, "attempt", n)
+ rep.Retried++
+ return
+ }
+ why := fmt.Sprintf("taken %s ago and never finished: %s", span(age), reasonFor(trigger))
+ s.giveUp(ctx, q, d, g, trigger, EndStalled, why, rep)
+}
+
+// giveUp ends the grab, holds the release back for StallBlock, removes the
+// download with its files and searches for the album again.
+func (s *Service) giveUp(ctx context.Context, q *dbq.Queries, d downloader.Driver, g dbq.ListLiveGrabsRow,
+ trigger, end, why string, rep *Report) {
+ until := s.now().Add(StallBlock)
+ detail := capital(why) + fmt.Sprintf(". Held back until %s; the album is searched for again.", until.Format("2 Jan"))
+ // The update is the lock: an import that claimed the download meanwhile
+ // keeps it.
+ if _, err := q.GiveUpGrab(ctx, dbq.GiveUpGrabParams{ID: g.ID, EndReason: end, EndDetail: detail, States: running}); err != nil {
+ return
+ }
+ // Held back before it is removed: if the pass dies between the two, the
+ // release is still not taken again.
+ if err := acquire.NewMemory(s.pool).Block(ctx, dbq.BlockReleaseParams{
+ InfoHash: g.InfoHash, Name: g.Name, Reason: acquire.BlockStalled, Detail: capital(why) + ".",
+ WantID: g.WantID, ExpiresAt: pgtype.Timestamptz{Time: until, Valid: true},
+ }); err != nil {
+ s.logger.Warn("lifecycle: hold the release back", "grab", g.Name, "err", err)
+ }
+ removed := s.remove(ctx, q, d, g.Client, g.InfoHash, g.ID, g.Name, rep)
+ if g.WantID.Valid {
+ if err := q.SearchWantAgain(ctx, g.WantID); err != nil {
+ s.logger.Warn("lifecycle: search again", "grab", g.Name, "err", err)
+ }
+ }
+ if _, err := q.CountRemediation(ctx, dbq.CountRemediationParams{
+ Client: g.Client, InfoHash: g.InfoHash, Trigger: trigger, Action: actGivenUp}); err != nil {
+ s.logger.Warn("lifecycle: stamp the ledger", "grab", g.Name, "err", err)
+ }
+ if removed {
+ if n, err := q.SettleInterventionsFor(ctx, dbq.SettleInterventionsForParams{
+ Client: g.Client, InfoHash: g.InfoHash}); err == nil {
+ rep.Settled += int(n)
+ }
+ }
+ s.logger.Info("lifecycle: gave up on a download", "grab", g.Name, "trigger", trigger, "why", why)
+ rep.GivenUp++
+}
+
+// endGone ends a grab whose download left the client, and searches again.
+// The release is not held back: nothing was found wrong with it.
+func (s *Service) endGone(ctx context.Context, q *dbq.Queries, g dbq.ListLiveGrabsRow, rep *Report) {
+ detail := "The download client no longer lists it: removed outside Minstrel. The album is searched for again."
+ if _, err := q.GiveUpGrab(ctx, dbq.GiveUpGrabParams{ID: g.ID, EndReason: EndGone, EndDetail: detail, States: running}); err != nil {
+ return
+ }
+ if g.WantID.Valid {
+ if err := q.SearchWantAgain(ctx, g.WantID); err != nil {
+ s.logger.Warn("lifecycle: search again", "grab", g.Name, "err", err)
+ }
+ }
+ if _, err := q.CountRemediation(ctx, dbq.CountRemediationParams{
+ Client: g.Client, InfoHash: g.InfoHash, Trigger: Gone, Action: actGivenUp}); err != nil {
+ s.logger.Warn("lifecycle: stamp the ledger", "grab", g.Name, "err", err)
+ }
+ if n, err := q.SettleInterventionsFor(ctx, dbq.SettleInterventionsForParams{
+ Client: g.Client, InfoHash: g.InfoHash}); err == nil {
+ rep.Settled += int(n)
+ }
+ s.logger.Info("lifecycle: a download left its client", "grab", g.Name)
+ rep.Gone++
+}
+
+// seed removes an imported download once it has shared enough.
+//
+// Absence is not a verdict: a restarting client answers with a partial list.
+// A seeding download missing from it is left until its time is up, then
+// recorded as released; there is nothing to delete.
+func (s *Service) seed(ctx context.Context, q *dbq.Queries, d downloader.Driver, g dbq.ListSeedingGrabsRow,
+ t downloader.Torrent, ok bool, set Settings, rep *Report) {
+ overdue := set.SeedTime > 0 && g.ImportedAt.Valid && s.now().Sub(g.ImportedAt.Time) >= set.SeedTime
+ if !ok {
+ if overdue {
+ s.release(ctx, q, g.ID, rep)
+ }
+ return
+ }
+ if err := q.SetGrabRatio(ctx, dbq.SetGrabRatioParams{ID: g.ID, Ratio: float32(t.Ratio)}); err != nil {
+ s.logger.Warn("lifecycle: record the ratio", "grab", g.Name, "err", err)
+ }
+ if t.Ratio < set.SeedRatio && !overdue {
+ return
+ }
+ s.remove(ctx, q, d, g.Client, g.InfoHash, g.ID, g.Name, rep)
+ s.logger.Info("lifecycle: seeded enough, removed the download", "grab", g.Name, "ratio", t.Ratio, "overdue", overdue)
+ s.release(ctx, q, g.ID, rep)
+}
+
+func (s *Service) release(ctx context.Context, q *dbq.Queries, id pgtype.UUID, rep *Report) {
+ if err := q.ReleaseSeed(ctx, id); err != nil {
+ s.logger.Warn("lifecycle: record the seeding ended", "err", err)
+ return
+ }
+ rep.Released++
+}
+
+// remove deletes a download and its files. A client that refuses leaves an
+// open not_removed row, which every pass tries again.
+func (s *Service) remove(ctx context.Context, q *dbq.Queries, d downloader.Driver, code, hash string,
+ grab pgtype.UUID, name string, rep *Report) bool {
+ if err := d.Delete(ctx, true, hash); err != nil {
+ s.logger.Warn("lifecycle: remove the download", "grab", name, "err", err)
+ s.raise(ctx, q, code, hash, grab, name, NotRemoved, notRemoved(err), rep)
+ return false
+ }
+ return true
+}
+
+// removeAgain asks the client once more to remove a download left behind,
+// unless it has been taken again since.
+func (s *Service) removeAgain(ctx context.Context, q *dbq.Queries, d downloader.Driver, l dbq.DownloadIntervention,
+ t downloader.Torrent, ok, listed bool, rep *Report) {
+ if !ok {
+ if listed {
+ s.settle(ctx, q, l.Client, l.InfoHash, NotRemoved, rep)
+ }
+ return
+ }
+ live, err := q.LiveGrabOnHash(ctx, l.InfoHash)
+ if err != nil {
+ return
+ }
+ if live {
+ s.settle(ctx, q, l.Client, l.InfoHash, NotRemoved, rep)
+ return
+ }
+ if err := d.Delete(ctx, true, l.InfoHash); err != nil {
+ s.raise(ctx, q, l.Client, l.InfoHash, l.GrabID, t.Name, NotRemoved, notRemoved(err), rep)
+ return
+ }
+ if _, err := q.CountRemediation(ctx, dbq.CountRemediationParams{
+ Client: l.Client, InfoHash: l.InfoHash, Trigger: NotRemoved, Action: actRemoved}); err != nil {
+ s.logger.Warn("lifecycle: stamp the ledger", "hash", l.InfoHash, "err", err)
+ }
+ s.logger.Info("lifecycle: removed a download left behind", "name", l.Name)
+ rep.Removed++
+}
+
+// raise records a condition or agrees with the open row that says it. The
+// bool is false when the write failed: then nothing is acted on, since
+// without the row's count there is no bound.
+func (s *Service) raise(ctx context.Context, q *dbq.Queries, code, hash string, grab pgtype.UUID, name, trigger,
+ detail string, rep *Report) (dbq.DownloadIntervention, bool) {
+ row, err := q.RaiseIntervention(ctx, dbq.RaiseInterventionParams{
+ Client: code, InfoHash: hash, GrabID: grab, Name: name, Trigger: trigger, Detail: detail})
+ if err != nil {
+ s.logger.Warn("lifecycle: record", "client", code, "hash", hash, "trigger", trigger, "err", err)
+ return row, false
+ }
+ rep.Raised++
+ return row, true
+}
+
+func (s *Service) settle(ctx context.Context, q *dbq.Queries, code, hash, trigger string, rep *Report) {
+ n, err := q.SettleIntervention(ctx, dbq.SettleInterventionParams{Client: code, InfoHash: hash, Trigger: trigger})
+ if err != nil {
+ s.logger.Warn("lifecycle: settle", "client", code, "hash", hash, "trigger", trigger, "err", err)
+ return
+ }
+ rep.Settled += int(n)
+}
+
+// ErrNotAbandonable: the download is not running; an import, or a person's
+// decision on one, has it.
+var ErrNotAbandonable = errors.New("lifecycle: that download is not running")
+
+// Abandon is a person giving up on a running download: the release is set
+// aside for good (their word about it), the download removed with its files,
+// and the album searched for again.
+func (s *Service) Abandon(ctx context.Context, grabID, by pgtype.UUID) (dbq.Grab, error) {
+ q := dbq.New(s.pool)
+ g, err := q.GetGrab(ctx, grabID)
+ if err != nil {
+ return dbq.Grab{}, err
+ }
+ if g.EndedAt.Valid || !isRunning(g.ImportState) {
+ return dbq.Grab{}, ErrNotAbandonable
+ }
+ const why = "Given up by you before it finished. The album is searched for again."
+ ended, err := q.GiveUpGrab(ctx, dbq.GiveUpGrabParams{ID: g.ID, EndReason: EndAbandoned, EndDetail: why, States: running})
+ if err != nil {
+ return dbq.Grab{}, ErrNotAbandonable
+ }
+ if g.InfoHash != "" {
+ if err := acquire.NewMemory(s.pool).Block(ctx, dbq.BlockReleaseParams{
+ InfoHash: g.InfoHash, Name: g.Name, Reason: acquire.BlockOperator, Detail: "Given up by you before it finished.",
+ WantID: g.WantID, BlockedBy: by,
+ }); err != nil {
+ s.logger.Warn("lifecycle: set the release aside", "grab", g.Name, "err", err)
+ }
+ var rep Report
+ if d, err := s.ClientFor(ctx, g.Client); err != nil {
+ s.raise(ctx, q, g.Client, g.InfoHash, g.ID, g.Name, NotRemoved, notRemoved(err), &rep)
+ } else if s.remove(ctx, q, d, g.Client, g.InfoHash, g.ID, g.Name, &rep) {
+ _, _ = q.SettleInterventionsFor(ctx, dbq.SettleInterventionsForParams{Client: g.Client, InfoHash: g.InfoHash})
+ }
+ }
+ if g.WantID.Valid {
+ if err := q.SearchWantAgain(ctx, g.WantID); err != nil {
+ s.logger.Warn("lifecycle: search again", "grab", g.Name, "err", err)
+ }
+ }
+ return ended, nil
+}
+
+func isRunning(state string) bool {
+ for _, r := range running {
+ if state == r {
+ return true
+ }
+ }
+ return false
+}
+
+// reasonFor is a condition in a person's words.
+func reasonFor(trigger string) string {
+ switch trigger {
+ case NoPeers:
+ return "nobody was sharing it"
+ case NoMetadata:
+ return "its file list never arrived"
+ case ClientError:
+ return "the download client gave up on it"
+ }
+ return "it stalled"
+}
+
+func notRemoved(err error) string {
+ return "Given up on, but the download client would not remove it: " + firstLine(err.Error()) +
+ ". Minstrel asks again every ten minutes."
+}
+
+// span is a duration as a person says it.
+func span(d time.Duration) string {
+ switch {
+ case d < 2*time.Hour:
+ return fmt.Sprintf("%d minutes", int(d.Minutes()))
+ case d < 48*time.Hour:
+ return fmt.Sprintf("%d hours", int(d.Hours()))
+ }
+ return fmt.Sprintf("%d days", int(d.Hours()/24))
+}
+
+func capital(s string) string {
+ if s == "" {
+ return s
+ }
+ return strings.ToUpper(s[:1]) + s[1:]
+}
+
+func firstLine(s string) string {
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return strings.TrimSpace(s[:i])
+ }
+ return strings.TrimSpace(s)
+}
diff --git a/internal/lifecycle/lifecycle_test.go b/internal/lifecycle/lifecycle_test.go
new file mode 100644
index 00000000..f5cd4c60
--- /dev/null
+++ b/internal/lifecycle/lifecycle_test.go
@@ -0,0 +1,441 @@
+package lifecycle
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "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/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"
+ other = "fedcba9876543210fedcba9876543210fedcba98"
+)
+
+// fakeClient lists the torrents it holds and records what it is asked.
+type fakeClient struct {
+ downloader.Driver
+ held map[string]downloader.Torrent
+ err error
+ deleteErr error
+ deleted []string
+ reannounce int
+ recheck int
+}
+
+func (c *fakeClient) Torrents(_ context.Context, hashes ...string) ([]downloader.Torrent, error) {
+ if c.err != nil {
+ return nil, c.err
+ }
+ var out []downloader.Torrent
+ if len(hashes) == 0 {
+ for _, t := range c.held {
+ out = append(out, t)
+ }
+ return out, nil
+ }
+ for _, h := range hashes {
+ if t, ok := c.held[h]; ok {
+ out = append(out, t)
+ }
+ }
+ return out, nil
+}
+
+func (c *fakeClient) Delete(_ context.Context, _ bool, hashes ...string) error {
+ if c.deleteErr != nil {
+ return c.deleteErr
+ }
+ c.deleted = append(c.deleted, hashes...)
+ for _, h := range hashes {
+ delete(c.held, h)
+ }
+ return nil
+}
+
+func (c *fakeClient) Reannounce(context.Context, ...string) error { c.reannounce++; return nil }
+func (c *fakeClient) Recheck(context.Context, ...string) error { c.recheck++; return nil }
+
+type stack struct {
+ q *dbq.Queries
+ s *Service
+ client *fakeClient
+ want dbq.Want
+ grab dbq.Grab
+ clock time.Time
+}
+
+// newStack wants RAM and has a grab of it running in a fake client holding
+// t under hash.
+func newStack(t *testing.T, tor downloader.Torrent) *stack {
+ t.Helper()
+ ctx := context.Background()
+ pool := newPool(t)
+ q := dbq.New(pool)
+ 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: "/downloads", Origin: acquire.OriginSchedule},
+ pgtype.UUID{}, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := q.MarkWantSearched(ctx, w.ID); err != nil {
+ t.Fatal(err)
+ }
+ tor.Hash = hash
+ st := &stack{q: q, client: &fakeClient{held: map[string]downloader.Torrent{hash: tor}}, want: w, grab: g,
+ clock: time.Now()}
+ st.s = New(pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ st.s.ClientFor = func(context.Context, string) (downloader.Driver, error) { return st.client, nil }
+ st.s.Now = func() time.Time { return st.clock }
+ return st
+}
+
+func (st *stack) grabNow(t *testing.T) dbq.Grab {
+ t.Helper()
+ g, err := st.q.GetGrab(context.Background(), st.grab.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return g
+}
+
+func (st *stack) searchedAgain(t *testing.T) bool {
+ t.Helper()
+ w, err := st.q.GetWantByReleaseGroup(context.Background(), "rg-ram")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return !w.SearchedAt.Valid
+}
+
+func (st *stack) ledger(t *testing.T) []dbq.DownloadIntervention {
+ t.Helper()
+ rows, err := st.s.pool.Query(context.Background(), `SELECT * FROM download_interventions ORDER BY first_seen, id`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := pgx.CollectRows(rows, pgx.RowToStructByPos[dbq.DownloadIntervention])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+func TestLifecycle_AFailedDownloadIsGivenUpAtOnce_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.4, State: "error",
+ FailReason: "No space left on device"})
+ rep := st.s.Pass(context.Background())
+ if rep.GivenUp != 1 {
+ t.Fatalf("pass = %+v", rep)
+ }
+ g := st.grabNow(t)
+ if !g.EndedAt.Valid || g.EndReason != EndFailed {
+ t.Fatalf("grab ended=%v reason=%q", g.EndedAt.Valid, g.EndReason)
+ }
+ if len(st.client.deleted) != 1 || !st.searchedAgain(t) {
+ t.Errorf("deleted %v, searched again %v", st.client.deleted, st.searchedAgain(t))
+ }
+ blocks, err := st.q.ListReleaseBlocks(context.Background())
+ if err != nil || len(blocks) != 1 || blocks[0].Reason != acquire.BlockStalled || !blocks[0].ExpiresAt.Valid {
+ t.Fatalf("blocks = %+v %v", blocks, err)
+ }
+ if d := blocks[0].ExpiresAt.Time.Sub(st.clock); d < StallBlock-time.Minute || d > StallBlock+time.Minute {
+ t.Errorf("held back for %s", d)
+ }
+ rows := st.ledger(t)
+ if len(rows) != 1 || rows[0].Trigger != ClientError || rows[0].Action != actGivenUp || rows[0].Outcome != "resolved" ||
+ rows[0].Detail != "No space left on device" {
+ t.Errorf("ledger = %+v", rows)
+ }
+}
+
+func TestLifecycle_ADownloadNobodySharesIsRetriedThenGivenUp_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.3, State: "stalledDL"})
+ ctx := context.Background()
+
+ // Within the stall setting: nothing is wrong yet.
+ if rep := st.s.Pass(ctx); rep.Retried+rep.GivenUp != 0 || len(st.ledger(t)) != 0 {
+ t.Fatalf("a young download was judged: %+v", rep)
+ }
+
+ st.clock = st.clock.Add(7 * time.Hour)
+ for i := 1; i <= Retries; i++ {
+ if rep := st.s.Pass(ctx); rep.Retried != 1 || rep.GivenUp != 0 {
+ t.Fatalf("pass %d = %+v", i, rep)
+ }
+ }
+ if st.client.reannounce != Retries || st.client.recheck != Retries {
+ t.Errorf("reannounced %d, rechecked %d", st.client.reannounce, st.client.recheck)
+ }
+ if st.grabNow(t).EndedAt.Valid {
+ t.Fatal("given up before the retries ran out")
+ }
+ if rep := st.s.Pass(ctx); rep.GivenUp != 1 {
+ t.Fatalf("last pass = %+v", rep)
+ }
+ g := st.grabNow(t)
+ if g.EndReason != EndStalled || len(st.client.deleted) != 1 || !st.searchedAgain(t) {
+ t.Errorf("grab %q: %s; deleted %v", g.EndReason, g.EndDetail, st.client.deleted)
+ }
+ rows := st.ledger(t)
+ if len(rows) != 1 || rows[0].Trigger != NoPeers || rows[0].Remediations != Retries+1 || rows[0].Outcome != "resolved" {
+ t.Errorf("ledger = %+v", rows)
+ }
+}
+
+func TestLifecycle_AStalledDownloadThatRecoversIsSettled_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.3, State: "stalledDL"})
+ ctx := context.Background()
+ st.clock = st.clock.Add(7 * time.Hour)
+ st.s.Pass(ctx)
+ t2 := st.client.held[hash]
+ t2.NumSeeds = 3
+ st.client.held[hash] = t2
+ st.s.Pass(ctx)
+ rows := st.ledger(t)
+ if len(rows) != 1 || rows[0].Outcome != "resolved" || rows[0].Action != actRetried {
+ t.Fatalf("ledger = %+v", rows)
+ }
+ if st.grabNow(t).EndedAt.Valid {
+ t.Error("a download that found peers was given up")
+ }
+}
+
+func TestLifecycle_TheStallSettingOffNeverGivesUp_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.3, State: "stalledDL"})
+ ctx := context.Background()
+ if _, err := st.s.pool.Exec(ctx, `UPDATE acquisition_settings SET stall_hours = 0`); err != nil {
+ t.Fatal(err)
+ }
+ st.clock = st.clock.Add(30 * 24 * time.Hour)
+ for range Retries + 2 {
+ if rep := st.s.Pass(ctx); rep.Retried+rep.GivenUp != 0 {
+ t.Fatalf("pass = %+v", rep)
+ }
+ }
+}
+
+func TestLifecycle_ADownloadGoneFromTheClientEndsAfterHalfAnHour_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.3, State: "downloading", NumSeeds: 4})
+ ctx := context.Background()
+ delete(st.client.held, hash)
+ st.client.held[other] = downloader.Torrent{Hash: other, Name: "Someone else's", Progress: 1, State: "uploading"}
+
+ if rep := st.s.Pass(ctx); rep.Gone != 0 {
+ t.Fatalf("ended at first sight: %+v", rep)
+ }
+ st.clock = st.clock.Add(31 * time.Minute)
+ if rep := st.s.Pass(ctx); rep.Gone != 1 {
+ t.Fatalf("pass = %+v", rep)
+ }
+ g := st.grabNow(t)
+ if g.EndReason != EndGone || !st.searchedAgain(t) {
+ t.Errorf("grab %q, searched again %v", g.EndReason, st.searchedAgain(t))
+ }
+ if blocks, _ := st.q.ListReleaseBlocks(ctx); len(blocks) != 0 {
+ t.Errorf("a release nothing was wrong with was held back: %+v", blocks)
+ }
+}
+
+func TestLifecycle_AnEmptyListIsNotAMeasurement_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.3, State: "downloading"})
+ ctx := context.Background()
+ delete(st.client.held, hash)
+ st.clock = st.clock.Add(24 * time.Hour)
+ for range 3 {
+ if rep := st.s.Pass(ctx); rep.Gone != 0 {
+ t.Fatalf("pass = %+v", rep)
+ }
+ }
+ if len(st.ledger(t)) != 0 || st.grabNow(t).EndedAt.Valid {
+ t.Error("a client listing nothing was taken to have lost the download")
+ }
+}
+
+func TestLifecycle_AnUnreachableClientIsRecordedAndSettled_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.3, State: "stalledDL"})
+ ctx := context.Background()
+ st.clock = st.clock.Add(7 * time.Hour)
+ st.client.err = errors.New("dial tcp: connection refused")
+ st.s.Pass(ctx)
+ down, err := st.q.ListClientsDown(ctx)
+ if err != nil || len(down) != 1 || down[0].Client != "qbittorrent" {
+ t.Fatalf("clients down = %+v %v", down, err)
+ }
+ if st.client.reannounce != 0 {
+ t.Error("a download was judged through a client that did not answer")
+ }
+ st.client.err = nil
+ st.s.Pass(ctx)
+ if down, _ := st.q.ListClientsDown(ctx); len(down) != 0 {
+ t.Errorf("still down: %+v", down)
+ }
+}
+
+func TestLifecycle_AnImportedDownloadSeedsThenIsRemoved_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 1, State: "uploading", Ratio: 0.8})
+ ctx := context.Background()
+ if _, err := st.q.RecordImport(ctx, dbq.RecordImportParams{ID: st.grab.ID, ImportState: "imported",
+ ImportReason: "Imported.", ImportDetail: []byte("{}"), ImportedFiles: 3, EndReason: "imported"}); err != nil {
+ t.Fatal(err)
+ }
+ if !st.grabNow(t).Seeding {
+ t.Fatal("an import did not leave its download seeding")
+ }
+ if rep := st.s.Pass(ctx); rep.Released != 0 || len(st.client.deleted) != 0 {
+ t.Fatalf("removed before the ratio: %+v", rep)
+ }
+ if g := st.grabNow(t); g.Ratio < 0.79 || g.Ratio > 0.81 {
+ t.Errorf("ratio recorded %v", g.Ratio)
+ }
+ tor := st.client.held[hash]
+ tor.Ratio = 2.1
+ st.client.held[hash] = tor
+ if rep := st.s.Pass(ctx); rep.Released != 1 || len(st.client.deleted) != 1 {
+ t.Fatalf("pass = %+v, deleted %v", rep, st.client.deleted)
+ }
+ if !st.grabNow(t).SeedReleasedAt.Valid {
+ t.Error("the release was not recorded")
+ }
+}
+
+func TestLifecycle_AnImportedDownloadIsRemovedWhenItsDaysAreUp_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 1, State: "uploading", Ratio: 0.1})
+ ctx := context.Background()
+ if _, err := st.q.RecordImport(ctx, dbq.RecordImportParams{ID: st.grab.ID, ImportState: "imported",
+ ImportReason: "Imported.", ImportDetail: []byte("{}"), ImportedFiles: 3, EndReason: "imported"}); err != nil {
+ t.Fatal(err)
+ }
+ st.clock = st.clock.Add(15 * 24 * time.Hour)
+ if rep := st.s.Pass(ctx); rep.Released != 1 || len(st.client.deleted) != 1 {
+ t.Fatalf("pass = %+v", rep)
+ }
+}
+
+func TestLifecycle_ADownloadTheClientWouldNotRemoveIsAskedAgain_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.4, State: "error"})
+ ctx := context.Background()
+ st.client.deleteErr = errors.New("HTTP 500")
+ if rep := st.s.Pass(ctx); rep.GivenUp != 1 {
+ t.Fatalf("pass = %+v", rep)
+ }
+ if !st.grabNow(t).EndedAt.Valid {
+ t.Fatal("a refused removal held the grab")
+ }
+ open := 0
+ for _, r := range st.ledger(t) {
+ if r.Trigger == NotRemoved && r.Outcome == "open" {
+ open++
+ }
+ }
+ if open != 1 {
+ t.Fatalf("ledger = %+v", st.ledger(t))
+ }
+ st.client.deleteErr = nil
+ if rep := st.s.Pass(ctx); rep.Removed != 1 || len(st.client.deleted) != 1 {
+ t.Fatalf("pass = %+v", rep)
+ }
+ for _, r := range st.ledger(t) {
+ if r.Outcome == "open" {
+ t.Errorf("still open: %+v", r)
+ }
+ }
+}
+
+func TestLifecycle_APersonGivingUpSetsTheReleaseAsideForGood_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.4, State: "downloading"})
+ ctx := context.Background()
+ if _, err := st.s.Abandon(ctx, st.grab.ID, pgtype.UUID{}); err != nil {
+ t.Fatal(err)
+ }
+ g := st.grabNow(t)
+ if g.EndReason != EndAbandoned || len(st.client.deleted) != 1 || !st.searchedAgain(t) {
+ t.Errorf("grab %q, deleted %v", g.EndReason, st.client.deleted)
+ }
+ blocks, _ := st.q.ListReleaseBlocks(ctx)
+ if len(blocks) != 1 || blocks[0].Reason != acquire.BlockOperator || blocks[0].ExpiresAt.Valid {
+ t.Errorf("blocks = %+v", blocks)
+ }
+ if _, err := st.s.Abandon(ctx, st.grab.ID, pgtype.UUID{}); !errors.Is(err, ErrNotAbandonable) {
+ t.Errorf("abandoned twice: %v", err)
+ }
+}
+
+func TestLifecycle_AHoldExpiresButABlockForGoodIsNeverShortened_Integration(t *testing.T) {
+ st := newStack(t, downloader.Torrent{Name: "x", Progress: 0.4, State: "downloading"})
+ ctx := context.Background()
+ mem := acquire.NewMemory(st.s.pool)
+ past := pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}
+ if err := mem.Block(ctx, dbq.BlockReleaseParams{InfoHash: other, Name: "old", Reason: acquire.BlockStalled, ExpiresAt: past}); err != nil {
+ t.Fatal(err)
+ }
+ if blocks, _ := st.q.ListReleaseBlocks(ctx); len(blocks) != 0 {
+ t.Errorf("an expired hold still holds: %+v", blocks)
+ }
+ if n, err := st.q.DeleteExpiredBlocks(ctx); err != nil || n != 1 {
+ t.Errorf("lifted %d %v", n, err)
+ }
+
+ if err := mem.Block(ctx, dbq.BlockReleaseParams{InfoHash: hash, Name: "x", Reason: acquire.BlockOperator}); err != nil {
+ t.Fatal(err)
+ }
+ soon := pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}
+ if err := mem.Block(ctx, dbq.BlockReleaseParams{InfoHash: hash, Name: "x", Reason: acquire.BlockStalled, ExpiresAt: soon}); err != nil {
+ t.Fatal(err)
+ }
+ blocks, _ := st.q.ListReleaseBlocks(ctx)
+ if len(blocks) != 1 || blocks[0].Reason != acquire.BlockOperator || blocks[0].ExpiresAt.Valid {
+ t.Errorf("a block for good was shortened: %+v", blocks)
+ }
+}
+
+func TestSpan(t *testing.T) {
+ for _, c := range []struct {
+ d time.Duration
+ want string
+ }{{45 * time.Minute, "45 minutes"}, {7 * time.Hour, "7 hours"}, {3 * 24 * time.Hour, "3 days"}} {
+ if got := span(c.d); got != c.want {
+ t.Errorf("span(%s) = %q, want %q", c.d, got, c.want)
+ }
+ }
+}
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index c0cc5728..738e16e6 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -630,6 +630,8 @@ export type WantGrab = {
progress: number;
import_state: ImportState;
import_reason: string;
+ // Why an ended download ended (#5523).
+ end_detail: string;
imported_at: string | null;
imported_files: number;
};
@@ -710,6 +712,12 @@ export type SearchSettings = {
// scan_paths), and the library's folders (#5522).
import_root: string;
scan_paths: string[];
+ // The download lifecycle (#5523): an imported download seeds to this
+ // ratio or for this many days (0: removed once imported; no time limit),
+ // and one nobody shares is given up after this many hours (0: never).
+ seed_ratio: number;
+ seed_days: number;
+ stall_hours: number;
};
export type WantCounts = {
@@ -761,17 +769,42 @@ export type Download = {
files: string[];
skipped: number;
rehomed_from?: string;
+ // Why an ended download ended; for an imported one, its seeding (#5523).
+ end_detail: string;
+ seeding: boolean;
+ ratio: number;
+ seed_released_at: string | null;
+ // What is wrong with it now, if anything.
+ condition: DownloadCondition | null;
};
-export type DownloadsPage = { items: Download[]; limit: number; offset: number };
+// An open condition on a download, from the lifecycle's ledger (#5523).
+export type DownloadCondition = {
+ trigger: 'no_peers' | 'no_metadata' | 'client_error' | 'client_unreachable' | 'gone' | 'not_removed';
+ detail: string;
+ retries: number;
+ since: string;
+};
+
+// A download client not answering now.
+export type ClientDown = { client: string; detail: string; since: string };
+
+export type DownloadsPage = {
+ items: Download[];
+ limit: number;
+ offset: number;
+ clients_down: ClientDown[];
+};
// A release set aside: by a person, by its files, or by an import's check.
export type ReleaseBlock = {
info_hash: string;
name: string;
- reason: 'operator' | 'files' | 'import';
+ reason: 'operator' | 'files' | 'import' | 'stalled';
detail: string;
blocked_at: string;
+ // When a release given up on may be taken again; null is for good.
+ expires_at: string | null;
want_title: string;
want_artist: string;
};
diff --git a/web/src/lib/api/wanted.ts b/web/src/lib/api/wanted.ts
index f09aa9aa..147e5d28 100644
--- a/web/src/lib/api/wanted.ts
+++ b/web/src/lib/api/wanted.ts
@@ -160,6 +160,9 @@ export async function putSearchSettings(body: {
search_interval_hours: number;
allowed_qualities: string[];
import_root: string;
+ seed_ratio: number;
+ seed_days: number;
+ stall_hours: number;
}): Promise {
return api.put('/api/admin/acquisition/settings', body);
}
@@ -199,6 +202,12 @@ export async function refuseDownload(id: string): Promise {
await api.post(`/api/admin/downloads/${id}/refuse`, {});
}
+// A running download given up: the release is set aside for good, the
+// download removed and the album searched for again (#5523).
+export async function abandonDownload(id: string): Promise {
+ await api.post(`/api/admin/downloads/${id}/abandon`, {});
+}
+
export async function listReleaseBlocks(offset: number = 0): Promise {
return api.get(`/api/admin/release-blocks?offset=${offset}`);
}
diff --git a/web/src/lib/components/SearchSettingsCard.svelte b/web/src/lib/components/SearchSettingsCard.svelte
index f7831722..f256d50a 100644
--- a/web/src/lib/components/SearchSettingsCard.svelte
+++ b/web/src/lib/components/SearchSettingsCard.svelte
@@ -11,13 +11,23 @@
// for, whether the best release is taken without asking, and which
// qualities are taken at all. The schedule wakes every ten minutes and
// keeps to each indexer's searches-per-hour. A finished download is
- // checked and filed into one of the library's folders (#5522).
+ // checked and filed into one of the library's folders (#5522); it then
+ // seeds until the ratio or the days below, and a download nobody shares
+ // is given up after the hours below (#5523).
const client = useQueryClient();
const store = createSearchSettingsQuery();
const query = $derived($store);
- type Form = { auto_grab: boolean; search_interval_hours: number; allowed: string[]; import_root: string };
+ type Form = {
+ auto_grab: boolean;
+ search_interval_hours: number;
+ allowed: string[];
+ import_root: string;
+ seed_ratio: number;
+ seed_days: number;
+ stall_hours: number;
+ };
// The form is the server's settings until edited; saved holds what a save
// returned until the query catches up.
@@ -32,7 +42,10 @@
auto_grab: s.auto_grab,
search_interval_hours: s.search_interval_hours,
allowed: [...s.allowed_qualities],
- import_root: s.import_root
+ import_root: s.import_root,
+ seed_ratio: s.seed_ratio,
+ seed_days: s.seed_days,
+ stall_hours: s.stall_hours
};
}
@@ -55,7 +68,10 @@
auto_grab: form.auto_grab,
search_interval_hours: Number(form.search_interval_hours),
allowed_qualities: form.allowed,
- import_root: form.import_root
+ import_root: form.import_root,
+ seed_ratio: Number(form.seed_ratio),
+ seed_days: Number(form.seed_days),
+ stall_hours: Number(form.stall_hours)
});
edited = null;
await client.invalidateQueries({ queryKey: qk.searchSettings() });
@@ -158,7 +174,7 @@
File new music into
A download that checks out is copied here as Artist/Album (Year)/Artist - Album - NN -
- Title, tagged with its MusicBrainz ids. The download itself keeps seeding.
+ Title, tagged with its MusicBrainz ids.
A download that checks out is copied into {query.data.scan_paths[0]}
- as Artist/Album (Year)/Artist - Album - NN - Title, tagged with its MusicBrainz ids. The
- download itself keeps seeding.
+ as Artist/Album (Year)/Artist - Album - NN - Title, tagged with its MusicBrainz ids.
{/if}
+
+ After the download
+
+
+ The files are copied into the library, so the download goes on sharing until either limit,
+ then is removed with its files. A ratio of 0 removes it once imported; 0 days is no time
+ limit.
+
+
+ Seed to a ratio of
+ edit({ seed_ratio: Number(e.currentTarget.value) })}
+ class="w-20 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
+ focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
+ />
+ or for
+ edit({ seed_days: Number(e.currentTarget.value) })}
+ class="w-20 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
+ focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
+ />
+ days
+
+
+
+
+ A download nobody is sharing is asked for again three times, ten minutes apart, then given
+ up: removed, held back for a week, and the album searched for again. 0 never gives up for
+ that; a download the client fails is always given up.
+
+
+ Give up on a stalled download after
+ edit({ stall_hours: Number(e.currentTarget.value) })}
+ class="w-20 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
+ focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
+ />
+ hours
+
+
+
+
{lastPass(query.data)}
diff --git a/web/src/lib/components/SearchSettingsCard.test.ts b/web/src/lib/components/SearchSettingsCard.test.ts
index b93a0c86..b3cf0c4f 100644
--- a/web/src/lib/components/SearchSettingsCard.test.ts
+++ b/web/src/lib/components/SearchSettingsCard.test.ts
@@ -24,7 +24,10 @@ const settings: SearchSettings = {
last_pass_at: new Date(Date.now() - 5 * 60_000).toISOString(),
last_pass: { looked: 12, grabbed: 3, waiting: 2, nothing: 7, failed: 0, paced: true },
import_root: '',
- scan_paths: ['/music', '/music2']
+ scan_paths: ['/music', '/music2'],
+ seed_ratio: 2,
+ seed_days: 14,
+ stall_hours: 6
};
afterEach(() => vi.clearAllMocks());
@@ -60,7 +63,10 @@ describe('SearchSettingsCard', () => {
auto_grab: false,
search_interval_hours: 24,
allowed_qualities: ['lossless', 'lossy_high', 'unknown'],
- import_root: ''
+ import_root: '',
+ seed_ratio: 2,
+ seed_days: 14,
+ stall_hours: 6
})
);
});
@@ -82,6 +88,20 @@ describe('SearchSettingsCard', () => {
expect(screen.getByText('/music')).toBeTruthy();
});
+ test('seeding and giving up on a stalled download are set here', async () => {
+ setup();
+ expect((screen.getByLabelText(/seed ratio/i) as HTMLInputElement).value).toBe('2');
+ await fireEvent.input(screen.getByLabelText(/seed ratio/i), { target: { value: '1.5' } });
+ await fireEvent.input(screen.getByLabelText(/seed days/i), { target: { value: '7' } });
+ await fireEvent.input(screen.getByLabelText(/stall hours/i), { target: { value: '0' } });
+ await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
+ await waitFor(() =>
+ expect(putSearchSettings).toHaveBeenCalledWith(
+ expect.objectContaining({ seed_ratio: 1.5, seed_days: 7, stall_hours: 0 })
+ )
+ );
+ });
+
test('before the first pass it says so', () => {
setup({ ...settings, last_pass_at: null, last_pass: {} });
expect(screen.getByTestId('last-pass').textContent).toMatch(/hasn’t run yet/);
diff --git a/web/src/lib/components/wanted/BlockList.svelte b/web/src/lib/components/wanted/BlockList.svelte
index cd41332a..33b80bd3 100644
--- a/web/src/lib/components/wanted/BlockList.svelte
+++ b/web/src/lib/components/wanted/BlockList.svelte
@@ -8,9 +8,10 @@
import ListContinuation from '#lib/components/ListContinuation.svelte';
import type { ReleaseBlock } from '#lib/api/types.js';
- // The Blocked tab (M502 #5521–#5522): releases the search will never take
+ // The Blocked tab (M502 #5521–#5523): releases the search will not take
// again — set aside by a person, by what their files were, or by an
- // import's check that found they weren't the album.
+ // import's check that found they weren't the album; and, for a week,
+ // releases given up on because they would not finish.
const client = useQueryClient();
const blocksStore = createReleaseBlocksQuery();
@@ -35,8 +36,13 @@
function by(b: ReleaseBlock): string {
if (b.reason === 'operator') return 'Set aside by you';
if (b.reason === 'files') return 'Set aside for its files';
+ if (b.reason === 'stalled') return 'Given up on: it would not finish';
return 'Refused on import';
}
+
+ // A release given up on is held back for a while, then may be taken again.
+ const until = (b: ReleaseBlock) =>
+ b.expires_at ? ` · held back until ${new Date(b.expires_at).toLocaleDateString()}` : '';
{#if blocks.isPending}
@@ -61,7 +67,7 @@
{#if b.want_title}
For {b.want_artist} – {b.want_title}
{/if}
-
{by(b)} {relativeTime(b.blocked_at)}
+
{by(b)} {relativeTime(b.blocked_at)}{until(b)}
{#if b.detail}
{b.detail}
{/if}
diff --git a/web/src/lib/components/wanted/BlockList.test.ts b/web/src/lib/components/wanted/BlockList.test.ts
index bd3574c3..ae4817e0 100644
--- a/web/src/lib/components/wanted/BlockList.test.ts
+++ b/web/src/lib/components/wanted/BlockList.test.ts
@@ -18,6 +18,7 @@ const block: ReleaseBlock = {
reason: 'import',
detail: 'The album tag says another version.',
blocked_at: new Date(Date.now() - 3_600_000).toISOString(),
+ expires_at: null,
want_title: 'Random Access Memories',
want_artist: 'Daft Punk'
};
@@ -43,6 +44,12 @@ describe('BlockList', () => {
await waitFor(() => expect(unblockRelease).toHaveBeenCalledWith('abc123'));
});
+ test('a release given up on says until when it is held back', () => {
+ setup([{ ...block, reason: 'stalled', detail: 'Nobody was sharing it.', expires_at: '2026-10-17T12:00:00Z' }]);
+ const line = screen.getByText(/given up on: it would not finish/i).textContent ?? '';
+ expect(line).toMatch(/held back until/);
+ });
+
test('with nothing blocked it says so', () => {
setup([]);
expect(screen.getByText(/no releases are blocked/i)).toBeTruthy();
diff --git a/web/src/lib/components/wanted/DownloadList.svelte b/web/src/lib/components/wanted/DownloadList.svelte
index 183babbb..7d6d0150 100644
--- a/web/src/lib/components/wanted/DownloadList.svelte
+++ b/web/src/lib/components/wanted/DownloadList.svelte
@@ -1,23 +1,32 @@
+{#each clientsDown as c (c.client)}
+
+ Can't reach the download client {c.client} since {relativeTime(c.since)}: {c.detail}. Its
+ downloads are left as they are until it answers.
+
+{/each}
+
{#if downloads.isPending}
Reading the downloads…
{:else if downloads.isError && !downloads.isFetchNextPageError}
@@ -147,6 +203,12 @@
{/if}
+ {#if running(d) && d.condition}
+
{condition(d.condition)}
+ {/if}
+ {#if ending(d)}
+
{ending(d)}
+ {/if}
{#if d.import_reason}
{d.import_reason}
@@ -158,12 +220,21 @@
{#if filed(d)}
{filed(d)}
{/if}
+ {#if seeded(d)}
+
{seeded(d)}
+ {/if}
{#if d.rehomed_from}
Taken for {d.rehomed_from}; it turned out to be this album.
{/if}
{when(d)}
- {#if needsYou(d)}
+ {#if running(d)}
+
+ onAbandon(d)}>
+ Give up
+
+
+ {:else if needsYou(d)}
{#if d.import_state === 'review'}
onImport(d)}>
diff --git a/web/src/lib/components/wanted/DownloadList.test.ts b/web/src/lib/components/wanted/DownloadList.test.ts
index 812353c2..8bf1b6b4 100644
--- a/web/src/lib/components/wanted/DownloadList.test.ts
+++ b/web/src/lib/components/wanted/DownloadList.test.ts
@@ -1,18 +1,19 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query';
-import type { Download } from '#lib/api/types.js';
+import type { ClientDown, Download } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createDownloadsQuery: vi.fn(),
importAnyway: vi.fn().mockResolvedValue(undefined),
retryImport: vi.fn().mockResolvedValue(undefined),
- refuseDownload: vi.fn().mockResolvedValue(undefined)
+ refuseDownload: vi.fn().mockResolvedValue(undefined),
+ abandonDownload: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import DownloadList from './DownloadList.svelte';
-import { createDownloadsQuery, importAnyway, refuseDownload, retryImport } from '#lib/api/wanted.js';
+import { abandonDownload, createDownloadsQuery, importAnyway, refuseDownload, retryImport } from '#lib/api/wanted.js';
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
@@ -38,15 +39,22 @@ function download(id: string, extra: Partial = {}): Download {
destination: '',
files: [],
skipped: 0,
+ end_detail: '',
+ seeding: false,
+ ratio: 0,
+ seed_released_at: null,
+ condition: null,
...extra
};
}
afterEach(() => vi.clearAllMocks());
-function setup(items: Download[]) {
+function setup(items: Download[], clientsDown: ClientDown[] = []) {
vi.mocked(createDownloadsQuery).mockReturnValue(
- mockInfiniteQuery({ pages: [{ items, total: items.length, limit: 50, offset: 0 }] }) as unknown as ReturnType<
+ mockInfiniteQuery({
+ pages: [{ items, total: items.length, limit: 50, offset: 0, clients_down: clientsDown }]
+ }) as unknown as ReturnType<
typeof createDownloadsQuery
>
);
@@ -54,13 +62,45 @@ function setup(items: Download[]) {
}
describe('DownloadList', () => {
- test('a running download shows how far it has got', () => {
+ test('a running download shows how far it has got, and can be given up', async () => {
setup([download('d1', { progress: 0.375 })]);
expect(screen.getByTestId('download-state').textContent).toMatch(/downloading/i);
expect(screen.getByRole('progressbar', { name: /downloaded/i }).getAttribute('aria-valuenow')).toBe('37');
+ expect(screen.queryByTestId('download-condition')).toBeNull();
+ await fireEvent.click(screen.getByRole('button', { name: /give up/i }));
+ await waitFor(() => expect(abandonDownload).toHaveBeenCalledWith('d1'));
+ });
+
+ test('a stalled download says what is wrong and how often it was asked for again', () => {
+ setup([
+ download('d1', {
+ progress: 0.4,
+ condition: { trigger: 'no_peers', detail: 'Nobody has shared it in 7 hours; 40% downloaded.', retries: 2, since: ago(1_200_000) }
+ })
+ ]);
+ expect(screen.getByTestId('download-condition').textContent).toMatch(
+ /nobody has shared it in 7 hours; 40% downloaded\. asked for again 2 of 3 times/i
+ );
+ });
+
+ test('a download given up on says why it ended', () => {
+ setup([
+ download('d1', {
+ ended_at: ago(60_000),
+ end_reason: 'stalled',
+ end_detail: 'Taken 7 hours ago and never finished: nobody was sharing it. Held back until 17 Oct.'
+ })
+ ]);
+ expect(screen.getByTestId('download-state').textContent).toMatch(/given up/i);
+ expect(screen.getByTestId('download-ending').textContent).toMatch(/nobody was sharing it/);
expect(screen.queryByRole('button')).toBeNull();
});
+ test('a download client that does not answer is named above the list', () => {
+ setup([download('d1')], [{ client: 'qbittorrent', detail: 'connection refused', since: ago(600_000) }]);
+ expect(screen.getByTestId('client-down').textContent).toMatch(/can't reach the download client qbittorrent .*connection refused/i);
+ });
+
test('a doubtful download says why and can be imported anyway or refused', async () => {
setup([
download('d1', {
@@ -102,6 +142,17 @@ describe('DownloadList', () => {
expect(screen.queryByRole('button')).toBeNull();
});
+ test('an imported download says how its seeding goes', () => {
+ const imported = { ended_at: ago(60_000), end_reason: 'imported', import_state: 'imported' as const };
+ setup([
+ download('d1', { ...imported, seeding: true, ratio: 0.8 }),
+ download('d2', { ...imported, seeding: true, ratio: 2.1, seed_released_at: ago(60_000) })
+ ]);
+ const lines = screen.getAllByTestId('download-seeding').map((e) => e.textContent);
+ expect(lines[0]).toMatch(/seeding: share ratio 0\.80/i);
+ expect(lines[1]).toMatch(/seeded, then removed/i);
+ });
+
test('with nothing downloaded it says what lands here', () => {
setup([]);
expect(screen.getByText(/nothing has been downloaded yet/i)).toBeTruthy();
diff --git a/web/src/lib/components/wanted/WantList.svelte b/web/src/lib/components/wanted/WantList.svelte
index 210b1c41..e996ff48 100644
--- a/web/src/lib/components/wanted/WantList.svelte
+++ b/web/src/lib/components/wanted/WantList.svelte
@@ -117,7 +117,10 @@
said = `${when}: ${s.error}`;
}
}
- if (w.grab?.import_state === 'refused') said += `. Last download refused: ${w.grab.import_reason}`;
+ const g = w.grab;
+ if (g?.import_state === 'refused') said += `. Last download refused: ${g.import_reason}`;
+ else if (g && !g.live && ['stalled', 'failed', 'gone'].includes(g.end_reason) && g.end_detail)
+ said += `. Last download given up: ${g.end_detail}`;
return said;
}
diff --git a/web/src/lib/components/wanted/WantList.test.ts b/web/src/lib/components/wanted/WantList.test.ts
index 7ca7a13d..57501db9 100644
--- a/web/src/lib/components/wanted/WantList.test.ts
+++ b/web/src/lib/components/wanted/WantList.test.ts
@@ -65,6 +65,7 @@ function grab(extra: Partial = {}): WantGrab {
progress: 0,
import_state: '',
import_reason: '',
+ end_detail: '',
imported_at: null,
imported_files: 0,
...extra
@@ -155,6 +156,16 @@ describe('WantList', () => {
expect(screen.getByTestId('want-search').textContent).toMatch(/last download refused: another album/i);
});
+ test('a download given up on is named beside the search that follows it', () => {
+ setup([
+ want('w1', {
+ search: { searched_at: ago(3_600_000), outcome: 'waiting', considered: 4, best_release: '', best_verdict: '', error: '' },
+ grab: grab({ live: false, end_reason: 'stalled', import_state: 'downloading', end_detail: 'Nobody was sharing it.' })
+ })
+ ]);
+ expect(screen.getByTestId('want-search').textContent).toMatch(/last download given up: nobody was sharing it/i);
+ });
+
test('a held album says how it arrived', () => {
setup(
[
diff --git a/web/src/lib/styles/error-copy.json b/web/src/lib/styles/error-copy.json
index e9a072c6..4197716c 100644
--- a/web/src/lib/styles/error-copy.json
+++ b/web/src/lib/styles/error-copy.json
@@ -76,5 +76,7 @@
"candidate_not_found": "That release is no longer in the list. Search again.",
"no_infohash": "That release can't be set aside: its indexer gave no infohash to know it by.",
"want_held": "The library already holds that album.",
- "block_not_found": "That release isn't set aside any more."
+ "block_not_found": "That release isn't set aside any more.",
+ "not_running": "That download isn't running any more. Refresh the list.",
+ "bad_lifecycle": "Seed ratio is 0 to 100, seed days 0 to 365, and stall hours 0 to 720."
}
diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts
index 5b0f3ee8..b326a93d 100644
--- a/web/src/routes/admin/integrations/integrations.test.ts
+++ b/web/src/routes/admin/integrations/integrations.test.ts
@@ -59,7 +59,10 @@ vi.mock('#lib/api/wanted.js', async () => {
last_pass: {},
import_root: '',
// Not "/music": a Lidarr root folder on this page is named that.
- scan_paths: ['/library']
+ scan_paths: ['/library'],
+ seed_ratio: 2,
+ seed_days: 14,
+ stall_hours: 6
}
})
),
--
2.54.0
From bc198a93895a0365e052cb9400a842adb499b53c Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 15:35:21 -0400
Subject: [PATCH 017/117] fix: the Activity test's page carries clients_down as
a value, not a literal Page would refuse (M502 #5523)
Co-Authored-By: Claude Opus 5.5
---
web/src/lib/components/wanted/DownloadList.test.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/web/src/lib/components/wanted/DownloadList.test.ts b/web/src/lib/components/wanted/DownloadList.test.ts
index 8bf1b6b4..605b42e2 100644
--- a/web/src/lib/components/wanted/DownloadList.test.ts
+++ b/web/src/lib/components/wanted/DownloadList.test.ts
@@ -51,10 +51,10 @@ function download(id: string, extra: Partial = {}): Download {
afterEach(() => vi.clearAllMocks());
function setup(items: Download[], clientsDown: ClientDown[] = []) {
+ // The Activity page carries the clients not answering beside its items.
+ const page = { items, total: items.length, limit: 50, offset: 0, clients_down: clientsDown };
vi.mocked(createDownloadsQuery).mockReturnValue(
- mockInfiniteQuery({
- pages: [{ items, total: items.length, limit: 50, offset: 0, clients_down: clientsDown }]
- }) as unknown as ReturnType<
+ mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<
typeof createDownloadsQuery
>
);
--
2.54.0
From 2c677b3184b17d65fd9be9e980d7e05b6c6c5dce Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 16:13:46 -0400
Subject: [PATCH 018/117] feat: the library's albums go on the wanted list as
held, once; after that the list is the truth (M502 #5567)
A one-time job in the wanted worker lists every album in the library as a
held want, one per release group, with a record that it happened
(acquisition_settings.library_listed_at). Albums whose tags name only a
release are named on MusicBrainz first, 120 a pass; what nothing names is
counted and shown on the Held tab.
Re-acquisition now wants back only albums on the list. Taking a held album
off the list leaves its files, and it is never fetched back; albums put in
the library folder by hand afterwards are not added.
Co-Authored-By: Claude Opus 5.5
---
internal/api/admin_wanted.go | 22 +-
internal/db/dbq/models.go | 25 +-
internal/db/dbq/reacquisition.sql.go | 7 +-
internal/db/dbq/search.sql.go | 10 +-
internal/db/dbq/wanted.sql.go | 155 ++++++++
.../db/migrations/0085_library_wants.down.sql | 8 +
.../db/migrations/0085_library_wants.up.sql | 23 ++
internal/db/queries/reacquisition.sql | 7 +-
internal/db/queries/wanted.sql | 63 ++++
internal/dbtest/reset.go | 3 +
internal/reacquisition/sweeper_test.go | 45 ++-
internal/tags/provider_musicbrainz.go | 15 +-
internal/wanted/library.go | 117 ++++++
internal/wanted/wanted.go | 14 +-
internal/wanted/wanted_test.go | 132 +++++++
web/src/lib/api/types.ts | 10 +-
web/src/lib/components/wanted/WantList.svelte | 39 +-
.../lib/components/wanted/WantList.test.ts | 333 +++++++++++++-----
web/src/routes/wanted/wanted.test.ts | 109 ++++--
19 files changed, 965 insertions(+), 172 deletions(-)
create mode 100644 internal/db/migrations/0085_library_wants.down.sql
create mode 100644 internal/db/migrations/0085_library_wants.up.sql
create mode 100644 internal/wanted/library.go
diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go
index 8dec9100..b6e015a1 100644
--- a/internal/api/admin_wanted.go
+++ b/internal/api/admin_wanted.go
@@ -86,6 +86,16 @@ type wantsPage struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Counts wantCounts `json:"counts"`
+ // Library is the one-time listing of the library's albums (#5567).
+ Library libraryListingView `json:"library"`
+}
+
+// libraryListingView: whether the library's albums are all on the list
+// yet, and how many could not be listed (nothing names them to
+// MusicBrainz).
+type libraryListingView struct {
+ ListedAt *string `json:"listed_at"`
+ Unlisted int32 `json:"unlisted"`
}
const wantsPageSize = 50
@@ -116,11 +126,21 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
return
}
+ listing, err := q.GetLibraryListing(r.Context())
+ if err != nil {
+ writeErrWithLog(w, h.logger, "admin wanted: library listing", apierror.Internal(err))
+ return
+ }
out := wantsPage{
- Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
+ Library: libraryListingView{Unlisted: listing.LibraryUnlisted},
+ Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
}
+ if listing.LibraryListedAt.Valid {
+ at := formatTimestamp(listing.LibraryListedAt)
+ out.Library.ListedAt = &at
+ }
switch state {
case "missing":
out.Total = counts.Missing
diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go
index d656ee35..d094ae0a 100644
--- a/internal/db/dbq/models.go
+++ b/internal/db/dbq/models.go
@@ -218,17 +218,20 @@ type AcquisitionService struct {
}
type AcquisitionSetting struct {
- ID bool
- AutoGrab bool
- SearchIntervalHours int32
- AllowedQualities []string
- LastPassAt pgtype.Timestamptz
- LastPass []byte
- UpdatedAt pgtype.Timestamptz
- ImportRoot string
- SeedRatio float32
- SeedDays int32
- StallHours int32
+ ID bool
+ AutoGrab bool
+ SearchIntervalHours int32
+ AllowedQualities []string
+ LastPassAt pgtype.Timestamptz
+ LastPass []byte
+ UpdatedAt pgtype.Timestamptz
+ ImportRoot string
+ SeedRatio float32
+ SeedDays int32
+ StallHours int32
+ LibraryListingStartedAt pgtype.Timestamptz
+ LibraryListedAt pgtype.Timestamptz
+ LibraryUnlisted int32
}
type Album struct {
diff --git a/internal/db/dbq/reacquisition.sql.go b/internal/db/dbq/reacquisition.sql.go
index c4ea7208..870f9bad 100644
--- a/internal/db/dbq/reacquisition.sql.go
+++ b/internal/db/dbq/reacquisition.sql.go
@@ -109,7 +109,10 @@ SELECT albums.id AS album_id,
AND artists.mbid IS NOT NULL
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
artists.name, artists.mbid
-HAVING NOT EXISTS (
+HAVING EXISTS (
+ SELECT 1 FROM wants w
+ WHERE w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
+ AND NOT EXISTS (
SELECT 1 FROM wants w
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
@@ -140,6 +143,8 @@ type ListAlbumsDueReacquisitionRow struct {
// re-stamps (#2523), so it is a genuine "gone since" clock;
// - MusicBrainz can name it: the album and its artist have MBIDs. Albums
// failing this are counted separately (CountAlbumsMissingWithoutMbid);
+// - it is on the wanted list (#5567): an album taken off the list, or
+// dropped into the library by hand, is not fetched back;
// - no want already covers it: none open, and none held since its files
// went missing. A want held after the loss means the library has a full
// copy despite the missing rows (#5245), so asking again would loop.
diff --git a/internal/db/dbq/search.sql.go b/internal/db/dbq/search.sql.go
index aa30dc94..3b2a75e4 100644
--- a/internal/db/dbq/search.sql.go
+++ b/internal/db/dbq/search.sql.go
@@ -96,7 +96,7 @@ func (q *Queries) EndGrab(ctx context.Context, arg EndGrabParams) error {
const getAcquisitionSettings = `-- name: GetAcquisitionSettings :one
-SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours FROM acquisition_settings WHERE id = true
+SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours, library_listing_started_at, library_listed_at, library_unlisted FROM acquisition_settings WHERE id = true
`
// Searching for wants and grabbing releases (M502 #5521).
@@ -115,6 +115,9 @@ func (q *Queries) GetAcquisitionSettings(ctx context.Context) (AcquisitionSettin
&i.SeedRatio,
&i.SeedDays,
&i.StallHours,
+ &i.LibraryListingStartedAt,
+ &i.LibraryListedAt,
+ &i.LibraryUnlisted,
)
return i, err
}
@@ -608,7 +611,7 @@ UPDATE acquisition_settings
stall_hours = $7,
updated_at = now()
WHERE id = true
-RETURNING id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours
+RETURNING id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours, library_listing_started_at, library_listed_at, library_unlisted
`
type UpdateAcquisitionSettingsParams struct {
@@ -644,6 +647,9 @@ func (q *Queries) UpdateAcquisitionSettings(ctx context.Context, arg UpdateAcqui
&i.SeedRatio,
&i.SeedDays,
&i.StallHours,
+ &i.LibraryListingStartedAt,
+ &i.LibraryListedAt,
+ &i.LibraryUnlisted,
)
return i, err
}
diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go
index 7e7df749..dacf5e07 100644
--- a/internal/db/dbq/wanted.sql.go
+++ b/internal/db/dbq/wanted.sql.go
@@ -124,6 +124,33 @@ func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error)
return result.RowsAffected(), nil
}
+const finishLibraryListing = `-- name: FinishLibraryListing :one
+UPDATE acquisition_settings s
+ SET library_listed_at = now(),
+ library_unlisted = (
+ SELECT COUNT(*) FROM albums a
+ WHERE a.created_at <= s.library_listing_started_at
+ AND NOT EXISTS (SELECT 1 FROM wants w
+ WHERE w.album_id = a.id
+ OR (a.release_group_mbid <> '' AND w.release_group_mbid = a.release_group_mbid)))
+ WHERE s.id = true AND s.library_listed_at IS NULL
+RETURNING library_listed_at, library_unlisted
+`
+
+type FinishLibraryListingRow struct {
+ LibraryListedAt pgtype.Timestamptz
+ LibraryUnlisted int32
+}
+
+// The listing is done: count the albums from before the cutoff it could not
+// list, because nothing names them to MusicBrainz.
+func (q *Queries) FinishLibraryListing(ctx context.Context) (FinishLibraryListingRow, error) {
+ row := q.db.QueryRow(ctx, finishLibraryListing)
+ var i FinishLibraryListingRow
+ err := row.Scan(&i.LibraryListedAt, &i.LibraryUnlisted)
+ return i, err
+}
+
const fulfillWant = `-- name: FulfillWant :exec
UPDATE wants
SET fulfilled_at = now(),
@@ -199,6 +226,26 @@ func (q *Queries) GetFollow(ctx context.Context, artistMbid string) (GetFollowRo
return i, err
}
+const getLibraryListing = `-- name: GetLibraryListing :one
+
+SELECT library_listing_started_at, library_listed_at, library_unlisted
+ FROM acquisition_settings WHERE id = true
+`
+
+type GetLibraryListingRow struct {
+ LibraryListingStartedAt pgtype.Timestamptz
+ LibraryListedAt pgtype.Timestamptz
+ LibraryUnlisted int32
+}
+
+// The one-time listing of the library (#5567). ---------------------------
+func (q *Queries) GetLibraryListing(ctx context.Context) (GetLibraryListingRow, error) {
+ row := q.db.QueryRow(ctx, getLibraryListing)
+ var i GetLibraryListingRow
+ err := row.Scan(&i.LibraryListingStartedAt, &i.LibraryListedAt, &i.LibraryUnlisted)
+ return i, err
+}
+
const getReleaseTypeProfile = `-- name: GetReleaseTypeProfile :one
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE id = $1
`
@@ -424,6 +471,83 @@ func (q *Queries) ListFollowsDue(ctx context.Context, arg ListFollowsDueParams)
return items, nil
}
+const listLibraryAlbums = `-- name: ListLibraryAlbums :execrows
+INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, wanted_at, fulfilled_at)
+SELECT DISTINCT ON (a.release_group_mbid)
+ a.release_group_mbid, ar.mbid, ar.name, a.title, 'library', a.id, now(),
+ COALESCE((SELECT MIN(t.missing_since) FROM tracks t WHERE t.album_id = a.id) - interval '1 second', now())
+ FROM albums a
+ JOIN artists ar ON ar.id = a.artist_id
+ WHERE a.release_group_mbid IS NOT NULL AND a.release_group_mbid <> ''
+ AND ar.mbid IS NOT NULL AND ar.mbid <> ''
+ AND a.created_at <= $1::timestamptz
+ AND ($2::uuid IS NULL OR a.id = $2::uuid)
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
+ ORDER BY a.release_group_mbid,
+ (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL) DESC, a.id
+ON CONFLICT (release_group_mbid) DO NOTHING
+`
+
+type ListLibraryAlbumsParams struct {
+ Cutoff pgtype.Timestamptz
+ AlbumID pgtype.UUID
+}
+
+// Puts library albums on the list as held: each release group once, by the
+// album holding the most tracks. Only albums the library had at the cutoff,
+// and with album_id given only that album. A group already on the list is
+// left as it is. An album with tracks already missing is held from just
+// before the first loss, so re-acquisition still fetches it back.
+func (q *Queries) ListLibraryAlbums(ctx context.Context, arg ListLibraryAlbumsParams) (int64, error) {
+ result, err := q.db.Exec(ctx, listLibraryAlbums, arg.Cutoff, arg.AlbumID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const listLibraryAlbumsToName = `-- name: ListLibraryAlbumsToName :many
+SELECT a.id, a.title, a.mbid FROM albums a
+ WHERE a.release_group_mbid IS NULL AND a.mbid IS NOT NULL AND a.mbid <> ''
+ AND a.created_at <= $1::timestamptz
+ ORDER BY a.created_at, a.id
+ LIMIT $2
+`
+
+type ListLibraryAlbumsToNameParams struct {
+ Cutoff pgtype.Timestamptz
+ MaxRows int32
+}
+
+type ListLibraryAlbumsToNameRow struct {
+ ID pgtype.UUID
+ Title string
+ Mbid *string
+}
+
+// Library albums from before the cutoff that carry a release id but no
+// release group yet: the listing asks MusicBrainz for each. ” marks one
+// MusicBrainz could not name, so it is asked once.
+func (q *Queries) ListLibraryAlbumsToName(ctx context.Context, arg ListLibraryAlbumsToNameParams) ([]ListLibraryAlbumsToNameRow, error) {
+ rows, err := q.db.Query(ctx, listLibraryAlbumsToName, arg.Cutoff, arg.MaxRows)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListLibraryAlbumsToNameRow
+ for rows.Next() {
+ var i ListLibraryAlbumsToNameRow
+ if err := rows.Scan(&i.ID, &i.Title, &i.Mbid); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const listOpenWants = `-- name: ListOpenWants :many
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants
WHERE fulfilled_at IS NULL
@@ -697,6 +821,21 @@ func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error
return err
}
+const setAlbumReleaseGroup = `-- name: SetAlbumReleaseGroup :exec
+UPDATE albums SET release_group_mbid = $1
+ WHERE id = $2 AND release_group_mbid IS NULL
+`
+
+type SetAlbumReleaseGroupParams struct {
+ ReleaseGroupMbid *string
+ ID pgtype.UUID
+}
+
+func (q *Queries) SetAlbumReleaseGroup(ctx context.Context, arg SetAlbumReleaseGroupParams) error {
+ _, err := q.db.Exec(ctx, setAlbumReleaseGroup, arg.ReleaseGroupMbid, arg.ID)
+ return err
+}
+
const setDefaultReleaseTypeProfile = `-- name: SetDefaultReleaseTypeProfile :execrows
UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1
`
@@ -709,6 +848,22 @@ func (q *Queries) SetDefaultReleaseTypeProfile(ctx context.Context, id pgtype.UU
return result.RowsAffected(), nil
}
+const startLibraryListing = `-- name: StartLibraryListing :one
+UPDATE acquisition_settings
+ SET library_listing_started_at = COALESCE(library_listing_started_at, now())
+ WHERE id = true
+RETURNING library_listing_started_at
+`
+
+// Marks the listing begun, once; its time is the cutoff for which albums
+// count as the library's.
+func (q *Queries) StartLibraryListing(ctx context.Context) (pgtype.Timestamptz, error) {
+ row := q.db.QueryRow(ctx, startLibraryListing)
+ var library_listing_started_at pgtype.Timestamptz
+ err := row.Scan(&library_listing_started_at)
+ return library_listing_started_at, err
+}
+
const updateReleaseTypeProfile = `-- name: UpdateReleaseTypeProfile :one
UPDATE release_type_profiles
SET name = $2,
diff --git a/internal/db/migrations/0085_library_wants.down.sql b/internal/db/migrations/0085_library_wants.down.sql
new file mode 100644
index 00000000..3fe34736
--- /dev/null
+++ b/internal/db/migrations/0085_library_wants.down.sql
@@ -0,0 +1,8 @@
+ALTER TABLE acquisition_settings
+ DROP COLUMN library_listing_started_at,
+ DROP COLUMN library_listed_at,
+ DROP COLUMN library_unlisted;
+
+DELETE FROM wants WHERE reason = 'library';
+ALTER TABLE wants DROP CONSTRAINT wants_reason_check;
+ALTER TABLE wants ADD CONSTRAINT wants_reason_check CHECK (reason IN ('request', 'follow', 'missing'));
diff --git a/internal/db/migrations/0085_library_wants.up.sql b/internal/db/migrations/0085_library_wants.up.sql
new file mode 100644
index 00000000..9cf69406
--- /dev/null
+++ b/internal/db/migrations/0085_library_wants.up.sql
@@ -0,0 +1,23 @@
+-- The library's albums go on the wanted list as held, once (M502 #5567,
+-- operator 2026-10-10). After that the list is the truth: an album taken
+-- off it stays off, and one dropped into the library folder by hand is not
+-- added. Re-acquisition fetches back only albums on the list.
+--
+-- wants.reason library: on the list because the library
+-- held it when the list began.
+-- acquisition_settings when the one-time listing began (its cutoff:
+-- albums added later are not listed), when it
+-- finished, and how many albums it could not
+-- list because nothing names them to
+-- MusicBrainz.
+--
+-- The listing itself runs in the wanted worker after boot, not here: it may
+-- have to ask MusicBrainz for each album's release group.
+ALTER TABLE wants DROP CONSTRAINT wants_reason_check;
+ALTER TABLE wants ADD CONSTRAINT wants_reason_check
+ CHECK (reason IN ('request', 'follow', 'missing', 'library'));
+
+ALTER TABLE acquisition_settings
+ ADD COLUMN library_listing_started_at timestamptz,
+ ADD COLUMN library_listed_at timestamptz,
+ ADD COLUMN library_unlisted integer NOT NULL DEFAULT 0;
diff --git a/internal/db/queries/reacquisition.sql b/internal/db/queries/reacquisition.sql
index e03f7169..fbae4a29 100644
--- a/internal/db/queries/reacquisition.sql
+++ b/internal/db/queries/reacquisition.sql
@@ -23,6 +23,8 @@ RETURNING *;
-- re-stamps (#2523), so it is a genuine "gone since" clock;
-- * MusicBrainz can name it: the album and its artist have MBIDs. Albums
-- failing this are counted separately (CountAlbumsMissingWithoutMbid);
+-- * it is on the wanted list (#5567): an album taken off the list, or
+-- dropped into the library by hand, is not fetched back;
-- * no want already covers it: none open, and none held since its files
-- went missing. A want held after the loss means the library has a full
-- copy despite the missing rows (#5245), so asking again would loop.
@@ -43,7 +45,10 @@ SELECT albums.id AS album_id,
AND artists.mbid IS NOT NULL
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
artists.name, artists.mbid
-HAVING NOT EXISTS (
+HAVING EXISTS (
+ SELECT 1 FROM wants w
+ WHERE w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
+ AND NOT EXISTS (
SELECT 1 FROM wants w
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql
index 5da52d33..394b6d76 100644
--- a/internal/db/queries/wanted.sql
+++ b/internal/db/queries/wanted.sql
@@ -222,3 +222,66 @@ SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::big
(SELECT COUNT(*) FROM release_blocks b
WHERE b.expires_at IS NULL OR b.expires_at > now())::bigint AS blocked
FROM wants;
+
+-- The one-time listing of the library (#5567). ---------------------------
+
+-- name: GetLibraryListing :one
+SELECT library_listing_started_at, library_listed_at, library_unlisted
+ FROM acquisition_settings WHERE id = true;
+
+-- name: StartLibraryListing :one
+-- Marks the listing begun, once; its time is the cutoff for which albums
+-- count as the library's.
+UPDATE acquisition_settings
+ SET library_listing_started_at = COALESCE(library_listing_started_at, now())
+ WHERE id = true
+RETURNING library_listing_started_at;
+
+-- name: ListLibraryAlbums :execrows
+-- Puts library albums on the list as held: each release group once, by the
+-- album holding the most tracks. Only albums the library had at the cutoff,
+-- and with album_id given only that album. A group already on the list is
+-- left as it is. An album with tracks already missing is held from just
+-- before the first loss, so re-acquisition still fetches it back.
+INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, wanted_at, fulfilled_at)
+SELECT DISTINCT ON (a.release_group_mbid)
+ a.release_group_mbid, ar.mbid, ar.name, a.title, 'library', a.id, now(),
+ COALESCE((SELECT MIN(t.missing_since) FROM tracks t WHERE t.album_id = a.id) - interval '1 second', now())
+ FROM albums a
+ JOIN artists ar ON ar.id = a.artist_id
+ WHERE a.release_group_mbid IS NOT NULL AND a.release_group_mbid <> ''
+ AND ar.mbid IS NOT NULL AND ar.mbid <> ''
+ AND a.created_at <= sqlc.arg(cutoff)::timestamptz
+ AND (sqlc.narg(album_id)::uuid IS NULL OR a.id = sqlc.narg(album_id)::uuid)
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
+ ORDER BY a.release_group_mbid,
+ (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL) DESC, a.id
+ON CONFLICT (release_group_mbid) DO NOTHING;
+
+-- name: ListLibraryAlbumsToName :many
+-- Library albums from before the cutoff that carry a release id but no
+-- release group yet: the listing asks MusicBrainz for each. '' marks one
+-- MusicBrainz could not name, so it is asked once.
+SELECT a.id, a.title, a.mbid FROM albums a
+ WHERE a.release_group_mbid IS NULL AND a.mbid IS NOT NULL AND a.mbid <> ''
+ AND a.created_at <= sqlc.arg(cutoff)::timestamptz
+ ORDER BY a.created_at, a.id
+ LIMIT sqlc.arg(max_rows);
+
+-- name: SetAlbumReleaseGroup :exec
+UPDATE albums SET release_group_mbid = sqlc.arg(release_group_mbid)
+ WHERE id = sqlc.arg(id) AND release_group_mbid IS NULL;
+
+-- name: FinishLibraryListing :one
+-- The listing is done: count the albums from before the cutoff it could not
+-- list, because nothing names them to MusicBrainz.
+UPDATE acquisition_settings s
+ SET library_listed_at = now(),
+ library_unlisted = (
+ SELECT COUNT(*) FROM albums a
+ WHERE a.created_at <= s.library_listing_started_at
+ AND NOT EXISTS (SELECT 1 FROM wants w
+ WHERE w.album_id = a.id
+ OR (a.release_group_mbid <> '' AND w.release_group_mbid = a.release_group_mbid)))
+ WHERE s.id = true AND s.library_listed_at IS NULL
+RETURNING library_listed_at, library_unlisted;
diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go
index 5da0e417..0a6cf757 100644
--- a/internal/dbtest/reset.go
+++ b/internal/dbtest/reset.go
@@ -185,10 +185,13 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
t.Fatalf("dbtest.ResetDB reset release-type profiles: %v", err)
}
// Search and grab settings (M502 #5521), a singleton reset the same way.
+ // The library listing (#5567) reads as done, so a test's albums are not
+ // put on the wanted list behind its back; its own tests clear it.
if _, err := pool.Exec(ctx, `
UPDATE acquisition_settings
SET auto_grab = DEFAULT, search_interval_hours = DEFAULT, allowed_qualities = DEFAULT, import_root = DEFAULT,
seed_ratio = DEFAULT, seed_days = DEFAULT, stall_hours = DEFAULT,
+ library_listing_started_at = now(), library_listed_at = now(), library_unlisted = 0,
last_pass_at = NULL, last_pass = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset acquisition settings: %v", err)
diff --git a/internal/reacquisition/sweeper_test.go b/internal/reacquisition/sweeper_test.go
index bb8adaaa..51a974f6 100644
--- a/internal/reacquisition/sweeper_test.go
+++ b/internal/reacquisition/sweeper_test.go
@@ -69,11 +69,23 @@ func lostAlbum(t *testing.T, pool *pgxpool.Pool, title, release, group string) p
return al.ID
}
-// #5241: an album is wanted by its release group, never its release id. A
-// tag-supplied group is used as is; otherwise MusicBrainz names it and the
-// answer is cached onto the album; an album MusicBrainz cannot name is
-// skipped and tried again next pass.
-func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
+// heldWant puts an album on the wanted list, held since ago.
+func heldWant(t *testing.T, pool *pgxpool.Pool, group string, album pgtype.UUID, ago string) {
+ t.Helper()
+ if _, err := pool.Exec(context.Background(), `
+ INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, fulfilled_at)
+ VALUES ($1, 'a', 'A', 'T', 'library', $2, now() - $3::interval)`, group, album, ago); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Only an album on the wanted list is fetched back (#5567): one taken off
+// it, or dropped into the library by hand, is not. An album on the list is
+// wanted by its release group, never its release id (#5241): a tag-supplied
+// group is used as is; otherwise MusicBrainz names it and the answer is
+// cached onto the album; an album MusicBrainz cannot name is skipped and
+// tried again next pass.
+func TestSweep_WantsBackOnlyAlbumsOnTheList_Integration(t *testing.T) {
pool := newSweepPool(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -85,7 +97,11 @@ func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
}
tagged := lostAlbum(t, pool, "Tagged", "release-tagged", "group-tagged")
resolved := lostAlbum(t, pool, "Resolved", "release-resolved", "")
- _ = lostAlbum(t, pool, "Unknown", "release-unknown", "")
+ unknown := lostAlbum(t, pool, "Unknown", "release-unknown", "")
+ _ = lostAlbum(t, pool, "Off The List", "release-off", "group-off")
+ heldWant(t, pool, "group-tagged", tagged, "3 days")
+ heldWant(t, pool, "group-resolved", resolved, "3 days")
+ heldWant(t, pool, "group-unknown", unknown, "3 days")
s := NewSweeper(pool, settings, logger)
asked := map[string]int{}
@@ -112,8 +128,14 @@ func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
}
wantFor("group-tagged", tagged)
wantFor("group-resolved", resolved)
- if asked["release-tagged"] != 0 {
- t.Error("MusicBrainz asked about an album whose tags named its group")
+ if w, _ := q.GetWantByReleaseGroup(ctx, "group-unknown"); !w.FulfilledAt.Valid {
+ t.Error("an album MusicBrainz cannot name was reopened under the wrong group")
+ }
+ if _, err := q.GetWantByReleaseGroup(ctx, "group-off"); err == nil {
+ t.Error("an album not on the list was wanted back")
+ }
+ if asked["release-tagged"] != 0 || asked["release-off"] != 0 {
+ t.Errorf("MusicBrainz asked %v: not about a tagged album, nor one off the list", asked)
}
var cached *string
if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", resolved).Scan(&cached); err != nil {
@@ -122,13 +144,6 @@ func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
if cached == nil || *cached != "group-resolved" {
t.Errorf("resolved group not cached on the album: %v", cached)
}
- var n int
- if err := pool.QueryRow(ctx, "SELECT count(*) FROM wants").Scan(&n); err != nil {
- t.Fatal(err)
- }
- if n != 2 {
- t.Errorf("%d wants, want 2: the unresolvable album is skipped", n)
- }
// The next pass finds both already wanted and asks nothing new.
asked = map[string]int{}
diff --git a/internal/tags/provider_musicbrainz.go b/internal/tags/provider_musicbrainz.go
index c60b6e0f..14aa424b 100644
--- a/internal/tags/provider_musicbrainz.go
+++ b/internal/tags/provider_musicbrainz.go
@@ -3,6 +3,7 @@ package tags
import (
"context"
"errors"
+ "fmt"
"net/http"
"net/url"
"sync/atomic"
@@ -172,15 +173,20 @@ func (p *musicbrainzProvider) TestConnection(ctx context.Context) error {
func ReleaseGroupForRelease(ctx context.Context, releaseMBID string) (string, error) {
p, err := ProviderByID("musicbrainz")
if err != nil {
- return "", ErrNotFound
+ return "", ErrSwitchedOff
}
mb, ok := p.(*musicbrainzProvider)
if !ok {
- return "", ErrNotFound
+ return "", ErrSwitchedOff
}
return mb.releaseGroupForRelease(ctx, releaseMBID)
}
+// ErrSwitchedOff is ReleaseGroupForRelease's answer while MusicBrainz is
+// switched off. It is an ErrNotFound, so callers that only skip still do; a
+// caller that would record "MusicBrainz has none" can tell it apart.
+var ErrSwitchedOff = fmt.Errorf("%w: MusicBrainz is switched off", ErrNotFound)
+
// mbReleaseResponse models the subset of /ws/2/release/{mbid}?inc=release-groups
// we read.
type mbReleaseResponse struct {
@@ -190,7 +196,10 @@ type mbReleaseResponse struct {
}
func (p *musicbrainzProvider) releaseGroupForRelease(ctx context.Context, releaseMBID string) (string, error) {
- if !p.enabled.Load() || releaseMBID == "" {
+ if !p.enabled.Load() {
+ return "", ErrSwitchedOff
+ }
+ if releaseMBID == "" {
return "", ErrNotFound
}
q := url.Values{"inc": {"release-groups"}, "fmt": {"json"}}
diff --git a/internal/wanted/library.go b/internal/wanted/library.go
new file mode 100644
index 00000000..cefadbc0
--- /dev/null
+++ b/internal/wanted/library.go
@@ -0,0 +1,117 @@
+package wanted
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/tags"
+)
+
+// ReasonLibrary: on the list because the library held it when the list
+// began (#5567).
+const ReasonLibrary = "library"
+
+// libraryNamesPerPass bounds the MusicBrainz lookups one pass makes for
+// albums whose tags named a release but not its group: about two minutes at
+// MusicBrainz's one request a second, so the pass still settles its wants.
+const libraryNamesPerPass = 120
+
+// listLibrary puts the library's albums on the wanted list as held, once
+// (operator, 2026-10-10: "make a record saying that the import has
+// happened so later changes like unwanting hold and dont auto import from
+// the file directory"). It runs a step each pass until done:
+//
+// - the first step stamps when the listing began, which is its cutoff:
+// an album the library gains afterwards, dropped in by hand, is never
+// listed;
+// - every album whose release group is known goes on the list at once;
+// - albums that name a release but not its group are looked up on
+// MusicBrainz, a bounded number a pass, and listed as each is named;
+// - once none is left to name, the listing is stamped done, with a count
+// of the albums nothing names to MusicBrainz.
+//
+// After that it never runs again, so an album taken off the list stays off.
+func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
+ q := dbq.New(s.pool)
+ state, err := q.GetLibraryListing(ctx)
+ if err != nil {
+ return fmt.Errorf("wanted: read the library listing: %w", err)
+ }
+ if state.LibraryListedAt.Valid {
+ return nil
+ }
+ cutoff := state.LibraryListingStartedAt
+ if !cutoff.Valid {
+ if cutoff, err = q.StartLibraryListing(ctx); err != nil {
+ return fmt.Errorf("wanted: start the library listing: %w", err)
+ }
+ n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff})
+ if err != nil {
+ return fmt.Errorf("wanted: list the library: %w", err)
+ }
+ res.Listed += int(n)
+ s.logger.Info("wanted: listing the library's albums as held", "listed", n)
+ }
+
+ for {
+ albums, err := q.ListLibraryAlbumsToName(ctx, dbq.ListLibraryAlbumsToNameParams{Cutoff: cutoff, MaxRows: libraryNamesPerPass})
+ if err != nil {
+ return fmt.Errorf("wanted: albums to name: %w", err)
+ }
+ if len(albums) == 0 {
+ break
+ }
+ for _, a := range albums {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ group, err := s.releaseGroup(ctx, deref(a.Mbid))
+ switch {
+ case errors.Is(err, tags.ErrSwitchedOff):
+ // Nothing can be named while MusicBrainz is off: the rest are
+ // counted as unlisted rather than left waiting for ever.
+ return s.finishLibrary(ctx, q, res)
+ case errors.Is(err, tags.ErrNotFound):
+ group = "" // MusicBrainz has none: asked once
+ case err != nil:
+ // Unreachable or refusing for now: the next pass goes on.
+ s.logger.Info("wanted: naming the library's albums paused", "err", err)
+ return nil
+ }
+ if err := q.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &group}); err != nil {
+ return fmt.Errorf("wanted: name album %s: %w", a.Title, err)
+ }
+ if group == "" {
+ continue
+ }
+ n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff, AlbumID: a.ID})
+ if err != nil {
+ return fmt.Errorf("wanted: list album %s: %w", a.Title, err)
+ }
+ res.Listed += int(n)
+ }
+ if len(albums) == libraryNamesPerPass {
+ return nil // the rest next pass
+ }
+ }
+ return s.finishLibrary(ctx, q, res)
+}
+
+func (s *Service) finishLibrary(ctx context.Context, q *dbq.Queries, res *PassResult) error {
+ done, err := q.FinishLibraryListing(ctx)
+ if err != nil {
+ return fmt.Errorf("wanted: finish the library listing: %w", err)
+ }
+ s.logger.Info("wanted: the library's albums are on the list", "listed_this_pass", res.Listed,
+ "unlisted", done.LibraryUnlisted)
+ return nil
+}
+
+func deref(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+}
diff --git a/internal/wanted/wanted.go b/internal/wanted/wanted.go
index 0d430b52..6f21ec0c 100644
--- a/internal/wanted/wanted.go
+++ b/internal/wanted/wanted.go
@@ -2,7 +2,9 @@
// artists, each on a release-type profile, and wanted release groups. A want
// comes from an approved request, a followed artist's new release, or an
// album whose files went missing; it is held once the library has a full
-// copy. Searching for open wants is #5521's.
+// copy. The library's own albums were put on the list as held once (#5567),
+// so the list is the record of what the library keeps. Searching for open
+// wants is #5521's.
package wanted
import (
@@ -172,9 +174,9 @@ func (s *Service) Run(ctx context.Context) {
switch {
case err != nil && ctx.Err() == nil:
s.logger.Warn("wanted: pass stopped", "err", err, "follows", res.Follows, "wanted", res.Wanted, "held", res.Held)
- case res.Wanted > 0 || res.Held > 0 || res.Baselined > 0:
+ case res.Wanted > 0 || res.Held > 0 || res.Baselined > 0 || res.Listed > 0:
s.logger.Info("wanted: pass complete", "follows", res.Follows, "baselined", res.Baselined,
- "wanted", res.Wanted, "held", res.Held)
+ "wanted", res.Wanted, "held", res.Held, "listed", res.Listed)
}
select {
case <-ctx.Done():
@@ -190,6 +192,7 @@ type PassResult struct {
Baselined int // of those, first reads
Wanted int // wants recorded from follows
Held int // wants the library now answers
+ Listed int // library albums put on the list as held (#5567)
}
// Pass applies the follows due and settles open wants. An unreachable
@@ -201,6 +204,11 @@ func (s *Service) Pass(ctx context.Context) (PassResult, error) {
if ctx.Err() != nil {
return res, ctx.Err()
}
+ // The one-time listing of the library (#5567); a failure waits for the
+ // next pass and does not hold back settling.
+ if err := s.listLibrary(ctx, &res); err != nil && ctx.Err() == nil {
+ s.logger.Warn("wanted: list the library", "err", err)
+ }
if err := s.settleWants(ctx, &res); err != nil {
return res, err
}
diff --git a/internal/wanted/wanted_test.go b/internal/wanted/wanted_test.go
index 7ede0c7c..0b7ecd09 100644
--- a/internal/wanted/wanted_test.go
+++ b/internal/wanted/wanted_test.go
@@ -455,3 +455,135 @@ func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
}
return u.ID
}
+
+// startListing clears the reset's "already listed", as a fresh install has it.
+func startListing(t *testing.T, pool *pgxpool.Pool) {
+ t.Helper()
+ if _, err := pool.Exec(context.Background(), `
+ UPDATE acquisition_settings SET library_listing_started_at = NULL, library_listed_at = NULL, library_unlisted = 0`); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// The library's albums go on the list as held, once (#5567). An album the
+// tags name by release only is named through MusicBrainz first; one nothing
+// names is counted, not listed. Taken off the list, an album stays off, and
+// one added to the library afterwards is never listed.
+func TestListLibrary_OnceAndThenTheListIsTheTruth_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ s := newService(t, pool, newFakeMB(t))
+ s.releaseGroup = func(_ context.Context, release string) (string, error) {
+ if release == "rel-parklife" {
+ return "rg-parklife", nil
+ }
+ return "", tags.ErrNotFound
+ }
+ blurAlbum := seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 3)
+ parklife := seedHeld(t, q, "Parklife", "rel-parklife", "", 2)
+ _ = seedHeld(t, q, "Bootleg", "rel-bootleg", "", 1)
+ // An album with a track gone: held from before the loss, so
+ // re-acquisition still fetches it back.
+ lost := seedHeld(t, q, "13", "rel-13", "rg-13", 2)
+ if _, err := pool.Exec(ctx, `UPDATE tracks SET missing_since = now() - interval '1 day'
+ WHERE album_id = $1 AND track_number = 1`, lost.ID); err != nil {
+ t.Fatal(err)
+ }
+ startListing(t, pool)
+
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ for group, album := range map[string]pgtype.UUID{"rg-blur": blurAlbum.ID, "rg-parklife": parklife.ID, "rg-13": lost.ID} {
+ w, err := q.GetWantByReleaseGroup(ctx, group)
+ if err != nil {
+ t.Fatalf("%s not listed: %v", group, err)
+ }
+ if w.Reason != ReasonLibrary || !w.FulfilledAt.Valid || w.AlbumID != album {
+ t.Errorf("%s = %+v: a held library want about the album", group, w)
+ }
+ }
+ if w, _ := q.GetWantByReleaseGroup(ctx, "rg-13"); !w.FulfilledAt.Time.Before(time.Now().Add(-23 * time.Hour)) {
+ t.Errorf("an album missing a track is held from %v, not from before the loss", w.FulfilledAt.Time)
+ }
+ state, err := q.GetLibraryListing(ctx)
+ if err != nil || !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
+ t.Fatalf("listing = %+v %v: done, with the bootleg unlisted", state, err)
+ }
+
+ // Off the list stays off; an album added later is never listed.
+ w, _ := q.GetWantByReleaseGroup(ctx, "rg-blur")
+ if _, err := s.RemoveWant(ctx, w.ID); err != nil {
+ t.Fatal(err)
+ }
+ _ = seedHeld(t, q, "Think Tank", "rel-think", "rg-think", 2)
+ if _, err := s.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ for _, group := range []string{"rg-blur", "rg-think"} {
+ if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
+ t.Errorf("%s is on the list", group)
+ }
+ }
+}
+
+// While MusicBrainz is switched off nothing can be named, so the listing
+// finishes with what it has rather than waiting for ever.
+func TestListLibrary_MusicBrainzOffFinishesWithWhatIsNamed_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ s := newService(t, pool, newFakeMB(t))
+ s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrSwitchedOff }
+ _ = seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 1)
+ _ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
+ startListing(t, pool)
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ state, _ := q.GetLibraryListing(ctx)
+ if !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
+ t.Errorf("listing = %+v", state)
+ }
+ var named *string
+ if err := pool.QueryRow(ctx, `SELECT release_group_mbid FROM albums WHERE title = 'Parklife'`).Scan(&named); err != nil {
+ t.Fatal(err)
+ }
+ if named != nil {
+ t.Errorf("an album was marked unnameable while MusicBrainz was off: %q", *named)
+ }
+}
+
+// A MusicBrainz that does not answer pauses the naming; the next pass goes on.
+func TestListLibrary_UnreachablePausesTheNaming_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ s := newService(t, pool, newFakeMB(t))
+ down := true
+ s.releaseGroup = func(context.Context, string) (string, error) {
+ if down {
+ return "", tags.ErrTransient
+ }
+ return "rg-parklife", nil
+ }
+ _ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
+ startListing(t, pool)
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ if state, _ := q.GetLibraryListing(ctx); state.LibraryListedAt.Valid {
+ t.Fatal("finished while an album was still to be named")
+ }
+ down = false
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := q.GetWantByReleaseGroup(ctx, "rg-parklife"); err != nil {
+ t.Errorf("not listed once named: %v", err)
+ }
+ if state, _ := q.GetLibraryListing(ctx); !state.LibraryListedAt.Valid {
+ t.Error("not finished")
+ }
+}
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index 738e16e6..fe1e4953 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -569,7 +569,8 @@ export type ResolvedDuplicatesResponse = {
// Wanted list (M502 #5520) ------------------------------------------------
-export type WantReason = 'request' | 'follow' | 'missing';
+// library: in the library when the list began (#5567).
+export type WantReason = 'request' | 'follow' | 'missing' | 'library';
export type WantState = 'wanted' | 'missing' | 'held';
// One release group on the wanted list. held_at is set once the library
@@ -811,7 +812,12 @@ export type ReleaseBlock = {
export type ReleaseBlocksPage = { items: ReleaseBlock[]; limit: number; offset: number };
-export type WantsPage = Page & { counts: WantCounts };
+// The one-time listing of the library's albums as held (#5567): when it
+// finished (null while it runs), and how many albums nothing names to
+// MusicBrainz, so they could not be listed.
+export type LibraryListing = { listed_at: string | null; unlisted: number };
+
+export type WantsPage = Page & { counts: WantCounts; library: LibraryListing };
export type Follow = {
artist_mbid: string;
diff --git a/web/src/lib/components/wanted/WantList.svelte b/web/src/lib/components/wanted/WantList.svelte
index e996ff48..ee953bb5 100644
--- a/web/src/lib/components/wanted/WantList.svelte
+++ b/web/src/lib/components/wanted/WantList.svelte
@@ -13,7 +13,9 @@
// One tab of the Wanted view (M502 #5520–#5522): the albums still wanted,
// the missing albums wanted back, or the ones held. Each open want says
// what its last search found and how its download is getting on; Releases
- // lists every candidate with its verdict, and Search runs one now.
+ // lists every candidate with its verdict, and Search runs one now. The
+ // library's albums were put on the list as held once (#5567); taking one
+ // off leaves its files and stops it being fetched back if they go.
let { list }: { list: WantState } = $props();
@@ -21,6 +23,7 @@
const wantsStore = $derived(createWantsQuery(list));
const wants = $derived($wantsStore);
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
+ const library = $derived(wants.data?.pages[0]?.library);
let busy = $state(null);
let searching = $state(null);
@@ -30,7 +33,11 @@
busy = w.id;
try {
await removeWant(w.id);
- pushToast(`${w.title} is off the wanted list.`);
+ pushToast(
+ w.held_at
+ ? `${w.title} is off the list. Its files stay, and it won't be fetched back if they go missing.`
+ : `${w.title} is off the wanted list.`
+ );
await client.invalidateQueries({ queryKey: ['adminWants'] });
} catch (e) {
pushToast(errMessage(e), 'error');
@@ -137,6 +144,7 @@
function why(w: Want): string {
if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested';
if (w.reason === 'follow') return 'New from a followed artist';
+ if (w.reason === 'library') return 'In your library when the list began';
return 'Its files went missing';
}
@@ -151,6 +159,22 @@
}
+{#if list === 'held' && library}
+ {#if !library.listed_at}
+
+ Your library's albums are being added to this list as held. Albums whose tags name only a
+ release are looked up on MusicBrainz first, about one a second.
+
+ {:else if library.unlisted > 0}
+
+ {library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
+ {library.unlisted === 1 ? "isn't" : "aren't"} on this list: nothing in {library.unlisted === 1 ? 'its' : 'their'}
+ tags names {library.unlisted === 1 ? 'it' : 'them'} to MusicBrainz. Those are never fetched back if
+ their files go missing.
+
+ {/if}
+{/if}
+
{#if wants.isPending}
Reading the wanted list…
{:else if wants.isError && !wants.isFetchNextPageError}
@@ -216,6 +240,17 @@
{arrived(w)}
{/if}
+ {#if w.held_at}
+
onRemove(w)}
+ disabled={busy === w.id}
+ aria-label="Take {w.title} off the list"
+ class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
+ >
+ Remove
+
+ {/if}
{#if !w.held_at}
({
+vi.mock("#lib/api/wanted.js", () => ({
createWantsQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
- outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
+ outcome: "nothing",
+ considered: 0,
+ best_release: "",
+ best_verdict: "",
+ narrowed: [],
}),
createCandidatesQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
- unblockRelease: vi.fn()
+ unblockRelease: vi.fn(),
}));
-vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
+vi.mock("#lib/stores/toast.svelte.js", () => ({ pushToast: vi.fn() }));
-import WantList from './WantList.svelte';
-import { pushToast } from '#lib/stores/toast.svelte.js';
-import { createCandidatesQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
-import type { WantState } from '#lib/api/types.js';
+import WantList from "./WantList.svelte";
+import { pushToast } from "#lib/stores/toast.svelte.js";
+import {
+ createCandidatesQuery,
+ createWantsQuery,
+ removeWant,
+ searchWant,
+} from "#lib/api/wanted.js";
+import type { WantState } from "#lib/api/types.js";
const DAY = 24 * 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
@@ -28,172 +42,305 @@ function want(id: string, extra: Partial = {}): Want {
return {
id,
release_group_mbid: `rg-${id}`,
- title: 'The Ballad of Darren',
- artist_mbid: 'blur',
- artist_name: 'Blur',
- artist_id: 'art-1',
- primary_type: 'Album',
+ title: "The Ballad of Darren",
+ artist_mbid: "blur",
+ artist_name: "Blur",
+ artist_id: "art-1",
+ primary_type: "Album",
secondary_types: [],
- year: '2023',
- reason: 'follow',
+ year: "2023",
+ reason: "follow",
wanted_at: ago(2 * DAY),
held_at: null,
- image_url: '',
- ...extra
+ image_url: "",
+ ...extra,
};
}
-function page(items: Want[]): WantsPage {
+function page(
+ items: Want[],
+ library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 },
+): WantsPage {
return {
items,
total: items.length,
limit: 50,
offset: 0,
- counts: { wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 }
+ counts: {
+ wanted: 2,
+ missing: 1,
+ held: 4,
+ following: 1,
+ activity: 1,
+ attention: 0,
+ blocked: 0,
+ },
+ library,
};
}
function grab(extra: Partial = {}): WantGrab {
return {
- id: 'g1',
- name: 'Blur - Blur (1997) [FLAC]',
+ id: "g1",
+ name: "Blur - Blur (1997) [FLAC]",
grabbed_at: ago(60_000),
- origin: 'schedule',
- quality: 'lossless',
+ origin: "schedule",
+ quality: "lossless",
live: true,
- end_reason: '',
+ end_reason: "",
progress: 0,
- import_state: '',
- import_reason: '',
- end_detail: '',
+ import_state: "",
+ import_reason: "",
+ end_detail: "",
imported_at: null,
imported_files: 0,
- ...extra
+ ...extra,
};
}
afterEach(() => vi.clearAllMocks());
function setup(
- items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })],
- state: WantState = 'wanted'
+ items: Want[] = [
+ want("w1"),
+ want("w2", { reason: "request", requested_by: "sam", title: "Parklife" }),
+ ],
+ state: WantState = "wanted",
+ library?: LibraryListing,
) {
vi.mocked(createWantsQuery).mockReturnValue(
- mockInfiniteQuery({ pages: [page(items)] }) as unknown as ReturnType
+ mockInfiniteQuery({
+ pages: [page(items, library)],
+ }) as unknown as ReturnType,
);
vi.mocked(createCandidatesQuery).mockReturnValue(
- mockQuery({ data: [] }) as unknown as ReturnType
+ mockQuery({ data: [] }) as unknown as ReturnType<
+ typeof createCandidatesQuery
+ >,
);
return render(WantList, { list: state });
}
-describe('WantList', () => {
- test('asks for its own state', () => {
- setup([], 'missing');
- expect(createWantsQuery).toHaveBeenCalledWith('missing');
+describe("WantList", () => {
+ test("asks for its own state", () => {
+ setup([], "missing");
+ expect(createWantsQuery).toHaveBeenCalledWith("missing");
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
});
- test('a want says why it is wanted', () => {
+ test("a want says why it is wanted", () => {
setup();
- const why = screen.getAllByTestId('want-why').map((e) => e.textContent ?? '');
+ const why = screen
+ .getAllByTestId("want-why")
+ .map((e) => e.textContent ?? "");
expect(why[0]).toMatch(/new from a followed artist/i);
expect(why[1]).toMatch(/requested by sam/i);
});
- test('remove takes a want off the list', async () => {
+ test("remove takes a want off the list", async () => {
setup();
- await fireEvent.click(screen.getByRole('button', { name: /stop wanting parklife/i }));
- await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w2'));
+ await fireEvent.click(
+ screen.getByRole("button", { name: /stop wanting parklife/i }),
+ );
+ await waitFor(() => expect(removeWant).toHaveBeenCalledWith("w2"));
});
- test('a held want has no remove button', () => {
- setup([want('w1', { held_at: ago(DAY), album_id: 'al-1' })]);
- expect(screen.queryByRole('button', { name: /stop wanting/i })).toBeNull();
- expect(screen.getByRole('link', { name: /the ballad of darren/i }).getAttribute('href')).toBe('/albums/al-1');
+ test("a held album can be taken off the list, and is not searched for", async () => {
+ setup(
+ [want("w1", { held_at: ago(DAY), album_id: "al-1", reason: "library" })],
+ "held",
+ );
+ expect(
+ screen.queryByRole("button", { name: /stop wanting|search for/i }),
+ ).toBeNull();
+ expect(
+ screen
+ .getByRole("link", { name: /the ballad of darren/i })
+ .getAttribute("href"),
+ ).toBe("/albums/al-1");
+ expect(screen.getByTestId("want-why").textContent).toMatch(
+ /in your library when the list began/i,
+ );
+ await fireEvent.click(
+ screen.getByRole("button", {
+ name: /take the ballad of darren off the list/i,
+ }),
+ );
+ await waitFor(() => expect(removeWant).toHaveBeenCalledWith("w1"));
});
- test('an empty list explains what lands there', () => {
+ test("the held tab says while the library is being listed, and what could not be", () => {
+ setup([], "held", { listed_at: null, unlisted: 0 });
+ expect(screen.getByTestId("library-listing").textContent).toMatch(
+ /being added to this list/i,
+ );
+ });
+
+ test("the held tab names how many albums nothing names to MusicBrainz", () => {
+ setup([want("w1", { held_at: ago(DAY) })], "held", {
+ listed_at: ago(DAY),
+ unlisted: 12,
+ });
+ expect(screen.getByTestId("library-listing").textContent).toMatch(
+ /12 albums in your library\s+aren't on this list/i,
+ );
+ });
+
+ test("an empty list explains what lands there", () => {
setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
});
- test('each open want says what its last search found', () => {
+ test("each open want says what its last search found", () => {
setup([
- want('w1'),
- want('w2', {
- title: 'Parklife',
- search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 4, best_release: 'x', best_verdict: 'rejected', error: '' }
+ want("w1"),
+ want("w2", {
+ title: "Parklife",
+ search: {
+ searched_at: ago(3_600_000),
+ outcome: "nothing",
+ considered: 4,
+ best_release: "x",
+ best_verdict: "rejected",
+ error: "",
+ },
+ }),
+ want("w3", { title: "Blur", grab: grab() }),
+ want("w4", {
+ title: "13",
+ grab: grab({ import_state: "downloading", progress: 0.42 }),
}),
- want('w3', { title: 'Blur', grab: grab() }),
- want('w4', { title: '13', grab: grab({ import_state: 'downloading', progress: 0.42 }) })
]);
- const lines = screen.getAllByTestId('want-search').map((e) => e.textContent ?? '');
+ const lines = screen
+ .getAllByTestId("want-search")
+ .map((e) => e.textContent ?? "");
expect(lines[0]).toMatch(/not searched yet/i);
expect(lines[1]).toMatch(/4 found, none of them this album/i);
- expect(lines[2]).toMatch(/downloading blur - blur \(1997\) \[flac\], taken by the schedule/i);
+ expect(lines[2]).toMatch(
+ /downloading blur - blur \(1997\) \[flac\], taken by the schedule/i,
+ );
expect(lines[3]).toMatch(/downloading 42%/i);
});
- test('a download waiting on a person says why and points to Activity', () => {
+ test("a download waiting on a person says why and points to Activity", () => {
setup([
- want('w1', { grab: grab({ import_state: 'review', import_reason: 'Only the lengths agree. A person decides.' }) })
+ want("w1", {
+ grab: grab({
+ import_state: "review",
+ import_reason: "Only the lengths agree. A person decides.",
+ }),
+ }),
]);
- const line = screen.getByTestId('want-search');
+ const line = screen.getByTestId("want-search");
expect(line.textContent).toMatch(/needs you: only the lengths agree/i);
- expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/wanted/activity');
+ expect(
+ screen
+ .getByRole("link", { name: /decide on activity/i })
+ .getAttribute("href"),
+ ).toBe("/wanted/activity");
// A live download can't be searched for again.
- expect((screen.getByRole('button', { name: /search for the ballad of darren now/i }) as HTMLButtonElement).disabled).toBe(true);
+ expect(
+ (
+ screen.getByRole("button", {
+ name: /search for the ballad of darren now/i,
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
});
- test('a refused download is named beside the search that follows it', () => {
+ test("a refused download is named beside the search that follows it", () => {
setup([
- want('w1', {
- search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', error: '' },
- grab: grab({ live: false, end_reason: 'refused', import_state: 'refused', import_reason: 'Another album.' })
- })
+ want("w1", {
+ search: {
+ searched_at: ago(3_600_000),
+ outcome: "nothing",
+ considered: 0,
+ best_release: "",
+ best_verdict: "",
+ error: "",
+ },
+ grab: grab({
+ live: false,
+ end_reason: "refused",
+ import_state: "refused",
+ import_reason: "Another album.",
+ }),
+ }),
]);
- expect(screen.getByTestId('want-search').textContent).toMatch(/last download refused: another album/i);
+ expect(screen.getByTestId("want-search").textContent).toMatch(
+ /last download refused: another album/i,
+ );
});
- test('a download given up on is named beside the search that follows it', () => {
+ test("a download given up on is named beside the search that follows it", () => {
setup([
- want('w1', {
- search: { searched_at: ago(3_600_000), outcome: 'waiting', considered: 4, best_release: '', best_verdict: '', error: '' },
- grab: grab({ live: false, end_reason: 'stalled', import_state: 'downloading', end_detail: 'Nobody was sharing it.' })
- })
+ want("w1", {
+ search: {
+ searched_at: ago(3_600_000),
+ outcome: "waiting",
+ considered: 4,
+ best_release: "",
+ best_verdict: "",
+ error: "",
+ },
+ grab: grab({
+ live: false,
+ end_reason: "stalled",
+ import_state: "downloading",
+ end_detail: "Nobody was sharing it.",
+ }),
+ }),
]);
- expect(screen.getByTestId('want-search').textContent).toMatch(/last download given up: nobody was sharing it/i);
+ expect(screen.getByTestId("want-search").textContent).toMatch(
+ /last download given up: nobody was sharing it/i,
+ );
});
- test('a held album says how it arrived', () => {
+ test("a held album says how it arrived", () => {
setup(
[
- want('w1', {
+ want("w1", {
held_at: ago(DAY),
- album_id: 'al-1',
- grab: grab({ live: false, end_reason: 'imported', import_state: 'imported', imported_at: ago(DAY), imported_files: 11 })
- })
+ album_id: "al-1",
+ grab: grab({
+ live: false,
+ end_reason: "imported",
+ import_state: "imported",
+ imported_at: ago(DAY),
+ imported_files: 11,
+ }),
+ }),
],
- 'held'
+ "held",
+ );
+ expect(screen.getByTestId("want-arrived").textContent).toMatch(
+ /imported 11 tracks .* from blur - blur/i,
);
- expect(screen.getByTestId('want-arrived').textContent).toMatch(/imported 11 tracks .* from blur - blur/i);
});
- test('Search runs one now and says what came of it', async () => {
+ test("Search runs one now and says what came of it", async () => {
setup();
- await fireEvent.click(screen.getByRole('button', { name: /search for parklife now/i }));
- await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w2'));
- await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Parklife.', 'info'));
+ await fireEvent.click(
+ screen.getByRole("button", { name: /search for parklife now/i }),
+ );
+ await waitFor(() => expect(searchWant).toHaveBeenCalledWith("w2"));
+ await waitFor(() =>
+ expect(pushToast).toHaveBeenCalledWith(
+ "Nothing found for Parklife.",
+ "info",
+ ),
+ );
});
- test('Releases opens what the last search found', async () => {
+ test("Releases opens what the last search found", async () => {
setup();
- expect(screen.queryByTestId('want-candidates')).toBeNull();
- const toggle = screen.getByRole('button', { name: /releases found for parklife/i });
+ expect(screen.queryByTestId("want-candidates")).toBeNull();
+ const toggle = screen.getByRole("button", {
+ name: /releases found for parklife/i,
+ });
await fireEvent.click(toggle);
- expect(toggle.getAttribute('aria-expanded')).toBe('true');
- expect(screen.getByTestId('want-candidates')).toBeTruthy();
- expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
+ expect(screen.getByTestId("want-candidates")).toBeTruthy();
+ expect(createCandidatesQuery).toHaveBeenCalledWith("w2");
});
});
diff --git a/web/src/routes/wanted/wanted.test.ts b/web/src/routes/wanted/wanted.test.ts
index 9787f91b..547b9c1a 100644
--- a/web/src/routes/wanted/wanted.test.ts
+++ b/web/src/routes/wanted/wanted.test.ts
@@ -1,61 +1,94 @@
-import { afterEach, describe, expect, test, vi } from 'vitest';
-import { render, screen } from '@testing-library/svelte';
-import { createRawSnippet } from 'svelte';
-import { mockInfiniteQuery } from '../../test-utils/query';
-import { pageUrlModule } from '#test-utils/mocks/appState.js';
-import type { WantCounts, WantsPage } from '#lib/api/types.js';
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { render, screen } from "@testing-library/svelte";
+import { createRawSnippet } from "svelte";
+import { mockInfiniteQuery } from "../../test-utils/query";
+import { pageUrlModule } from "#test-utils/mocks/appState.js";
+import type { WantCounts, WantsPage } from "#lib/api/types.js";
-const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/wanted') }));
-vi.mock('$app/state', () => pageUrlModule(state));
-vi.mock('#lib/api/wanted.js', () => ({ createWantsQuery: vi.fn() }));
+const state = vi.hoisted(() => ({
+ pageUrl: new URL("http://localhost/wanted"),
+}));
+vi.mock("$app/state", () => pageUrlModule(state));
+vi.mock("#lib/api/wanted.js", () => ({ createWantsQuery: vi.fn() }));
-import WantedLayout from './+layout.svelte';
-import { createWantsQuery } from '#lib/api/wanted.js';
+import WantedLayout from "./+layout.svelte";
+import { createWantsQuery } from "#lib/api/wanted.js";
-const counts: WantCounts = { wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
-const children = createRawSnippet(() => ({ render: () => `body
` }));
+const counts: WantCounts = {
+ wanted: 5,
+ missing: 1,
+ held: 9,
+ following: 2,
+ activity: 3,
+ attention: 0,
+ blocked: 4,
+};
+const children = createRawSnippet(() => ({
+ render: () => `body
`,
+}));
afterEach(() => vi.clearAllMocks());
-function setup(c: WantCounts = counts, path = '/wanted') {
+function setup(c: WantCounts = counts, path = "/wanted") {
state.pageUrl = new URL(`http://localhost${path}`);
- const page: WantsPage = { items: [], total: 0, limit: 50, offset: 0, counts: c };
+ const page: WantsPage = {
+ items: [],
+ total: 0,
+ limit: 50,
+ offset: 0,
+ counts: c,
+ library: { listed_at: null, unlisted: 0 },
+ };
vi.mocked(createWantsQuery).mockReturnValue(
- mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType
+ mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<
+ typeof createWantsQuery
+ >,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return render(WantedLayout, { children } as any);
}
-describe('/wanted layout', () => {
- test('tabs are links carrying their counts, the current one marked', () => {
- setup(counts, '/wanted/activity');
- const nav = screen.getByRole('navigation', { name: /wanted sections/i });
- const links = Array.from(nav.querySelectorAll('a')).map((a) => [a.getAttribute('href'), a.textContent?.replace(/\s+/g, ' ').trim()]);
- expect(links).toEqual([
- ['/wanted', 'Wanted 5'],
- ['/wanted/activity', 'Activity 3'],
- ['/wanted/missing', 'Missing files 1'],
- ['/wanted/held', 'Held 9'],
- ['/wanted/following', 'Following 2'],
- ['/wanted/blocked', 'Blocked 4']
+describe("/wanted layout", () => {
+ test("tabs are links carrying their counts, the current one marked", () => {
+ setup(counts, "/wanted/activity");
+ const nav = screen.getByRole("navigation", { name: /wanted sections/i });
+ const links = Array.from(nav.querySelectorAll("a")).map((a) => [
+ a.getAttribute("href"),
+ a.textContent?.replace(/\s+/g, " ").trim(),
]);
- expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
- expect(screen.getByRole('link', { name: /^wanted/i })).not.toHaveAttribute('aria-current');
- expect(screen.getByTestId('tab-body')).toBeTruthy();
+ expect(links).toEqual([
+ ["/wanted", "Wanted 5"],
+ ["/wanted/activity", "Activity 3"],
+ ["/wanted/missing", "Missing files 1"],
+ ["/wanted/held", "Held 9"],
+ ["/wanted/following", "Following 2"],
+ ["/wanted/blocked", "Blocked 4"],
+ ]);
+ expect(screen.getByRole("link", { name: /activity/i })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.getByRole("link", { name: /^wanted/i })).not.toHaveAttribute(
+ "aria-current",
+ );
+ expect(screen.getByTestId("tab-body")).toBeTruthy();
});
- test('when nothing needs a person the verdict says so', () => {
+ test("when nothing needs a person the verdict says so", () => {
setup();
- expect(screen.getByTestId('wanted-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
- expect(screen.queryByTestId('tab-attention')).toBeNull();
+ expect(screen.getByTestId("wanted-verdict").textContent).toMatch(
+ /5 albums wanted, 3 downloading\. Nothing needs you\./,
+ );
+ expect(screen.queryByTestId("tab-attention")).toBeNull();
});
- test('downloads waiting on a person lead the page and mark Activity', () => {
+ test("downloads waiting on a person lead the page and mark Activity", () => {
setup({ ...counts, attention: 2 });
- const verdict = screen.getByTestId('wanted-verdict');
+ const verdict = screen.getByTestId("wanted-verdict");
expect(verdict.textContent).toMatch(/2 downloads need you/);
- expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/wanted/activity');
- expect(screen.getByTestId('tab-attention').textContent).toBe('2');
+ expect(
+ screen.getByRole("link", { name: /see activity/i }).getAttribute("href"),
+ ).toBe("/wanted/activity");
+ expect(screen.getByTestId("tab-attention").textContent).toBe("2");
});
});
--
2.54.0
From b887ab759bf7a1032654a0e6d18bb60039bfed0c Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 17:16:05 -0400
Subject: [PATCH 019/117] fix: every library album is matched to MusicBrainz
before it is listed, by its tags, its artist's discography or a search, and
checked against its tracks (M502 #5569)
The one-time listing took only albums whose tags already named their
release group and artist, and left 340 off the list. The rest are now
matched: the release their tags name, else the artist's release groups
called what the album is called, else a MusicBrainz search by artist and
title. A group found by name is taken only when its tracks agree with the
album's (by disc and number, then title; length and title, or recording),
so a partial copy still matches and a same-titled other album does not.
library_album_matches records each album the listing decided, listed or
unmatched with why, so an album taken off the list is never re-listed. The
albums no match was found for are named on the Held tab. Album rows with
no tracks are no longer counted.
The length and title checks move from the importer to release, shared by
both.
Co-Authored-By: Claude Opus 5.5
---
internal/api/admin_wanted.go | 32 +-
internal/db/dbq/models.go | 9 +
internal/db/dbq/wanted.sql.go | 285 +++++++++++---
.../0086_library_album_matches.down.sql | 1 +
.../0086_library_album_matches.up.sql | 47 +++
internal/db/queries/wanted.sql | 82 +++-
internal/dbtest/reset.go | 1 +
internal/importer/verify.go | 38 +-
internal/importer/verify_test.go | 16 -
internal/musicbrainz/client_test.go | 21 +
internal/musicbrainz/entities.go | 19 +
internal/release/agree.go | 40 ++
internal/release/agree_test.go | 19 +
internal/wanted/library.go | 108 ++++--
internal/wanted/library_test.go | 262 +++++++++++++
internal/wanted/match.go | 359 ++++++++++++++++++
internal/wanted/wanted_test.go | 140 +------
web/src/lib/api/types.ts | 4 +-
web/src/lib/components/wanted/WantList.svelte | 52 ++-
.../lib/components/wanted/WantList.test.ts | 344 ++++++-----------
web/src/routes/wanted/wanted.test.ts | 100 ++---
21 files changed, 1378 insertions(+), 601 deletions(-)
create mode 100644 internal/db/migrations/0086_library_album_matches.down.sql
create mode 100644 internal/db/migrations/0086_library_album_matches.up.sql
create mode 100644 internal/release/agree.go
create mode 100644 internal/release/agree_test.go
create mode 100644 internal/wanted/library_test.go
create mode 100644 internal/wanted/match.go
diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go
index b6e015a1..7ed0b292 100644
--- a/internal/api/admin_wanted.go
+++ b/internal/api/admin_wanted.go
@@ -91,13 +91,24 @@ type wantsPage struct {
}
// libraryListingView: whether the library's albums are all on the list
-// yet, and how many could not be listed (nothing names them to
-// MusicBrainz).
+// yet, and which could not be matched to MusicBrainz (#5569). Unmatched is
+// filled on the first page of the held list only.
type libraryListingView struct {
- ListedAt *string `json:"listed_at"`
- Unlisted int32 `json:"unlisted"`
+ ListedAt *string `json:"listed_at"`
+ Unlisted int32 `json:"unlisted"`
+ Unmatched []unmatchedAlbumView `json:"unmatched"`
}
+type unmatchedAlbumView struct {
+ AlbumID string `json:"album_id"`
+ Title string `json:"title"`
+ Artist string `json:"artist"`
+ Detail string `json:"detail"`
+}
+
+// unmatchedShown is how many unmatched albums the held list names.
+const unmatchedShown = 200
+
const wantsPageSize = 50
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&offset=.
@@ -137,10 +148,23 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
}
+ out.Library.Unmatched = []unmatchedAlbumView{}
if listing.LibraryListedAt.Valid {
at := formatTimestamp(listing.LibraryListedAt)
out.Library.ListedAt = &at
}
+ if state == "held" && offset == 0 && listing.LibraryUnlisted > 0 {
+ unmatched, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
+ if err != nil {
+ writeErrWithLog(w, h.logger, "admin wanted: unmatched albums", apierror.Internal(err))
+ return
+ }
+ for _, u := range unmatched {
+ out.Library.Unmatched = append(out.Library.Unmatched, unmatchedAlbumView{
+ AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
+ })
+ }
+ }
switch state {
case "missing":
out.Total = counts.Missing
diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go
index d094ae0a..883db4df 100644
--- a/internal/db/dbq/models.go
+++ b/internal/db/dbq/models.go
@@ -487,6 +487,15 @@ type Grab struct {
SeedReleasedAt pgtype.Timestamptz
}
+type LibraryAlbumMatch struct {
+ AlbumID pgtype.UUID
+ Outcome string
+ Method string
+ ReleaseGroupMbid string
+ Detail string
+ DecidedAt pgtype.Timestamptz
+}
+
type LibraryChange struct {
ID int64
EntityType string
diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go
index dacf5e07..fbe40c14 100644
--- a/internal/db/dbq/wanted.sql.go
+++ b/internal/db/dbq/wanted.sql.go
@@ -125,15 +125,10 @@ func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error)
}
const finishLibraryListing = `-- name: FinishLibraryListing :one
-UPDATE acquisition_settings s
+UPDATE acquisition_settings
SET library_listed_at = now(),
- library_unlisted = (
- SELECT COUNT(*) FROM albums a
- WHERE a.created_at <= s.library_listing_started_at
- AND NOT EXISTS (SELECT 1 FROM wants w
- WHERE w.album_id = a.id
- OR (a.release_group_mbid <> '' AND w.release_group_mbid = a.release_group_mbid)))
- WHERE s.id = true AND s.library_listed_at IS NULL
+ library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
+ WHERE id = true AND library_listed_at IS NULL
RETURNING library_listed_at, library_unlisted
`
@@ -142,8 +137,7 @@ type FinishLibraryListingRow struct {
LibraryUnlisted int32
}
-// The listing is done: count the albums from before the cutoff it could not
-// list, because nothing names them to MusicBrainz.
+// The listing is done: count the albums no match was found for.
func (q *Queries) FinishLibraryListing(ctx context.Context) (FinishLibraryListingRow, error) {
row := q.db.QueryRow(ctx, finishLibraryListing)
var i FinishLibraryListingRow
@@ -297,6 +291,44 @@ func (q *Queries) GetWantByReleaseGroup(ctx context.Context, releaseGroupMbid st
return i, err
}
+const insertLibraryWant = `-- name: InsertLibraryWant :execrows
+INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date,
+ reason, album_id, wanted_at, fulfilled_at)
+VALUES ($1, $2, $3, $4,
+ $5, $6, 'library', $7::uuid, now(),
+ COALESCE((SELECT MIN(t.missing_since) FROM tracks t WHERE t.album_id = $7::uuid)
+ - interval '1 second', now()))
+ON CONFLICT (release_group_mbid) DO NOTHING
+`
+
+type InsertLibraryWantParams struct {
+ ReleaseGroupMbid string
+ ArtistMbid string
+ ArtistName string
+ Title string
+ PrimaryType string
+ FirstReleaseDate string
+ AlbumID pgtype.UUID
+}
+
+// Puts one matched album on the list as held, from just before its first
+// lost track if it has one. A group already on the list is left as it is.
+func (q *Queries) InsertLibraryWant(ctx context.Context, arg InsertLibraryWantParams) (int64, error) {
+ result, err := q.db.Exec(ctx, insertLibraryWant,
+ arg.ReleaseGroupMbid,
+ arg.ArtistMbid,
+ arg.ArtistName,
+ arg.Title,
+ arg.PrimaryType,
+ arg.FirstReleaseDate,
+ arg.AlbumID,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
const libraryHoldsReleaseGroup = `-- name: LibraryHoldsReleaseGroup :one
SELECT EXISTS (
SELECT 1 FROM albums a
@@ -325,6 +357,103 @@ func (q *Queries) LibraryHoldsReleaseGroup(ctx context.Context, arg LibraryHolds
return exists, err
}
+const listAlbumTracksToMatch = `-- name: ListAlbumTracksToMatch :many
+SELECT title, disc_number, track_number, duration_ms, mbid
+ FROM tracks
+ WHERE album_id = $1
+ ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title
+`
+
+type ListAlbumTracksToMatchRow struct {
+ Title string
+ DiscNumber *int32
+ TrackNumber *int32
+ DurationMs int32
+ Mbid *string
+}
+
+// What an album's tracks say about themselves, to check a match against.
+func (q *Queries) ListAlbumTracksToMatch(ctx context.Context, albumID pgtype.UUID) ([]ListAlbumTracksToMatchRow, error) {
+ rows, err := q.db.Query(ctx, listAlbumTracksToMatch, albumID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListAlbumTracksToMatchRow
+ for rows.Next() {
+ var i ListAlbumTracksToMatchRow
+ if err := rows.Scan(
+ &i.Title,
+ &i.DiscNumber,
+ &i.TrackNumber,
+ &i.DurationMs,
+ &i.Mbid,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listAlbumsToMatch = `-- name: ListAlbumsToMatch :many
+SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
+ FROM albums a
+ JOIN artists ar ON ar.id = a.artist_id
+ WHERE a.created_at <= $1::timestamptz
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
+ AND NOT EXISTS (SELECT 1 FROM library_album_matches m WHERE m.album_id = a.id)
+ ORDER BY a.created_at, a.id
+ LIMIT $2
+`
+
+type ListAlbumsToMatchParams struct {
+ Cutoff pgtype.Timestamptz
+ MaxRows int32
+}
+
+type ListAlbumsToMatchRow struct {
+ ID pgtype.UUID
+ Title string
+ Mbid *string
+ ReleaseGroupMbid *string
+ ArtistName string
+ ArtistMbid *string
+}
+
+// Library albums from before the cutoff that the listing has not decided:
+// each is matched to MusicBrainz (#5569). An album row with no tracks is
+// not an album in the library.
+func (q *Queries) ListAlbumsToMatch(ctx context.Context, arg ListAlbumsToMatchParams) ([]ListAlbumsToMatchRow, error) {
+ rows, err := q.db.Query(ctx, listAlbumsToMatch, arg.Cutoff, arg.MaxRows)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListAlbumsToMatchRow
+ for rows.Next() {
+ var i ListAlbumsToMatchRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Title,
+ &i.Mbid,
+ &i.ReleaseGroupMbid,
+ &i.ArtistName,
+ &i.ArtistMbid,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const listFollowGroups = `-- name: ListFollowGroups :many
SELECT g.mbid, g.title, g.primary_type, g.secondary_types, g.first_release_date,
EXISTS (SELECT 1 FROM follow_seen_groups s
@@ -506,48 +635,6 @@ func (q *Queries) ListLibraryAlbums(ctx context.Context, arg ListLibraryAlbumsPa
return result.RowsAffected(), nil
}
-const listLibraryAlbumsToName = `-- name: ListLibraryAlbumsToName :many
-SELECT a.id, a.title, a.mbid FROM albums a
- WHERE a.release_group_mbid IS NULL AND a.mbid IS NOT NULL AND a.mbid <> ''
- AND a.created_at <= $1::timestamptz
- ORDER BY a.created_at, a.id
- LIMIT $2
-`
-
-type ListLibraryAlbumsToNameParams struct {
- Cutoff pgtype.Timestamptz
- MaxRows int32
-}
-
-type ListLibraryAlbumsToNameRow struct {
- ID pgtype.UUID
- Title string
- Mbid *string
-}
-
-// Library albums from before the cutoff that carry a release id but no
-// release group yet: the listing asks MusicBrainz for each. ” marks one
-// MusicBrainz could not name, so it is asked once.
-func (q *Queries) ListLibraryAlbumsToName(ctx context.Context, arg ListLibraryAlbumsToNameParams) ([]ListLibraryAlbumsToNameRow, error) {
- rows, err := q.db.Query(ctx, listLibraryAlbumsToName, arg.Cutoff, arg.MaxRows)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var items []ListLibraryAlbumsToNameRow
- for rows.Next() {
- var i ListLibraryAlbumsToNameRow
- if err := rows.Scan(&i.ID, &i.Title, &i.Mbid); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
const listOpenWants = `-- name: ListOpenWants :many
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants
WHERE fulfilled_at IS NULL
@@ -645,6 +732,49 @@ func (q *Queries) ListReleaseTypeProfiles(ctx context.Context) ([]ListReleaseTyp
return items, nil
}
+const listUnmatchedLibraryAlbums = `-- name: ListUnmatchedLibraryAlbums :many
+SELECT m.album_id, a.title, ar.name AS artist_name, m.detail
+ FROM library_album_matches m
+ JOIN albums a ON a.id = m.album_id
+ JOIN artists ar ON ar.id = a.artist_id
+ WHERE m.outcome = 'unmatched'
+ ORDER BY ar.name, a.title
+ LIMIT $1
+`
+
+type ListUnmatchedLibraryAlbumsRow struct {
+ AlbumID pgtype.UUID
+ Title string
+ ArtistName string
+ Detail string
+}
+
+// The albums the listing could not match, for the Held tab.
+func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32) ([]ListUnmatchedLibraryAlbumsRow, error) {
+ rows, err := q.db.Query(ctx, listUnmatchedLibraryAlbums, maxRows)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListUnmatchedLibraryAlbumsRow
+ for rows.Next() {
+ var i ListUnmatchedLibraryAlbumsRow
+ if err := rows.Scan(
+ &i.AlbumID,
+ &i.Title,
+ &i.ArtistName,
+ &i.Detail,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const listWants = `-- name: ListWants :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
@@ -806,6 +936,52 @@ func (q *Queries) MarkGroupSeen(ctx context.Context, arg MarkGroupSeenParams) er
return err
}
+const recordAlbumMatch = `-- name: RecordAlbumMatch :exec
+INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid, detail)
+VALUES ($1, $2, $3, $4, $5)
+ON CONFLICT (album_id) DO UPDATE
+ SET outcome = EXCLUDED.outcome, method = EXCLUDED.method,
+ release_group_mbid = EXCLUDED.release_group_mbid, detail = EXCLUDED.detail, decided_at = now()
+`
+
+type RecordAlbumMatchParams struct {
+ AlbumID pgtype.UUID
+ Outcome string
+ Method string
+ ReleaseGroupMbid string
+ Detail string
+}
+
+func (q *Queries) RecordAlbumMatch(ctx context.Context, arg RecordAlbumMatchParams) error {
+ _, err := q.db.Exec(ctx, recordAlbumMatch,
+ arg.AlbumID,
+ arg.Outcome,
+ arg.Method,
+ arg.ReleaseGroupMbid,
+ arg.Detail,
+ )
+ return err
+}
+
+const recordListedAlbums = `-- name: RecordListedAlbums :exec
+INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid)
+SELECT a.id, 'listed', 'tags', a.release_group_mbid
+ FROM albums a
+ JOIN artists ar ON ar.id = a.artist_id
+ WHERE a.release_group_mbid IS NOT NULL AND a.release_group_mbid <> ''
+ AND ar.mbid IS NOT NULL AND ar.mbid <> ''
+ AND a.created_at <= $1::timestamptz
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
+ON CONFLICT (album_id) DO NOTHING
+`
+
+// The albums ListLibraryAlbums takes are decided as listed by their tags,
+// so the matching that follows leaves them alone (#5569).
+func (q *Queries) RecordListedAlbums(ctx context.Context, cutoff pgtype.Timestamptz) error {
+ _, err := q.db.Exec(ctx, recordListedAlbums, cutoff)
+ return err
+}
+
const repointWant = `-- name: RepointWant :exec
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1
`
@@ -822,8 +998,8 @@ func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error
}
const setAlbumReleaseGroup = `-- name: SetAlbumReleaseGroup :exec
-UPDATE albums SET release_group_mbid = $1
- WHERE id = $2 AND release_group_mbid IS NULL
+UPDATE albums SET release_group_mbid = $1, updated_at = now()
+ WHERE id = $2 AND (release_group_mbid IS NULL OR release_group_mbid = '')
`
type SetAlbumReleaseGroupParams struct {
@@ -831,6 +1007,7 @@ type SetAlbumReleaseGroupParams struct {
ID pgtype.UUID
}
+// Names a matched album's release group, filling only what nothing named.
func (q *Queries) SetAlbumReleaseGroup(ctx context.Context, arg SetAlbumReleaseGroupParams) error {
_, err := q.db.Exec(ctx, setAlbumReleaseGroup, arg.ReleaseGroupMbid, arg.ID)
return err
diff --git a/internal/db/migrations/0086_library_album_matches.down.sql b/internal/db/migrations/0086_library_album_matches.down.sql
new file mode 100644
index 00000000..b61ff4fa
--- /dev/null
+++ b/internal/db/migrations/0086_library_album_matches.down.sql
@@ -0,0 +1 @@
+DROP TABLE library_album_matches;
diff --git a/internal/db/migrations/0086_library_album_matches.up.sql b/internal/db/migrations/0086_library_album_matches.up.sql
new file mode 100644
index 00000000..712850ee
--- /dev/null
+++ b/internal/db/migrations/0086_library_album_matches.up.sql
@@ -0,0 +1,47 @@
+-- Every library album is matched to MusicBrainz before it goes on the list
+-- (#5569, operator 2026-10-10: "they should all exist in music brainz we
+-- need to use their meta data or a search system to link them up"). The
+-- listing in 0085 took only albums whose tags already named their release
+-- group and artist; the rest are now matched by the release their tags
+-- name, the artist's discography, or a search, and checked against their
+-- own tracks.
+--
+-- library_album_matches one row per album the listing has decided:
+-- listed (with how it was matched) or unmatched
+-- (with why). It is the record that the listing
+-- saw the album, so an album taken off the list
+-- is never listed again.
+CREATE TABLE library_album_matches (
+ album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE,
+ outcome text NOT NULL
+ CONSTRAINT library_album_matches_outcome_check CHECK (outcome IN ('listed', 'unmatched')),
+ -- tags its tags named the release group.
+ -- release its tags named a release; MusicBrainz named the group.
+ -- discography its title is one of the artist's release groups.
+ -- search MusicBrainz's search found it by artist and title.
+ method text NOT NULL DEFAULT ''
+ CONSTRAINT library_album_matches_method_check CHECK (method IN ('', 'tags', 'release', 'discography', 'search')),
+ release_group_mbid text NOT NULL DEFAULT '',
+ detail text NOT NULL DEFAULT '',
+ decided_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX library_album_matches_unmatched_idx ON library_album_matches (decided_at) WHERE outcome = 'unmatched';
+
+-- The albums 0085's listing already took, by its own test, are decided:
+-- one taken off the list since stays off.
+INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid)
+SELECT a.id, 'listed', 'tags', a.release_group_mbid
+ FROM albums a
+ JOIN artists ar ON ar.id = a.artist_id
+ JOIN acquisition_settings s ON s.id = true
+ WHERE s.library_listing_started_at IS NOT NULL
+ AND a.created_at <= s.library_listing_started_at
+ AND a.release_group_mbid IS NOT NULL AND a.release_group_mbid <> ''
+ AND ar.mbid IS NOT NULL AND ar.mbid <> ''
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id);
+
+-- The rest are matched by the wanted worker after boot: the listing resumes.
+UPDATE acquisition_settings
+ SET library_listed_at = NULL, library_unlisted = 0
+ WHERE library_listing_started_at IS NOT NULL;
diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql
index 394b6d76..2cb967b1 100644
--- a/internal/db/queries/wanted.sql
+++ b/internal/db/queries/wanted.sql
@@ -258,30 +258,76 @@ SELECT DISTINCT ON (a.release_group_mbid)
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL) DESC, a.id
ON CONFLICT (release_group_mbid) DO NOTHING;
--- name: ListLibraryAlbumsToName :many
--- Library albums from before the cutoff that carry a release id but no
--- release group yet: the listing asks MusicBrainz for each. '' marks one
--- MusicBrainz could not name, so it is asked once.
-SELECT a.id, a.title, a.mbid FROM albums a
- WHERE a.release_group_mbid IS NULL AND a.mbid IS NOT NULL AND a.mbid <> ''
+-- name: RecordListedAlbums :exec
+-- The albums ListLibraryAlbums takes are decided as listed by their tags,
+-- so the matching that follows leaves them alone (#5569).
+INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid)
+SELECT a.id, 'listed', 'tags', a.release_group_mbid
+ FROM albums a
+ JOIN artists ar ON ar.id = a.artist_id
+ WHERE a.release_group_mbid IS NOT NULL AND a.release_group_mbid <> ''
+ AND ar.mbid IS NOT NULL AND ar.mbid <> ''
AND a.created_at <= sqlc.arg(cutoff)::timestamptz
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
+ON CONFLICT (album_id) DO NOTHING;
+
+-- name: ListAlbumsToMatch :many
+-- Library albums from before the cutoff that the listing has not decided:
+-- each is matched to MusicBrainz (#5569). An album row with no tracks is
+-- not an album in the library.
+SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
+ FROM albums a
+ JOIN artists ar ON ar.id = a.artist_id
+ WHERE a.created_at <= sqlc.arg(cutoff)::timestamptz
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
+ AND NOT EXISTS (SELECT 1 FROM library_album_matches m WHERE m.album_id = a.id)
ORDER BY a.created_at, a.id
LIMIT sqlc.arg(max_rows);
+-- name: ListAlbumTracksToMatch :many
+-- What an album's tracks say about themselves, to check a match against.
+SELECT title, disc_number, track_number, duration_ms, mbid
+ FROM tracks
+ WHERE album_id = $1
+ ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title;
+
-- name: SetAlbumReleaseGroup :exec
-UPDATE albums SET release_group_mbid = sqlc.arg(release_group_mbid)
- WHERE id = sqlc.arg(id) AND release_group_mbid IS NULL;
+-- Names a matched album's release group, filling only what nothing named.
+UPDATE albums SET release_group_mbid = sqlc.arg(release_group_mbid), updated_at = now()
+ WHERE id = sqlc.arg(id) AND (release_group_mbid IS NULL OR release_group_mbid = '');
+
+-- name: InsertLibraryWant :execrows
+-- Puts one matched album on the list as held, from just before its first
+-- lost track if it has one. A group already on the list is left as it is.
+INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date,
+ reason, album_id, wanted_at, fulfilled_at)
+VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(title),
+ sqlc.arg(primary_type), sqlc.arg(first_release_date), 'library', sqlc.arg(album_id)::uuid, now(),
+ COALESCE((SELECT MIN(t.missing_since) FROM tracks t WHERE t.album_id = sqlc.arg(album_id)::uuid)
+ - interval '1 second', now()))
+ON CONFLICT (release_group_mbid) DO NOTHING;
+
+-- name: RecordAlbumMatch :exec
+INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid, detail)
+VALUES (sqlc.arg(album_id), sqlc.arg(outcome), sqlc.arg(method), sqlc.arg(release_group_mbid), sqlc.arg(detail))
+ON CONFLICT (album_id) DO UPDATE
+ SET outcome = EXCLUDED.outcome, method = EXCLUDED.method,
+ release_group_mbid = EXCLUDED.release_group_mbid, detail = EXCLUDED.detail, decided_at = now();
-- name: FinishLibraryListing :one
--- The listing is done: count the albums from before the cutoff it could not
--- list, because nothing names them to MusicBrainz.
-UPDATE acquisition_settings s
+-- The listing is done: count the albums no match was found for.
+UPDATE acquisition_settings
SET library_listed_at = now(),
- library_unlisted = (
- SELECT COUNT(*) FROM albums a
- WHERE a.created_at <= s.library_listing_started_at
- AND NOT EXISTS (SELECT 1 FROM wants w
- WHERE w.album_id = a.id
- OR (a.release_group_mbid <> '' AND w.release_group_mbid = a.release_group_mbid)))
- WHERE s.id = true AND s.library_listed_at IS NULL
+ library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
+ WHERE id = true AND library_listed_at IS NULL
RETURNING library_listed_at, library_unlisted;
+
+-- name: ListUnmatchedLibraryAlbums :many
+-- The albums the listing could not match, for the Held tab.
+SELECT m.album_id, a.title, ar.name AS artist_name, m.detail
+ FROM library_album_matches m
+ JOIN albums a ON a.id = m.album_id
+ JOIN artists ar ON ar.id = a.artist_id
+ WHERE m.outcome = 'unmatched'
+ ORDER BY ar.name, a.title
+ LIMIT sqlc.arg(max_rows);
diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go
index 0a6cf757..ae8b602a 100644
--- a/internal/dbtest/reset.go
+++ b/internal/dbtest/reset.go
@@ -110,6 +110,7 @@ var dataTables = []string{
"mb_artists", // M502 #5519
"requests", // M502 #5520 (was lidarr_requests)
"download_interventions", // M502 #5523
+ "library_album_matches", // #5569
"release_blocks", // M502 #5521
"grabs", // M502 #5521
"want_candidates", // M502 #5521
diff --git a/internal/importer/verify.go b/internal/importer/verify.go
index e5721fc2..b33925e8 100644
--- a/internal/importer/verify.go
+++ b/internal/importer/verify.go
@@ -351,8 +351,8 @@ func pairUp(files []File, tracks []Track) []Pair {
File: order[i],
Track: i,
Recording: t.RecordingMBID != "" && (f.RecordingMBID == t.RecordingMBID || slices.Contains(f.Recordings, t.RecordingMBID)),
- Length: lengthFits(f.LengthMs, t.LengthMs),
- Title: titleAgrees(f.Title, t.Title),
+ Length: release.LengthFits(f.LengthMs, t.LengthMs),
+ Title: release.TitleAgrees(f.Title, t.Title),
}
}
return pairs
@@ -372,40 +372,6 @@ func numbered(files []File) bool {
return true
}
-// lengthFits allows five seconds or 3%, whichever is more: remasters and
-// rips differ by a second or two, a different recording by far more. A
-// track MusicBrainz has no length for says nothing against the file.
-func lengthFits(fileMs, trackMs int) bool {
- if trackMs <= 0 {
- return true
- }
- if fileMs <= 0 {
- return false
- }
- tol := max(5000, trackMs*3/100)
- d := fileMs - trackMs
- return d <= tol && -d <= tol
-}
-
-// titleAgrees: the same words, or one title holding all of the other's
-// ("Get Lucky (Radio Edit)" holds "Get Lucky").
-func titleAgrees(a, b string) bool {
- ta, tb := release.Tokens(a), release.Tokens(b)
- if len(ta) == 0 || len(tb) == 0 {
- return false
- }
- return subset(ta, tb) || subset(tb, ta)
-}
-
-func subset(small, big []string) bool {
- for _, w := range small {
- if !slices.Contains(big, w) {
- return false
- }
- }
- return true
-}
-
// score weighs recordings above lengths above titles: a fingerprint is the
// audio itself, a length is the audio's shape, a title is only a name.
func score(pairs []Pair) int {
diff --git a/internal/importer/verify_test.go b/internal/importer/verify_test.go
index 2a25d39f..94ec335f 100644
--- a/internal/importer/verify_test.go
+++ b/internal/importer/verify_test.go
@@ -161,22 +161,6 @@ func TestVerify_WithoutAcoustIDTheReasonSaysSo(t *testing.T) {
}
}
-func TestLengthFits(t *testing.T) {
- for _, c := range []struct {
- file, track int
- ok bool
- }{
- // Short tracks get the 5s floor; longer ones 3% of the track.
- {120000, 125000, true}, {120000, 126000, false},
- {240000, 246000, true}, {240000, 248000, false}, {600000, 615000, true}, {600000, 620000, false},
- {0, 240000, false}, {240000, 0, true},
- } {
- if got := lengthFits(c.file, c.track); got != c.ok {
- t.Errorf("lengthFits(%d, %d) = %v", c.file, c.track, got)
- }
- }
-}
-
func TestPlan_LidarrsNaming(t *testing.T) {
e := edition("rel-cd", 2)
e.Tracks[1].Title = "Within: Part 2/3?"
diff --git a/internal/musicbrainz/client_test.go b/internal/musicbrainz/client_test.go
index d645984f..ded2815f 100644
--- a/internal/musicbrainz/client_test.go
+++ b/internal/musicbrainz/client_test.go
@@ -58,6 +58,27 @@ func TestSearchReleaseGroups_AsksTitleAndArtistAndReadsCredits(t *testing.T) {
}
}
+func TestSearchReleaseGroupsBy_AsksForBothTitleAndArtist(t *testing.T) {
+ var query atomic.Value
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ query.Store(r.URL.Query().Get("query"))
+ _, _ = w.Write([]byte(`{"release-groups": [{"id": "rg1", "title": "Highway to Hell",
+ "artist-credit": [{"name": "AC/DC", "artist": {"id": "a1"}}]}]}`))
+ }))
+ defer srv.Close()
+
+ got, err := testClient(srv).SearchReleaseGroupsBy(context.Background(), " AC/DC ", "Highway to Hell", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if q := query.Load(); q != `releasegroup:(Highway to Hell) AND artist:(AC\/DC)` {
+ t.Errorf("query = %q", q)
+ }
+ if len(got) != 1 || got[0].MBID != "rg1" || got[0].ArtistMBIDs[0] != "a1" {
+ t.Errorf("got %+v", got)
+ }
+}
+
func TestSearchRecordings_FilesASongUnderItsAlbum(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"recordings": [{
diff --git a/internal/musicbrainz/entities.go b/internal/musicbrainz/entities.go
index 21a449a4..6b6e4e04 100644
--- a/internal/musicbrainz/entities.go
+++ b/internal/musicbrainz/entities.go
@@ -252,6 +252,25 @@ func (c *Client) SearchReleaseGroups(ctx context.Context, text string, limit int
return out, nil
}
+// SearchReleaseGroupsBy finds an artist's albums by title: both must
+// match, where SearchReleaseGroups takes either. It is for naming an album
+// already in hand, not for a person's search box.
+func (c *Client) SearchReleaseGroupsBy(ctx context.Context, artist, title string, limit int) ([]ReleaseGroup, error) {
+ var resp struct {
+ ReleaseGroups []wireReleaseGroup `json:"release-groups"`
+ }
+ query := "releasegroup:(" + Escape(strings.TrimSpace(title)) + ") AND artist:(" + Escape(strings.TrimSpace(artist)) + ")"
+ q := url.Values{"query": {query}, "limit": {searchLimit(limit)}}
+ if err := c.get(ctx, "/release-group", q, &resp); err != nil {
+ return nil, err
+ }
+ out := make([]ReleaseGroup, 0, len(resp.ReleaseGroups))
+ for _, g := range resp.ReleaseGroups {
+ out = append(out, g.group())
+ }
+ return out, nil
+}
+
// SearchRecordings finds songs by title or by artist.
func (c *Client) SearchRecordings(ctx context.Context, text string, limit int) ([]Recording, error) {
var resp struct {
diff --git a/internal/release/agree.go b/internal/release/agree.go
new file mode 100644
index 00000000..cd2b9822
--- /dev/null
+++ b/internal/release/agree.go
@@ -0,0 +1,40 @@
+package release
+
+import "slices"
+
+// Comparing a file against a MusicBrainz track: shared by the importer's
+// check of a download and the library listing's match of an album (#5569).
+
+// LengthFits allows five seconds or 3%, whichever is more: remasters and
+// rips differ by a second or two, a different recording by far more. A
+// track MusicBrainz has no length for says nothing against the file.
+func LengthFits(fileMs, trackMs int) bool {
+ if trackMs <= 0 {
+ return true
+ }
+ if fileMs <= 0 {
+ return false
+ }
+ tol := max(5000, trackMs*3/100)
+ d := fileMs - trackMs
+ return d <= tol && -d <= tol
+}
+
+// TitleAgrees: the same words, or one title holding all of the other's
+// ("Get Lucky (Radio Edit)" holds "Get Lucky").
+func TitleAgrees(a, b string) bool {
+ ta, tb := Tokens(a), Tokens(b)
+ if len(ta) == 0 || len(tb) == 0 {
+ return false
+ }
+ return subset(ta, tb) || subset(tb, ta)
+}
+
+func subset(small, big []string) bool {
+ for _, w := range small {
+ if !slices.Contains(big, w) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/release/agree_test.go b/internal/release/agree_test.go
new file mode 100644
index 00000000..50910fe7
--- /dev/null
+++ b/internal/release/agree_test.go
@@ -0,0 +1,19 @@
+package release
+
+import "testing"
+
+func TestLengthFits(t *testing.T) {
+ for _, c := range []struct {
+ file, track int
+ ok bool
+ }{
+ // Short tracks get the 5s floor; longer ones 3% of the track.
+ {120000, 125000, true}, {120000, 126000, false},
+ {240000, 246000, true}, {240000, 248000, false}, {600000, 615000, true}, {600000, 620000, false},
+ {0, 240000, false}, {240000, 0, true},
+ } {
+ if got := LengthFits(c.file, c.track); got != c.ok {
+ t.Errorf("LengthFits(%d, %d) = %v", c.file, c.track, got)
+ }
+ }
+}
diff --git a/internal/wanted/library.go b/internal/wanted/library.go
index cefadbc0..8642d846 100644
--- a/internal/wanted/library.go
+++ b/internal/wanted/library.go
@@ -4,19 +4,24 @@ import (
"context"
"errors"
"fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
- "git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// ReasonLibrary: on the list because the library held it when the list
// began (#5567).
const ReasonLibrary = "library"
-// libraryNamesPerPass bounds the MusicBrainz lookups one pass makes for
-// albums whose tags named a release but not its group: about two minutes at
-// MusicBrainz's one request a second, so the pass still settles its wants.
-const libraryNamesPerPass = 120
+// libraryMatchBudget bounds the time one pass spends matching albums to
+// MusicBrainz, at its one request a second, so the pass still settles its
+// wants; the rest wait for the next pass.
+const libraryMatchBudget = 2 * time.Minute
+
+// libraryMatchBatch is how many albums one query hands the matching.
+const libraryMatchBatch = 25
// listLibrary puts the library's albums on the wanted list as held, once
// (operator, 2026-10-10: "make a record saying that the import has
@@ -26,13 +31,15 @@ const libraryNamesPerPass = 120
// - the first step stamps when the listing began, which is its cutoff:
// an album the library gains afterwards, dropped in by hand, is never
// listed;
-// - every album whose release group is known goes on the list at once;
-// - albums that name a release but not its group are looked up on
-// MusicBrainz, a bounded number a pass, and listed as each is named;
-// - once none is left to name, the listing is stamped done, with a count
-// of the albums nothing names to MusicBrainz.
+// - every album whose tags name its release group and artist goes on the
+// list at once;
+// - every other album is matched to MusicBrainz (#5569), within a time
+// budget a pass, and listed as it is matched;
+// - once none is left to match, the listing is stamped done, with a count
+// of the albums no match was found for.
//
-// After that it never runs again, so an album taken off the list stays off.
+// Each album decided is recorded in library_album_matches, so an album taken
+// off the list stays off, and once done the listing never runs again.
func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
state, err := q.GetLibraryListing(ctx)
@@ -47,6 +54,9 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
if cutoff, err = q.StartLibraryListing(ctx); err != nil {
return fmt.Errorf("wanted: start the library listing: %w", err)
}
+ if err := q.RecordListedAlbums(ctx, cutoff); err != nil {
+ return fmt.Errorf("wanted: record the library: %w", err)
+ }
n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff})
if err != nil {
return fmt.Errorf("wanted: list the library: %w", err)
@@ -55,10 +65,11 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
s.logger.Info("wanted: listing the library's albums as held", "listed", n)
}
+ deadline := s.now().Add(libraryMatchBudget)
for {
- albums, err := q.ListLibraryAlbumsToName(ctx, dbq.ListLibraryAlbumsToNameParams{Cutoff: cutoff, MaxRows: libraryNamesPerPass})
+ albums, err := q.ListAlbumsToMatch(ctx, dbq.ListAlbumsToMatchParams{Cutoff: cutoff, MaxRows: libraryMatchBatch})
if err != nil {
- return fmt.Errorf("wanted: albums to name: %w", err)
+ return fmt.Errorf("wanted: albums to match: %w", err)
}
if len(albums) == 0 {
break
@@ -67,45 +78,68 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
if ctx.Err() != nil {
return ctx.Err()
}
- group, err := s.releaseGroup(ctx, deref(a.Mbid))
- switch {
- case errors.Is(err, tags.ErrSwitchedOff):
- // Nothing can be named while MusicBrainz is off: the rest are
- // counted as unlisted rather than left waiting for ever.
- return s.finishLibrary(ctx, q, res)
- case errors.Is(err, tags.ErrNotFound):
- group = "" // MusicBrainz has none: asked once
- case err != nil:
- // Unreachable or refusing for now: the next pass goes on.
- s.logger.Info("wanted: naming the library's albums paused", "err", err)
+ if s.now().After(deadline) {
+ return nil // the rest next pass
+ }
+ n, err := s.listAlbum(ctx, q, a)
+ if errors.Is(err, errMusicBrainzAway) {
+ s.logger.Info("wanted: matching the library's albums paused", "err", err)
return nil
}
- if err := q.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &group}); err != nil {
- return fmt.Errorf("wanted: name album %s: %w", a.Title, err)
- }
- if group == "" {
- continue
- }
- n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff, AlbumID: a.ID})
if err != nil {
- return fmt.Errorf("wanted: list album %s: %w", a.Title, err)
+ return err
}
- res.Listed += int(n)
- }
- if len(albums) == libraryNamesPerPass {
- return nil // the rest next pass
+ res.Listed += n
}
}
return s.finishLibrary(ctx, q, res)
}
+// listAlbum matches one album and lists it, or records why it could not.
+func (s *Service) listAlbum(ctx context.Context, q *dbq.Queries, a dbq.ListAlbumsToMatchRow) (int, error) {
+ rows, err := q.ListAlbumTracksToMatch(ctx, a.ID)
+ if err != nil {
+ return 0, fmt.Errorf("wanted: read %s's tracks: %w", a.Title, err)
+ }
+ m, err := s.matchAlbum(ctx, a, tracksToMatch(rows))
+ if err != nil {
+ return 0, err
+ }
+ if !m.OK {
+ s.logger.Info("wanted: no MusicBrainz match for a library album", "album", a.Title, "artist", a.ArtistName, "why", m.Detail)
+ return 0, q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{AlbumID: a.ID, Outcome: "unmatched", Detail: m.Detail})
+ }
+ var listed int64
+ err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
+ tq := dbq.New(tx)
+ if err := tq.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &m.Group}); err != nil {
+ return err
+ }
+ n, err := tq.InsertLibraryWant(ctx, dbq.InsertLibraryWantParams{
+ ReleaseGroupMbid: m.Group, ArtistMbid: m.ArtistMBID, ArtistName: m.ArtistName, Title: m.Title,
+ PrimaryType: m.PrimaryType, FirstReleaseDate: m.FirstReleaseDate, AlbumID: a.ID,
+ })
+ if err != nil {
+ return err
+ }
+ listed = n
+ return tq.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
+ AlbumID: a.ID, Outcome: "listed", Method: m.Method, ReleaseGroupMbid: m.Group,
+ })
+ })
+ if err != nil {
+ return 0, fmt.Errorf("wanted: list album %s: %w", a.Title, err)
+ }
+ return int(listed), nil
+}
+
func (s *Service) finishLibrary(ctx context.Context, q *dbq.Queries, res *PassResult) error {
done, err := q.FinishLibraryListing(ctx)
if err != nil {
return fmt.Errorf("wanted: finish the library listing: %w", err)
}
s.logger.Info("wanted: the library's albums are on the list", "listed_this_pass", res.Listed,
- "unlisted", done.LibraryUnlisted)
+ "unmatched", done.LibraryUnlisted)
return nil
}
diff --git a/internal/wanted/library_test.go b/internal/wanted/library_test.go
new file mode 100644
index 00000000..eb865d1a
--- /dev/null
+++ b/internal/wanted/library_test.go
@@ -0,0 +1,262 @@
+package wanted
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/tags"
+)
+
+// startListing clears the reset's "already listed", as a fresh install has it.
+func startListing(t *testing.T, pool *pgxpool.Pool) {
+ t.Helper()
+ if _, err := pool.Exec(context.Background(), `
+ UPDATE acquisition_settings SET library_listing_started_at = NULL, library_listed_at = NULL, library_unlisted = 0`); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// seedAlbum puts an album in the library whose tracks are titled titles,
+// numbered by numbers (from 1 when none are given). An artistMBID or
+// release of "" leaves that id out, as untagged files do.
+func seedAlbum(t *testing.T, q *dbq.Queries, artistMBID, title, rel string, titles []string, numbers ...int32) dbq.Album {
+ t.Helper()
+ ctx := context.Background()
+ ap := dbq.UpsertArtistParams{Name: "Blur", SortName: "Blur"}
+ if artistMBID != "" {
+ ap.Mbid = &artistMBID
+ }
+ artist, err := q.UpsertArtist(ctx, ap)
+ if err != nil {
+ t.Fatal(err)
+ }
+ params := dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID}
+ if rel != "" {
+ params.Mbid = &rel
+ }
+ album, err := q.UpsertAlbum(ctx, params)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, name := range titles {
+ num := int32(i + 1)
+ if i < len(numbers) {
+ num = numbers[i]
+ }
+ if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
+ Title: name, AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 180000,
+ FilePath: fmt.Sprintf("/music/%s/%02d.flac", strings.ReplaceAll(title, " ", "_"), num), FileSize: 1, FileFormat: "flac",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return album
+}
+
+func matchOf(t *testing.T, pool *pgxpool.Pool, album pgtype.UUID) (outcome, method, group, detail string) {
+ t.Helper()
+ if err := pool.QueryRow(context.Background(), `
+ SELECT outcome, method, release_group_mbid, detail FROM library_album_matches WHERE album_id = $1`, album,
+ ).Scan(&outcome, &method, &group, &detail); err != nil {
+ t.Fatalf("no match recorded: %v", err)
+ }
+ return
+}
+
+// The library's albums go on the list as held, once (#5567). Tags that name
+// the group list it at once; a release id is named through MusicBrainz;
+// what nothing matches is recorded with why. Taken off the list, an album
+// stays off, and one added to the library afterwards is never listed.
+func TestListLibrary_OnceAndThenTheListIsTheTruth_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ s := newService(t, pool, newFakeMB(t))
+ s.releaseGroup = func(_ context.Context, release string) (string, error) {
+ if release == "rel-ballad" {
+ return theBallad, nil
+ }
+ return "", tags.ErrNotFound
+ }
+ blurAlbum := seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 3)
+ ballad := seedAlbum(t, q, blur, "The Ballad of Darren", "rel-ballad", []string{"The Ballad", "St. Charles Square"})
+ bootleg := seedAlbum(t, q, blur, "Bootleg", "rel-bootleg", []string{"Intro"})
+ // An album with a track gone: held from before the loss, so
+ // re-acquisition still fetches it back.
+ lost := seedHeld(t, q, "13", "rel-13", "rg-13", 2)
+ if _, err := pool.Exec(ctx, `UPDATE tracks SET missing_since = now() - interval '1 day'
+ WHERE album_id = $1 AND track_number = 1`, lost.ID); err != nil {
+ t.Fatal(err)
+ }
+ // An album row with no tracks is not an album in the library.
+ if _, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Empty", SortTitle: "Empty", ArtistID: blurAlbum.ArtistID}); err != nil {
+ t.Fatal(err)
+ }
+ startListing(t, pool)
+
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ for group, album := range map[string]pgtype.UUID{"rg-blur": blurAlbum.ID, theBallad: ballad.ID, "rg-13": lost.ID} {
+ w, err := q.GetWantByReleaseGroup(ctx, group)
+ if err != nil {
+ t.Fatalf("%s not listed: %v", group, err)
+ }
+ if w.Reason != ReasonLibrary || !w.FulfilledAt.Valid || w.AlbumID != album || w.ArtistMbid != blur {
+ t.Errorf("%s = %+v: a held library want about the album", group, w)
+ }
+ }
+ if w, _ := q.GetWantByReleaseGroup(ctx, "rg-13"); !w.FulfilledAt.Time.Before(time.Now().Add(-23 * time.Hour)) {
+ t.Errorf("an album missing a track is held from %v, not from before the loss", w.FulfilledAt.Time)
+ }
+ if outcome, method, _, _ := matchOf(t, pool, ballad.ID); outcome != "listed" || method != matchRelease {
+ t.Errorf("ballad decided %s by %s", outcome, method)
+ }
+ if outcome, _, _, detail := matchOf(t, pool, bootleg.ID); outcome != "unmatched" || !strings.Contains(detail, `no album called "Bootleg"`) {
+ t.Errorf("bootleg decided %s: %q", outcome, detail)
+ }
+ state, err := q.GetLibraryListing(ctx)
+ if err != nil || !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
+ t.Fatalf("listing = %+v %v: done, with only the bootleg unmatched", state, err)
+ }
+
+ // Off the list stays off; an album added later is never listed.
+ w, _ := q.GetWantByReleaseGroup(ctx, "rg-blur")
+ if _, err := s.RemoveWant(ctx, w.ID); err != nil {
+ t.Fatal(err)
+ }
+ _ = seedHeld(t, q, "Think Tank", "rel-think", "rg-think", 2)
+ if _, err := s.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ for _, group := range []string{"rg-blur", "rg-think"} {
+ if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
+ t.Errorf("%s is on the list", group)
+ }
+ }
+}
+
+// An album whose tags name nothing is matched by its title among the
+// artist's release groups, and taken only when its tracks agree: a partial
+// copy agrees on the tracks it has. One whose title matches but whose
+// tracks do not is left unmatched, with why.
+func TestListLibrary_MatchesByDiscographyWhenTheTracksAgree_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ s := newService(t, pool, newFakeMB(t))
+ // MusicBrainz switched off for tags does not stop matching by name.
+ s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrSwitchedOff }
+ partial := seedAlbum(t, q, blur, "The Ballad of Darren (Deluxe)", "rel-ballad", []string{"St Charles Square"}, 2)
+ wrong := seedAlbum(t, q, blur, "Parklife", "", []string{"Girls & Boys", "Tracy Jacks"})
+ startListing(t, pool)
+
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ if outcome, method, group, _ := matchOf(t, pool, partial.ID); outcome != "listed" || method != matchDiscography || group != theBallad {
+ t.Errorf("partial copy decided %s by %s as %s", outcome, method, group)
+ }
+ var named *string
+ if err := pool.QueryRow(ctx, `SELECT release_group_mbid FROM albums WHERE id = $1`, partial.ID).Scan(&named); err != nil || named == nil || *named != theBallad {
+ t.Errorf("the album's release group = %v %v", named, err)
+ }
+ if outcome, _, _, detail := matchOf(t, pool, wrong.ID); outcome != "unmatched" || !strings.Contains(detail, "none has its tracks") {
+ t.Errorf("Parklife without Parklife's tracks decided %s: %q", outcome, detail)
+ }
+ if _, err := q.GetWantByReleaseGroup(ctx, parklife); err == nil {
+ t.Error("Parklife listed on its title alone")
+ }
+}
+
+// An artist the tags give no id is found by MusicBrainz's search on artist
+// and title, and the want is filed under the artist MusicBrainz credits.
+func TestListLibrary_MatchesBySearchWhenTheArtistHasNoID_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ s := newService(t, pool, newFakeMB(t))
+ album := seedAlbum(t, q, "", "The Ballad of Darren", "", []string{"The Ballad", "St. Charles Square"})
+ startListing(t, pool)
+
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ if outcome, method, _, _ := matchOf(t, pool, album.ID); outcome != "listed" || method != matchSearch {
+ t.Errorf("decided %s by %s", outcome, method)
+ }
+ w, err := q.GetWantByReleaseGroup(ctx, theBallad)
+ if err != nil || w.ArtistMbid != blur || w.Reason != ReasonLibrary || !w.FulfilledAt.Valid {
+ t.Errorf("want = %+v %v", w, err)
+ }
+}
+
+// A MusicBrainz that does not answer pauses the matching; the album is not
+// decided on half the evidence, and the next pass goes on.
+func TestListLibrary_UnreachablePausesTheMatching_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ f := newFakeMB(t)
+ s := newService(t, pool, f)
+ album := seedAlbum(t, q, blur, "The Ballad of Darren", "", []string{"The Ballad", "St. Charles Square"})
+ startListing(t, pool)
+
+ f.down.Store(true)
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ if state, _ := q.GetLibraryListing(ctx); state.LibraryListedAt.Valid {
+ t.Fatal("finished while an album was still to be matched")
+ }
+ var n int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_album_matches WHERE album_id = $1`, album.ID).Scan(&n); err != nil || n != 0 {
+ t.Fatalf("decided while MusicBrainz was away: %d %v", n, err)
+ }
+ f.down.Store(false)
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
+ t.Errorf("not listed once MusicBrainz answered: %v", err)
+ }
+ if state, _ := q.GetLibraryListing(ctx); !state.LibraryListedAt.Valid {
+ t.Error("not finished")
+ }
+}
+
+func TestTrackAgreement(t *testing.T) {
+ edition := []editionTrack{
+ {Medium: 1, Position: 1, Title: "Intro", LengthMs: 60000, Recording: "rec-1"},
+ {Medium: 1, Position: 2, Title: "Song", LengthMs: 200000},
+ {Medium: 2, Position: 1, Title: "Encore", LengthMs: 300000},
+ }
+ for _, c := range []struct {
+ name string
+ tracks []libraryTrack
+ want int
+ }{
+ {"all, by position", []libraryTrack{
+ {Disc: 1, Number: 1, Title: "Intro", LengthMs: 60000}, {Number: 2, Title: "Song", LengthMs: 201000},
+ {Disc: 2, Number: 1, Title: "Encore", LengthMs: 300000}}, 3},
+ {"a partial copy", []libraryTrack{{Disc: 2, Number: 1, Title: "Encore", LengthMs: 300500}}, 1},
+ {"unnumbered, by title", []libraryTrack{{Title: "Song", LengthMs: 200000}, {Title: "Encore", LengthMs: 299000}}, 2},
+ {"the recording, whatever its title", []libraryTrack{{Number: 1, Title: "Untitled", LengthMs: 1, Recording: "REC-1"}}, 1},
+ {"the right title at the wrong length", []libraryTrack{{Number: 2, Title: "Song", LengthMs: 400000}}, 0},
+ {"a track counted once", []libraryTrack{{Title: "Song", LengthMs: 200000}, {Title: "Song", LengthMs: 200000}}, 1},
+ } {
+ if got := trackAgreement(c.tracks, edition); got != c.want {
+ t.Errorf("%s: %d agree, want %d", c.name, got, c.want)
+ }
+ }
+ if agrees(3, 4) || !agrees(4, 5) || agrees(0, 0) {
+ t.Error("agrees: 80% of the album's tracks, and an album has tracks")
+ }
+}
diff --git a/internal/wanted/match.go b/internal/wanted/match.go
new file mode 100644
index 00000000..bcdf3ea8
--- /dev/null
+++ b/internal/wanted/match.go
@@ -0,0 +1,359 @@
+package wanted
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/release"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/tags"
+)
+
+// Matching a library album to its MusicBrainz release group (#5569).
+//
+// Everything in the library came from a service that matched it against
+// MusicBrainz (operator, 2026-10-10: "they should all exist in music brainz
+// we need to use their meta data or a search system to link them up"), so
+// every album should have a group. The ids its tags carry are taken as
+// they are. Without them, the groups called what the album is called, from
+// the artist's discography and then from a search, are checked against the
+// album's own tracks, and the one its tracks agree with is taken.
+
+// Match methods, as library_album_matches records them.
+const (
+ matchTags = "tags"
+ matchRelease = "release"
+ matchDiscography = "discography"
+ matchSearch = "search"
+)
+
+// matchAgreement is the share of an album's tracks, in tenths, an edition
+// must agree with: most, not all, since a rip's track may carry a typo or a
+// hidden track's length.
+const matchAgreement = 8
+
+// matchCandidates bounds how many groups one album is checked against: each
+// costs a MusicBrainz read the first time.
+const matchCandidates = 8
+
+// matchDiscographyAge is how old a held discography may be and still be
+// read for matching: a name does not change, and a new release is not in
+// the library.
+const matchDiscographyAge = 90 * 24 * time.Hour
+
+// errMusicBrainzAway: MusicBrainz did not answer, so the album is left for
+// the next pass rather than decided on half the evidence.
+var errMusicBrainzAway = errors.New("wanted: MusicBrainz did not answer")
+
+// libraryTrack is what one of an album's tracks says about itself.
+type libraryTrack struct {
+ Disc, Number int
+ Title string
+ LengthMs int
+ Recording string
+}
+
+// editionTrack is one track of a MusicBrainz edition.
+type editionTrack struct {
+ Medium, Position int
+ Title string
+ LengthMs int
+ Recording string
+}
+
+// candidate is a release group an album may be.
+type candidate struct {
+ Group string
+ Title string
+ PrimaryType string
+ FirstReleaseDate string
+ ArtistMBID string
+ ArtistName string
+ Method string
+}
+
+// albumMatch is what matching decided: a group, or why there is none.
+type albumMatch struct {
+ candidate
+ OK bool
+ Detail string
+}
+
+func tracksToMatch(rows []dbq.ListAlbumTracksToMatchRow) []libraryTrack {
+ out := make([]libraryTrack, 0, len(rows))
+ for _, r := range rows {
+ t := libraryTrack{Title: r.Title, LengthMs: int(r.DurationMs), Recording: deref(r.Mbid)}
+ if r.DiscNumber != nil {
+ t.Disc = int(*r.DiscNumber)
+ }
+ if r.TrackNumber != nil {
+ t.Number = int(*r.TrackNumber)
+ }
+ out = append(out, t)
+ }
+ return out
+}
+
+// trackAgreement counts the album's tracks an edition agrees with: the same
+// recording, or a length that fits and a title that agrees. A track is
+// paired by its disc and number first, then by title, so an album missing
+// some tracks still pairs the ones it has.
+func trackAgreement(tracks []libraryTrack, edition []editionTrack) int {
+ byPos := make(map[[2]int]int, len(edition))
+ for i, e := range edition {
+ byPos[[2]int{max(e.Medium, 1), e.Position}] = i
+ }
+ agree := func(t libraryTrack, e editionTrack) bool {
+ if t.Recording != "" && strings.EqualFold(t.Recording, e.Recording) {
+ return true
+ }
+ return release.LengthFits(t.LengthMs, e.LengthMs) && release.TitleAgrees(t.Title, e.Title)
+ }
+ used := make([]bool, len(edition))
+ n := 0
+ for _, t := range tracks {
+ if t.Number > 0 {
+ if i, ok := byPos[[2]int{max(t.Disc, 1), t.Number}]; ok && !used[i] && agree(t, edition[i]) {
+ used[i] = true
+ n++
+ continue
+ }
+ }
+ for i, e := range edition {
+ if !used[i] && agree(t, e) {
+ used[i] = true
+ n++
+ break
+ }
+ }
+ }
+ return n
+}
+
+// agrees: enough of the album's tracks agree for the edition to be it.
+func agrees(n, tracks int) bool {
+ return tracks > 0 && n*10 >= tracks*matchAgreement
+}
+
+// sameTitle: the same words, not merely one holding the other.
+func sameTitle(a, b string) bool {
+ return slices.Equal(release.Tokens(a), release.Tokens(b))
+}
+
+// matchAlbum finds the release group a library album is. A MusicBrainz
+// that does not answer is errMusicBrainzAway; anything else MusicBrainz
+// says is evidence, and an album with none is an albumMatch that is not OK.
+func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tracks []libraryTrack) (albumMatch, error) {
+ // The ids the tags carry name it.
+ if group := deref(a.ReleaseGroupMbid); group != "" {
+ if c, err := s.groupCandidate(ctx, group, matchTags, a); err != nil || c.Group != "" {
+ return albumMatch{candidate: c, OK: err == nil}, err
+ }
+ }
+ if rel := deref(a.Mbid); rel != "" {
+ group, err := s.releaseGroup(ctx, rel)
+ switch {
+ case errors.Is(err, tags.ErrNotFound): // includes MusicBrainz switched off for tags
+ case err != nil:
+ return albumMatch{}, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
+ default:
+ if c, err := s.groupCandidate(ctx, group, matchRelease, a); err != nil || c.Group != "" {
+ return albumMatch{candidate: c, OK: err == nil}, err
+ }
+ }
+ }
+
+ // Otherwise its name, checked against its tracks: the artist's own
+ // discography first, then a search.
+ tried := map[string]bool{}
+ var best albumMatch
+ bestN, bestEdition, considered := -1, 0, 0
+ check := func(cands []candidate) error {
+ for _, c := range cands {
+ if tried[c.Group] || len(tried) >= matchCandidates {
+ continue
+ }
+ tried[c.Group] = true
+ n, size, err := s.agreementWith(ctx, c.Group, tracks)
+ if err != nil {
+ return err
+ }
+ considered++
+ // More tracks agreeing wins; then the same title over a longer
+ // one; then the edition nearest the album's length.
+ st, bt := sameTitle(c.Title, a.Title), sameTitle(best.Title, a.Title)
+ if n > bestN || (n == bestN && ((st && !bt) || (st == bt && abs(size-len(tracks)) < abs(bestEdition-len(tracks))))) {
+ best, bestN, bestEdition = albumMatch{candidate: c}, n, size
+ }
+ }
+ return nil
+ }
+ discography, err := s.discographyCandidates(ctx, a)
+ if err != nil {
+ return albumMatch{}, err
+ }
+ if err := check(discography); err != nil {
+ return albumMatch{}, err
+ }
+ if !agrees(bestN, len(tracks)) {
+ found, err := s.searchCandidates(ctx, a)
+ if err != nil {
+ return albumMatch{}, err
+ }
+ if err := check(found); err != nil {
+ return albumMatch{}, err
+ }
+ }
+ if agrees(bestN, len(tracks)) {
+ best.OK = true
+ return best, nil
+ }
+ if considered == 0 {
+ return albumMatch{Detail: fmt.Sprintf("MusicBrainz has no album called %q by %s.", a.Title, a.ArtistName)}, nil
+ }
+ return albumMatch{Detail: fmt.Sprintf(
+ "MusicBrainz has %d %s called %q by %s, but none has its tracks: the closest, %q, agrees on %d of its %d.",
+ considered, plural(considered, "album", "albums"), a.Title, a.ArtistName, best.Title, max(bestN, 0), len(tracks))}, nil
+}
+
+// groupCandidate reads a group the tags name. A group MusicBrainz does not
+// have is a zero candidate, so the album is matched by name instead.
+func (s *Service) groupCandidate(ctx context.Context, group, method string, a dbq.ListAlbumsToMatchRow) (candidate, error) {
+ g, err := s.cat.MusicBrainz().ReleaseGroup(ctx, group)
+ switch {
+ case errors.Is(err, musicbrainz.ErrNotFound):
+ return candidate{}, nil
+ case err != nil:
+ return candidate{}, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
+ }
+ artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid))
+ if artist == "" {
+ return candidate{}, nil
+ }
+ return candidate{Group: g.MBID, Title: g.Title, PrimaryType: g.PrimaryType, FirstReleaseDate: g.FirstReleaseDate,
+ ArtistMBID: artist, ArtistName: g.ArtistCredit, Method: method}, nil
+}
+
+// discographyCandidates are the artist's release groups called what the
+// album is called.
+func (s *Service) discographyCandidates(ctx context.Context, a dbq.ListAlbumsToMatchRow) ([]candidate, error) {
+ artist := deref(a.ArtistMbid)
+ if !Followable(artist, a.ArtistName) {
+ return nil, nil // no artist, or Various Artists: thousands of groups
+ }
+ d, err := s.cat.DiscographyWithin(ctx, artist, matchDiscographyAge)
+ switch {
+ case errors.Is(err, catalogue.ErrNotFound):
+ return nil, nil
+ case err != nil:
+ return nil, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
+ }
+ var out []candidate
+ for _, g := range d.Groups {
+ if release.TitleAgrees(g.Title, a.Title) {
+ out = append(out, candidate{Group: g.Mbid, Title: g.Title, PrimaryType: g.PrimaryType,
+ FirstReleaseDate: g.FirstReleaseDate, ArtistMBID: artist, ArtistName: d.Artist.Name, Method: matchDiscography})
+ }
+ }
+ sortSameTitleFirst(out, a.Title)
+ return out, nil
+}
+
+// searchCandidates are the groups MusicBrainz's search finds by the album's
+// artist and title, whose title and artist both agree.
+func (s *Service) searchCandidates(ctx context.Context, a dbq.ListAlbumsToMatchRow) ([]candidate, error) {
+ found, err := s.cat.MusicBrainz().SearchReleaseGroupsBy(ctx, a.ArtistName, a.Title, 10)
+ switch {
+ case errors.Is(err, musicbrainz.ErrNotFound):
+ return nil, nil
+ case errors.Is(err, musicbrainz.ErrBadResponse):
+ // A query MusicBrainz will not read is not a reason to wait.
+ s.logger.Warn("wanted: MusicBrainz refused a search", "album", a.Title, "artist", a.ArtistName, "err", err)
+ return nil, nil
+ case err != nil:
+ return nil, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
+ }
+ var out []candidate
+ for _, g := range found {
+ artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid))
+ if artist == "" || !release.TitleAgrees(g.Title, a.Title) {
+ continue
+ }
+ if !slices.Contains(g.ArtistMBIDs, deref(a.ArtistMbid)) && !release.TitleAgrees(g.ArtistCredit, a.ArtistName) {
+ continue
+ }
+ out = append(out, candidate{Group: g.MBID, Title: g.Title, PrimaryType: g.PrimaryType,
+ FirstReleaseDate: g.FirstReleaseDate, ArtistMBID: artist, ArtistName: g.ArtistCredit, Method: matchSearch})
+ }
+ sortSameTitleFirst(out, a.Title)
+ return out, nil
+}
+
+// agreementWith is how many of the album's tracks the group's best edition
+// agrees with, and that edition's length. A group MusicBrainz no longer has
+// agrees with none.
+func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack) (n, size int, err error) {
+ eds, err := s.cat.Releases(ctx, group)
+ switch {
+ case errors.Is(err, catalogue.ErrNotFound):
+ return -1, 0, nil
+ case err != nil:
+ return 0, 0, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
+ }
+ n = -1
+ for _, e := range eds.Editions {
+ edition := make([]editionTrack, 0, len(e.Tracks))
+ for _, t := range e.Tracks {
+ edition = append(edition, editionTrack{Medium: int(t.MediumPosition), Position: int(t.Position),
+ Title: t.Title, LengthMs: int(t.LengthMs), Recording: t.RecordingMbid})
+ }
+ if got := trackAgreement(tracks, edition); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) {
+ n, size = got, len(edition)
+ }
+ }
+ return n, size, nil
+}
+
+// creditedArtist is the artist a want is filed under: the library's own
+// artist when MusicBrainz credits them, else the first credited.
+func creditedArtist(credited []string, own string) string {
+ if own != "" && (len(credited) == 0 || slices.Contains(credited, own)) {
+ return own
+ }
+ if len(credited) > 0 {
+ return credited[0]
+ }
+ return ""
+}
+
+func sortSameTitleFirst(cs []candidate, title string) {
+ slices.SortStableFunc(cs, func(x, y candidate) int {
+ switch sx, sy := sameTitle(x.Title, title), sameTitle(y.Title, title); {
+ case sx && !sy:
+ return -1
+ case sy && !sx:
+ return 1
+ }
+ return 0
+ })
+}
+
+func abs(n int) int {
+ if n < 0 {
+ return -n
+ }
+ return n
+}
+
+func plural(n int, one, many string) string {
+ if n == 1 {
+ return one
+ }
+ return many
+}
diff --git a/internal/wanted/wanted_test.go b/internal/wanted/wanted_test.go
index 0b7ecd09..e0f5270e 100644
--- a/internal/wanted/wanted_test.go
+++ b/internal/wanted/wanted_test.go
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "strings"
"sync/atomic"
"testing"
"time"
@@ -226,6 +227,13 @@ func newFakeMB(t *testing.T) *fakeMB {
{"position": 3, "number": "3", "title": "Bonus", "recording": {"id": "rec-3"}}]}]}]}`)
case r.URL.Path == "/release":
_, _ = fmt.Fprint(w, `{"release-count": 0, "releases": []}`)
+ case r.URL.Path == "/release-group" && strings.Contains(r.URL.Query().Get("query"), "releasegroup:"):
+ // The library listing's search by artist and title (#5569).
+ found := ""
+ if strings.Contains(r.URL.Query().Get("query"), "Ballad") {
+ found = group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21")
+ }
+ _, _ = fmt.Fprintf(w, `{"release-groups": [%s]}`, found)
default:
w.WriteHeader(http.StatusNotFound)
}
@@ -455,135 +463,3 @@ func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
}
return u.ID
}
-
-// startListing clears the reset's "already listed", as a fresh install has it.
-func startListing(t *testing.T, pool *pgxpool.Pool) {
- t.Helper()
- if _, err := pool.Exec(context.Background(), `
- UPDATE acquisition_settings SET library_listing_started_at = NULL, library_listed_at = NULL, library_unlisted = 0`); err != nil {
- t.Fatal(err)
- }
-}
-
-// The library's albums go on the list as held, once (#5567). An album the
-// tags name by release only is named through MusicBrainz first; one nothing
-// names is counted, not listed. Taken off the list, an album stays off, and
-// one added to the library afterwards is never listed.
-func TestListLibrary_OnceAndThenTheListIsTheTruth_Integration(t *testing.T) {
- pool := newPool(t)
- ctx := context.Background()
- q := dbq.New(pool)
- s := newService(t, pool, newFakeMB(t))
- s.releaseGroup = func(_ context.Context, release string) (string, error) {
- if release == "rel-parklife" {
- return "rg-parklife", nil
- }
- return "", tags.ErrNotFound
- }
- blurAlbum := seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 3)
- parklife := seedHeld(t, q, "Parklife", "rel-parklife", "", 2)
- _ = seedHeld(t, q, "Bootleg", "rel-bootleg", "", 1)
- // An album with a track gone: held from before the loss, so
- // re-acquisition still fetches it back.
- lost := seedHeld(t, q, "13", "rel-13", "rg-13", 2)
- if _, err := pool.Exec(ctx, `UPDATE tracks SET missing_since = now() - interval '1 day'
- WHERE album_id = $1 AND track_number = 1`, lost.ID); err != nil {
- t.Fatal(err)
- }
- startListing(t, pool)
-
- if err := s.listLibrary(ctx, &PassResult{}); err != nil {
- t.Fatal(err)
- }
- for group, album := range map[string]pgtype.UUID{"rg-blur": blurAlbum.ID, "rg-parklife": parklife.ID, "rg-13": lost.ID} {
- w, err := q.GetWantByReleaseGroup(ctx, group)
- if err != nil {
- t.Fatalf("%s not listed: %v", group, err)
- }
- if w.Reason != ReasonLibrary || !w.FulfilledAt.Valid || w.AlbumID != album {
- t.Errorf("%s = %+v: a held library want about the album", group, w)
- }
- }
- if w, _ := q.GetWantByReleaseGroup(ctx, "rg-13"); !w.FulfilledAt.Time.Before(time.Now().Add(-23 * time.Hour)) {
- t.Errorf("an album missing a track is held from %v, not from before the loss", w.FulfilledAt.Time)
- }
- state, err := q.GetLibraryListing(ctx)
- if err != nil || !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
- t.Fatalf("listing = %+v %v: done, with the bootleg unlisted", state, err)
- }
-
- // Off the list stays off; an album added later is never listed.
- w, _ := q.GetWantByReleaseGroup(ctx, "rg-blur")
- if _, err := s.RemoveWant(ctx, w.ID); err != nil {
- t.Fatal(err)
- }
- _ = seedHeld(t, q, "Think Tank", "rel-think", "rg-think", 2)
- if _, err := s.Pass(ctx); err != nil {
- t.Fatal(err)
- }
- for _, group := range []string{"rg-blur", "rg-think"} {
- if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
- t.Errorf("%s is on the list", group)
- }
- }
-}
-
-// While MusicBrainz is switched off nothing can be named, so the listing
-// finishes with what it has rather than waiting for ever.
-func TestListLibrary_MusicBrainzOffFinishesWithWhatIsNamed_Integration(t *testing.T) {
- pool := newPool(t)
- ctx := context.Background()
- q := dbq.New(pool)
- s := newService(t, pool, newFakeMB(t))
- s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrSwitchedOff }
- _ = seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 1)
- _ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
- startListing(t, pool)
- if err := s.listLibrary(ctx, &PassResult{}); err != nil {
- t.Fatal(err)
- }
- state, _ := q.GetLibraryListing(ctx)
- if !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
- t.Errorf("listing = %+v", state)
- }
- var named *string
- if err := pool.QueryRow(ctx, `SELECT release_group_mbid FROM albums WHERE title = 'Parklife'`).Scan(&named); err != nil {
- t.Fatal(err)
- }
- if named != nil {
- t.Errorf("an album was marked unnameable while MusicBrainz was off: %q", *named)
- }
-}
-
-// A MusicBrainz that does not answer pauses the naming; the next pass goes on.
-func TestListLibrary_UnreachablePausesTheNaming_Integration(t *testing.T) {
- pool := newPool(t)
- ctx := context.Background()
- q := dbq.New(pool)
- s := newService(t, pool, newFakeMB(t))
- down := true
- s.releaseGroup = func(context.Context, string) (string, error) {
- if down {
- return "", tags.ErrTransient
- }
- return "rg-parklife", nil
- }
- _ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
- startListing(t, pool)
- if err := s.listLibrary(ctx, &PassResult{}); err != nil {
- t.Fatal(err)
- }
- if state, _ := q.GetLibraryListing(ctx); state.LibraryListedAt.Valid {
- t.Fatal("finished while an album was still to be named")
- }
- down = false
- if err := s.listLibrary(ctx, &PassResult{}); err != nil {
- t.Fatal(err)
- }
- if _, err := q.GetWantByReleaseGroup(ctx, "rg-parklife"); err != nil {
- t.Errorf("not listed once named: %v", err)
- }
- if state, _ := q.GetLibraryListing(ctx); !state.LibraryListedAt.Valid {
- t.Error("not finished")
- }
-}
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index fe1e4953..957fc4a3 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -815,7 +815,9 @@ export type ReleaseBlocksPage = { items: ReleaseBlock[]; limit: number; offset:
// The one-time listing of the library's albums as held (#5567): when it
// finished (null while it runs), and how many albums nothing names to
// MusicBrainz, so they could not be listed.
-export type LibraryListing = { listed_at: string | null; unlisted: number };
+export type UnmatchedAlbum = { album_id: string; title: string; artist: string; detail: string };
+
+export type LibraryListing = { listed_at: string | null; unlisted: number; unmatched: UnmatchedAlbum[] };
export type WantsPage = Page & { counts: WantCounts; library: LibraryListing };
diff --git a/web/src/lib/components/wanted/WantList.svelte b/web/src/lib/components/wanted/WantList.svelte
index ee953bb5..168f9fee 100644
--- a/web/src/lib/components/wanted/WantList.svelte
+++ b/web/src/lib/components/wanted/WantList.svelte
@@ -14,8 +14,9 @@
// the missing albums wanted back, or the ones held. Each open want says
// what its last search found and how its download is getting on; Releases
// lists every candidate with its verdict, and Search runs one now. The
- // library's albums were put on the list as held once (#5567); taking one
- // off leaves its files and stops it being fetched back if they go.
+ // library's albums were put on the list as held once (#5567), each matched to
+ // MusicBrainz (#5569); taking one off leaves its files and stops it being
+ // fetched back if they go. The ones no match was found for are named here.
let { list }: { list: WantState } = $props();
@@ -26,6 +27,7 @@
const library = $derived(wants.data?.pages[0]?.library);
let busy = $state(null);
+ let showUnmatched = $state(false);
let searching = $state(null);
let open = $state(null);
@@ -162,16 +164,46 @@
{#if list === 'held' && library}
{#if !library.listed_at}
- Your library's albums are being added to this list as held. Albums whose tags name only a
- release are looked up on MusicBrainz first, about one a second.
+ Your library's albums are being added to this list as held. Albums whose tags don't name them
+ are matched on MusicBrainz first, by their artist, title and tracks, about one a second.
{:else if library.unlisted > 0}
-
- {library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
- {library.unlisted === 1 ? "isn't" : "aren't"} on this list: nothing in {library.unlisted === 1 ? 'its' : 'their'}
- tags names {library.unlisted === 1 ? 'it' : 'them'} to MusicBrainz. Those are never fetched back if
- their files go missing.
-
+
+
+ {library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
+ {library.unlisted === 1 ? "isn't" : "aren't"} on this list: no MusicBrainz album matched
+ {library.unlisted === 1 ? 'its' : 'their'} artist, title and tracks. Until
+ {library.unlisted === 1 ? "it's" : "they're"} matched, {library.unlisted === 1 ? "it isn't" : "they aren't"}
+ fetched back if {library.unlisted === 1 ? 'its' : 'their'} files go missing.
+
+ {#if library.unmatched.length > 0}
+
(showUnmatched = !showUnmatched)}
+ >
+
+ {showUnmatched ? 'Hide them' : 'Show them'}
+
+ {#if showUnmatched}
+
+ {#each library.unmatched as u (u.album_id)}
+
+ {u.title}
+ · {u.artist}
+ {u.detail}
+
+ {/each}
+
+ {#if library.unmatched.length < library.unlisted}
+
+ The first {library.unmatched.length} of {library.unlisted}.
+
+ {/if}
+ {/if}
+ {/if}
+
{/if}
{/if}
diff --git a/web/src/lib/components/wanted/WantList.test.ts b/web/src/lib/components/wanted/WantList.test.ts
index b4e5b8a3..e7d7c283 100644
--- a/web/src/lib/components/wanted/WantList.test.ts
+++ b/web/src/lib/components/wanted/WantList.test.ts
@@ -1,39 +1,25 @@
-import { afterEach, describe, expect, test, vi } from "vitest";
-import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
-import { mockInfiniteQuery, mockQuery } from "../../../test-utils/query";
-import type {
- LibraryListing,
- Want,
- WantGrab,
- WantsPage,
-} from "#lib/api/types.js";
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
+import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query';
+import type { LibraryListing, Want, WantGrab, WantsPage } from '#lib/api/types.js';
-vi.mock("#lib/api/wanted.js", () => ({
+vi.mock('#lib/api/wanted.js', () => ({
createWantsQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
- outcome: "nothing",
- considered: 0,
- best_release: "",
- best_verdict: "",
- narrowed: [],
+ outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
}),
createCandidatesQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
- unblockRelease: vi.fn(),
+ unblockRelease: vi.fn()
}));
-vi.mock("#lib/stores/toast.svelte.js", () => ({ pushToast: vi.fn() }));
+vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
-import WantList from "./WantList.svelte";
-import { pushToast } from "#lib/stores/toast.svelte.js";
-import {
- createCandidatesQuery,
- createWantsQuery,
- removeWant,
- searchWant,
-} from "#lib/api/wanted.js";
-import type { WantState } from "#lib/api/types.js";
+import WantList from './WantList.svelte';
+import { pushToast } from '#lib/stores/toast.svelte.js';
+import { createCandidatesQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
+import type { WantState } from '#lib/api/types.js';
const DAY = 24 * 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
@@ -42,305 +28,203 @@ function want(id: string, extra: Partial = {}): Want {
return {
id,
release_group_mbid: `rg-${id}`,
- title: "The Ballad of Darren",
- artist_mbid: "blur",
- artist_name: "Blur",
- artist_id: "art-1",
- primary_type: "Album",
+ title: 'The Ballad of Darren',
+ artist_mbid: 'blur',
+ artist_name: 'Blur',
+ artist_id: 'art-1',
+ primary_type: 'Album',
secondary_types: [],
- year: "2023",
- reason: "follow",
+ year: '2023',
+ reason: 'follow',
wanted_at: ago(2 * DAY),
held_at: null,
- image_url: "",
- ...extra,
+ image_url: '',
+ ...extra
};
}
function page(
items: Want[],
- library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 },
+ library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
): WantsPage {
return {
items,
total: items.length,
limit: 50,
offset: 0,
- counts: {
- wanted: 2,
- missing: 1,
- held: 4,
- following: 1,
- activity: 1,
- attention: 0,
- blocked: 0,
- },
- library,
+ counts: { wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
+ library
};
}
function grab(extra: Partial = {}): WantGrab {
return {
- id: "g1",
- name: "Blur - Blur (1997) [FLAC]",
+ id: 'g1',
+ name: 'Blur - Blur (1997) [FLAC]',
grabbed_at: ago(60_000),
- origin: "schedule",
- quality: "lossless",
+ origin: 'schedule',
+ quality: 'lossless',
live: true,
- end_reason: "",
+ end_reason: '',
progress: 0,
- import_state: "",
- import_reason: "",
- end_detail: "",
+ import_state: '',
+ import_reason: '',
+ end_detail: '',
imported_at: null,
imported_files: 0,
- ...extra,
+ ...extra
};
}
afterEach(() => vi.clearAllMocks());
function setup(
- items: Want[] = [
- want("w1"),
- want("w2", { reason: "request", requested_by: "sam", title: "Parklife" }),
- ],
- state: WantState = "wanted",
- library?: LibraryListing,
+ items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })],
+ state: WantState = 'wanted',
+ library?: LibraryListing
) {
vi.mocked(createWantsQuery).mockReturnValue(
- mockInfiniteQuery({
- pages: [page(items, library)],
- }) as unknown as ReturnType,
+ mockInfiniteQuery({ pages: [page(items, library)] }) as unknown as ReturnType
);
vi.mocked(createCandidatesQuery).mockReturnValue(
- mockQuery({ data: [] }) as unknown as ReturnType<
- typeof createCandidatesQuery
- >,
+ mockQuery({ data: [] }) as unknown as ReturnType
);
return render(WantList, { list: state });
}
-describe("WantList", () => {
- test("asks for its own state", () => {
- setup([], "missing");
- expect(createWantsQuery).toHaveBeenCalledWith("missing");
+describe('WantList', () => {
+ test('asks for its own state', () => {
+ setup([], 'missing');
+ expect(createWantsQuery).toHaveBeenCalledWith('missing');
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
});
- test("a want says why it is wanted", () => {
+ test('a want says why it is wanted', () => {
setup();
- const why = screen
- .getAllByTestId("want-why")
- .map((e) => e.textContent ?? "");
+ const why = screen.getAllByTestId('want-why').map((e) => e.textContent ?? '');
expect(why[0]).toMatch(/new from a followed artist/i);
expect(why[1]).toMatch(/requested by sam/i);
});
- test("remove takes a want off the list", async () => {
+ test('remove takes a want off the list', async () => {
setup();
- await fireEvent.click(
- screen.getByRole("button", { name: /stop wanting parklife/i }),
- );
- await waitFor(() => expect(removeWant).toHaveBeenCalledWith("w2"));
+ await fireEvent.click(screen.getByRole('button', { name: /stop wanting parklife/i }));
+ await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w2'));
});
- test("a held album can be taken off the list, and is not searched for", async () => {
- setup(
- [want("w1", { held_at: ago(DAY), album_id: "al-1", reason: "library" })],
- "held",
- );
- expect(
- screen.queryByRole("button", { name: /stop wanting|search for/i }),
- ).toBeNull();
- expect(
- screen
- .getByRole("link", { name: /the ballad of darren/i })
- .getAttribute("href"),
- ).toBe("/albums/al-1");
- expect(screen.getByTestId("want-why").textContent).toMatch(
- /in your library when the list began/i,
- );
- await fireEvent.click(
- screen.getByRole("button", {
- name: /take the ballad of darren off the list/i,
- }),
- );
- await waitFor(() => expect(removeWant).toHaveBeenCalledWith("w1"));
+ test('a held album can be taken off the list, and is not searched for', async () => {
+ setup([want('w1', { held_at: ago(DAY), album_id: 'al-1', reason: 'library' })], 'held');
+ expect(screen.queryByRole('button', { name: /stop wanting|search for/i })).toBeNull();
+ expect(screen.getByRole('link', { name: /the ballad of darren/i }).getAttribute('href')).toBe('/albums/al-1');
+ expect(screen.getByTestId('want-why').textContent).toMatch(/in your library when the list began/i);
+ await fireEvent.click(screen.getByRole('button', { name: /take the ballad of darren off the list/i }));
+ await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w1'));
});
- test("the held tab says while the library is being listed, and what could not be", () => {
- setup([], "held", { listed_at: null, unlisted: 0 });
- expect(screen.getByTestId("library-listing").textContent).toMatch(
- /being added to this list/i,
- );
+ test('the held tab says while the library is being listed', () => {
+ setup([], 'held', { listed_at: null, unlisted: 0, unmatched: [] });
+ expect(screen.getByTestId('library-listing').textContent).toMatch(/being added to this list/i);
});
- test("the held tab names how many albums nothing names to MusicBrainz", () => {
- setup([want("w1", { held_at: ago(DAY) })], "held", {
+ test('the held tab names the albums no MusicBrainz album matched, on request', async () => {
+ setup([want('w1', { held_at: ago(DAY) })], 'held', {
listed_at: ago(DAY),
unlisted: 12,
+ unmatched: [
+ { album_id: 'al-9', title: 'Demo Tape', artist: 'Blur', detail: 'MusicBrainz has no album called "Demo Tape" by Blur.' }
+ ]
});
- expect(screen.getByTestId("library-listing").textContent).toMatch(
- /12 albums in your library\s+aren't on this list/i,
- );
+ expect(screen.getByTestId('library-listing').textContent).toMatch(/12 albums in your library\s+aren't on this list/i);
+ expect(screen.queryByTestId('unmatched-albums')).toBeNull();
+ await fireEvent.click(screen.getByRole('button', { name: /show them/i }));
+ const list = screen.getByTestId('unmatched-albums');
+ expect(list.textContent).toMatch(/Demo Tape/);
+ expect(list.textContent).toMatch(/has no album called/);
+ expect(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
+ expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
});
- test("an empty list explains what lands there", () => {
+ test('an empty list explains what lands there', () => {
setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
});
- test("each open want says what its last search found", () => {
+ test('each open want says what its last search found', () => {
setup([
- want("w1"),
- want("w2", {
- title: "Parklife",
- search: {
- searched_at: ago(3_600_000),
- outcome: "nothing",
- considered: 4,
- best_release: "x",
- best_verdict: "rejected",
- error: "",
- },
- }),
- want("w3", { title: "Blur", grab: grab() }),
- want("w4", {
- title: "13",
- grab: grab({ import_state: "downloading", progress: 0.42 }),
+ want('w1'),
+ want('w2', {
+ title: 'Parklife',
+ search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 4, best_release: 'x', best_verdict: 'rejected', error: '' }
}),
+ want('w3', { title: 'Blur', grab: grab() }),
+ want('w4', { title: '13', grab: grab({ import_state: 'downloading', progress: 0.42 }) })
]);
- const lines = screen
- .getAllByTestId("want-search")
- .map((e) => e.textContent ?? "");
+ const lines = screen.getAllByTestId('want-search').map((e) => e.textContent ?? '');
expect(lines[0]).toMatch(/not searched yet/i);
expect(lines[1]).toMatch(/4 found, none of them this album/i);
- expect(lines[2]).toMatch(
- /downloading blur - blur \(1997\) \[flac\], taken by the schedule/i,
- );
+ expect(lines[2]).toMatch(/downloading blur - blur \(1997\) \[flac\], taken by the schedule/i);
expect(lines[3]).toMatch(/downloading 42%/i);
});
- test("a download waiting on a person says why and points to Activity", () => {
+ test('a download waiting on a person says why and points to Activity', () => {
setup([
- want("w1", {
- grab: grab({
- import_state: "review",
- import_reason: "Only the lengths agree. A person decides.",
- }),
- }),
+ want('w1', { grab: grab({ import_state: 'review', import_reason: 'Only the lengths agree. A person decides.' }) })
]);
- const line = screen.getByTestId("want-search");
+ const line = screen.getByTestId('want-search');
expect(line.textContent).toMatch(/needs you: only the lengths agree/i);
- expect(
- screen
- .getByRole("link", { name: /decide on activity/i })
- .getAttribute("href"),
- ).toBe("/wanted/activity");
+ expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/wanted/activity');
// A live download can't be searched for again.
- expect(
- (
- screen.getByRole("button", {
- name: /search for the ballad of darren now/i,
- }) as HTMLButtonElement
- ).disabled,
- ).toBe(true);
+ expect((screen.getByRole('button', { name: /search for the ballad of darren now/i }) as HTMLButtonElement).disabled).toBe(true);
});
- test("a refused download is named beside the search that follows it", () => {
+ test('a refused download is named beside the search that follows it', () => {
setup([
- want("w1", {
- search: {
- searched_at: ago(3_600_000),
- outcome: "nothing",
- considered: 0,
- best_release: "",
- best_verdict: "",
- error: "",
- },
- grab: grab({
- live: false,
- end_reason: "refused",
- import_state: "refused",
- import_reason: "Another album.",
- }),
- }),
+ want('w1', {
+ search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', error: '' },
+ grab: grab({ live: false, end_reason: 'refused', import_state: 'refused', import_reason: 'Another album.' })
+ })
]);
- expect(screen.getByTestId("want-search").textContent).toMatch(
- /last download refused: another album/i,
- );
+ expect(screen.getByTestId('want-search').textContent).toMatch(/last download refused: another album/i);
});
- test("a download given up on is named beside the search that follows it", () => {
+ test('a download given up on is named beside the search that follows it', () => {
setup([
- want("w1", {
- search: {
- searched_at: ago(3_600_000),
- outcome: "waiting",
- considered: 4,
- best_release: "",
- best_verdict: "",
- error: "",
- },
- grab: grab({
- live: false,
- end_reason: "stalled",
- import_state: "downloading",
- end_detail: "Nobody was sharing it.",
- }),
- }),
+ want('w1', {
+ search: { searched_at: ago(3_600_000), outcome: 'waiting', considered: 4, best_release: '', best_verdict: '', error: '' },
+ grab: grab({ live: false, end_reason: 'stalled', import_state: 'downloading', end_detail: 'Nobody was sharing it.' })
+ })
]);
- expect(screen.getByTestId("want-search").textContent).toMatch(
- /last download given up: nobody was sharing it/i,
- );
+ expect(screen.getByTestId('want-search').textContent).toMatch(/last download given up: nobody was sharing it/i);
});
- test("a held album says how it arrived", () => {
+ test('a held album says how it arrived', () => {
setup(
[
- want("w1", {
+ want('w1', {
held_at: ago(DAY),
- album_id: "al-1",
- grab: grab({
- live: false,
- end_reason: "imported",
- import_state: "imported",
- imported_at: ago(DAY),
- imported_files: 11,
- }),
- }),
+ album_id: 'al-1',
+ grab: grab({ live: false, end_reason: 'imported', import_state: 'imported', imported_at: ago(DAY), imported_files: 11 })
+ })
],
- "held",
- );
- expect(screen.getByTestId("want-arrived").textContent).toMatch(
- /imported 11 tracks .* from blur - blur/i,
+ 'held'
);
+ expect(screen.getByTestId('want-arrived').textContent).toMatch(/imported 11 tracks .* from blur - blur/i);
});
- test("Search runs one now and says what came of it", async () => {
+ test('Search runs one now and says what came of it', async () => {
setup();
- await fireEvent.click(
- screen.getByRole("button", { name: /search for parklife now/i }),
- );
- await waitFor(() => expect(searchWant).toHaveBeenCalledWith("w2"));
- await waitFor(() =>
- expect(pushToast).toHaveBeenCalledWith(
- "Nothing found for Parklife.",
- "info",
- ),
- );
+ await fireEvent.click(screen.getByRole('button', { name: /search for parklife now/i }));
+ await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w2'));
+ await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Parklife.', 'info'));
});
- test("Releases opens what the last search found", async () => {
+ test('Releases opens what the last search found', async () => {
setup();
- expect(screen.queryByTestId("want-candidates")).toBeNull();
- const toggle = screen.getByRole("button", {
- name: /releases found for parklife/i,
- });
+ expect(screen.queryByTestId('want-candidates')).toBeNull();
+ const toggle = screen.getByRole('button', { name: /releases found for parklife/i });
await fireEvent.click(toggle);
- expect(toggle.getAttribute("aria-expanded")).toBe("true");
- expect(screen.getByTestId("want-candidates")).toBeTruthy();
- expect(createCandidatesQuery).toHaveBeenCalledWith("w2");
+ expect(toggle.getAttribute('aria-expanded')).toBe('true');
+ expect(screen.getByTestId('want-candidates')).toBeTruthy();
+ expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
});
});
diff --git a/web/src/routes/wanted/wanted.test.ts b/web/src/routes/wanted/wanted.test.ts
index 547b9c1a..4b1bb38d 100644
--- a/web/src/routes/wanted/wanted.test.ts
+++ b/web/src/routes/wanted/wanted.test.ts
@@ -1,35 +1,23 @@
-import { afterEach, describe, expect, test, vi } from "vitest";
-import { render, screen } from "@testing-library/svelte";
-import { createRawSnippet } from "svelte";
-import { mockInfiniteQuery } from "../../test-utils/query";
-import { pageUrlModule } from "#test-utils/mocks/appState.js";
-import type { WantCounts, WantsPage } from "#lib/api/types.js";
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import { createRawSnippet } from 'svelte';
+import { mockInfiniteQuery } from '../../test-utils/query';
+import { pageUrlModule } from '#test-utils/mocks/appState.js';
+import type { WantCounts, WantsPage } from '#lib/api/types.js';
-const state = vi.hoisted(() => ({
- pageUrl: new URL("http://localhost/wanted"),
-}));
-vi.mock("$app/state", () => pageUrlModule(state));
-vi.mock("#lib/api/wanted.js", () => ({ createWantsQuery: vi.fn() }));
+const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/wanted') }));
+vi.mock('$app/state', () => pageUrlModule(state));
+vi.mock('#lib/api/wanted.js', () => ({ createWantsQuery: vi.fn() }));
-import WantedLayout from "./+layout.svelte";
-import { createWantsQuery } from "#lib/api/wanted.js";
+import WantedLayout from './+layout.svelte';
+import { createWantsQuery } from '#lib/api/wanted.js';
-const counts: WantCounts = {
- wanted: 5,
- missing: 1,
- held: 9,
- following: 2,
- activity: 3,
- attention: 0,
- blocked: 4,
-};
-const children = createRawSnippet(() => ({
- render: () => `body
`,
-}));
+const counts: WantCounts = { wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
+const children = createRawSnippet(() => ({ render: () => `body
` }));
afterEach(() => vi.clearAllMocks());
-function setup(c: WantCounts = counts, path = "/wanted") {
+function setup(c: WantCounts = counts, path = '/wanted') {
state.pageUrl = new URL(`http://localhost${path}`);
const page: WantsPage = {
items: [],
@@ -37,58 +25,44 @@ function setup(c: WantCounts = counts, path = "/wanted") {
limit: 50,
offset: 0,
counts: c,
- library: { listed_at: null, unlisted: 0 },
+ library: { listed_at: null, unlisted: 0, unmatched: [] }
};
vi.mocked(createWantsQuery).mockReturnValue(
- mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<
- typeof createWantsQuery
- >,
+ mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return render(WantedLayout, { children } as any);
}
-describe("/wanted layout", () => {
- test("tabs are links carrying their counts, the current one marked", () => {
- setup(counts, "/wanted/activity");
- const nav = screen.getByRole("navigation", { name: /wanted sections/i });
- const links = Array.from(nav.querySelectorAll("a")).map((a) => [
- a.getAttribute("href"),
- a.textContent?.replace(/\s+/g, " ").trim(),
- ]);
+describe('/wanted layout', () => {
+ test('tabs are links carrying their counts, the current one marked', () => {
+ setup(counts, '/wanted/activity');
+ const nav = screen.getByRole('navigation', { name: /wanted sections/i });
+ const links = Array.from(nav.querySelectorAll('a')).map((a) => [a.getAttribute('href'), a.textContent?.replace(/\s+/g, ' ').trim()]);
expect(links).toEqual([
- ["/wanted", "Wanted 5"],
- ["/wanted/activity", "Activity 3"],
- ["/wanted/missing", "Missing files 1"],
- ["/wanted/held", "Held 9"],
- ["/wanted/following", "Following 2"],
- ["/wanted/blocked", "Blocked 4"],
+ ['/wanted', 'Wanted 5'],
+ ['/wanted/activity', 'Activity 3'],
+ ['/wanted/missing', 'Missing files 1'],
+ ['/wanted/held', 'Held 9'],
+ ['/wanted/following', 'Following 2'],
+ ['/wanted/blocked', 'Blocked 4']
]);
- expect(screen.getByRole("link", { name: /activity/i })).toHaveAttribute(
- "aria-current",
- "page",
- );
- expect(screen.getByRole("link", { name: /^wanted/i })).not.toHaveAttribute(
- "aria-current",
- );
- expect(screen.getByTestId("tab-body")).toBeTruthy();
+ expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
+ expect(screen.getByRole('link', { name: /^wanted/i })).not.toHaveAttribute('aria-current');
+ expect(screen.getByTestId('tab-body')).toBeTruthy();
});
- test("when nothing needs a person the verdict says so", () => {
+ test('when nothing needs a person the verdict says so', () => {
setup();
- expect(screen.getByTestId("wanted-verdict").textContent).toMatch(
- /5 albums wanted, 3 downloading\. Nothing needs you\./,
- );
- expect(screen.queryByTestId("tab-attention")).toBeNull();
+ expect(screen.getByTestId('wanted-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
+ expect(screen.queryByTestId('tab-attention')).toBeNull();
});
- test("downloads waiting on a person lead the page and mark Activity", () => {
+ test('downloads waiting on a person lead the page and mark Activity', () => {
setup({ ...counts, attention: 2 });
- const verdict = screen.getByTestId("wanted-verdict");
+ const verdict = screen.getByTestId('wanted-verdict');
expect(verdict.textContent).toMatch(/2 downloads need you/);
- expect(
- screen.getByRole("link", { name: /see activity/i }).getAttribute("href"),
- ).toBe("/wanted/activity");
- expect(screen.getByTestId("tab-attention").textContent).toBe("2");
+ expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/wanted/activity');
+ expect(screen.getByTestId('tab-attention').textContent).toBe('2');
});
});
--
2.54.0
From 80d2fdc3aba91949d98d30dc97158b416558a316 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 17:29:42 -0400
Subject: [PATCH 020/117] fix: under the album's own name, matching track
titles are enough to match it; a refusal says which track differed and how
(M502 #5569)
The first run on the library refused singles whose one track has the
album's title ("0 of 1 agree") and near-complete albums (11 of 14), because
every track's length had to fit as well. Lengths now only decide between
groups whose names merely overlap. The reason for a refusal names the first
track that differed, with both titles and lengths, so what is left can be
judged. Migration 0087 clears the refusals so those albums are matched
again.
Co-Authored-By: Claude Opus 5.5
---
.../migrations/0087_library_rematch.down.sql | 2 +
.../db/migrations/0087_library_rematch.up.sql | 9 ++
internal/wanted/library_test.go | 14 ++-
internal/wanted/match.go | 88 +++++++++++++------
4 files changed, 83 insertions(+), 30 deletions(-)
create mode 100644 internal/db/migrations/0087_library_rematch.down.sql
create mode 100644 internal/db/migrations/0087_library_rematch.up.sql
diff --git a/internal/db/migrations/0087_library_rematch.down.sql b/internal/db/migrations/0087_library_rematch.down.sql
new file mode 100644
index 00000000..66b5e525
--- /dev/null
+++ b/internal/db/migrations/0087_library_rematch.down.sql
@@ -0,0 +1,2 @@
+-- Nothing to undo: the refused albums are matched again either way.
+SELECT 1;
diff --git a/internal/db/migrations/0087_library_rematch.up.sql b/internal/db/migrations/0087_library_rematch.up.sql
new file mode 100644
index 00000000..7347fff3
--- /dev/null
+++ b/internal/db/migrations/0087_library_rematch.up.sql
@@ -0,0 +1,9 @@
+-- The first matching (#5569) required every track's length to fit as well
+-- as its title, and refused singles and near-complete albums the library
+-- plainly holds. Matching now takes the titles alone under the album's own
+-- name: the albums it refused are matched again.
+DELETE FROM library_album_matches WHERE outcome = 'unmatched';
+
+UPDATE acquisition_settings
+ SET library_listed_at = NULL, library_unlisted = 0
+ WHERE library_listing_started_at IS NOT NULL;
diff --git a/internal/wanted/library_test.go b/internal/wanted/library_test.go
index eb865d1a..76410b7e 100644
--- a/internal/wanted/library_test.go
+++ b/internal/wanted/library_test.go
@@ -252,10 +252,22 @@ func TestTrackAgreement(t *testing.T) {
{"the right title at the wrong length", []libraryTrack{{Number: 2, Title: "Song", LengthMs: 400000}}, 0},
{"a track counted once", []libraryTrack{{Title: "Song", LengthMs: 200000}, {Title: "Song", LengthMs: 200000}}, 1},
} {
- if got := trackAgreement(c.tracks, edition); got != c.want {
+ if got, _ := trackAgreement(c.tracks, edition, false); got != c.want {
t.Errorf("%s: %d agree, want %d", c.name, got, c.want)
}
}
+ // Under the album's own name the titles are enough; the reason a track
+ // did not agree names both sides.
+ edit := []libraryTrack{{Number: 2, Title: "Song (Radio Edit)", LengthMs: 150000}}
+ if n, miss := trackAgreement(edit, edition, false); n != 0 || miss != `"Song (Radio Edit)" runs 2:30 here; MusicBrainz's track 2 is "Song", 3:20` {
+ t.Errorf("by length: %d, %q", n, miss)
+ }
+ if n, _ := trackAgreement(edit, edition, true); n != 1 {
+ t.Errorf("by title alone: %d agree", n)
+ }
+ if _, miss := trackAgreement([]libraryTrack{{Title: "Other", LengthMs: 61000}}, edition, true); miss != `no track of its edition is like "Other" (1:01)` {
+ t.Errorf("unplaced: %q", miss)
+ }
if agrees(3, 4) || !agrees(4, 5) || agrees(0, 0) {
t.Error("agrees: 80% of the album's tracks, and an album has tracks")
}
diff --git a/internal/wanted/match.go b/internal/wanted/match.go
index bcdf3ea8..04c7527e 100644
--- a/internal/wanted/match.go
+++ b/internal/wanted/match.go
@@ -101,10 +101,14 @@ func tracksToMatch(rows []dbq.ListAlbumTracksToMatchRow) []libraryTrack {
}
// trackAgreement counts the album's tracks an edition agrees with: the same
-// recording, or a length that fits and a title that agrees. A track is
-// paired by its disc and number first, then by title, so an album missing
-// some tracks still pairs the ones it has.
-func trackAgreement(tracks []libraryTrack, edition []editionTrack) int {
+// recording, or a title that agrees at a length that fits. When the group is
+// called just what the album is called, the titles alone are enough: the
+// lengths are there to tell apart albums whose names only overlap, and a
+// rip's single may be the edit MusicBrainz lists at another length. A track
+// is paired by its disc and number first, then by title, so an album missing
+// some tracks still pairs the ones it has. miss says how the first track
+// that did not agree differs, for the reason a match was refused.
+func trackAgreement(tracks []libraryTrack, edition []editionTrack, titlesEnough bool) (n int, miss string) {
byPos := make(map[[2]int]int, len(edition))
for i, e := range edition {
byPos[[2]int{max(e.Medium, 1), e.Position}] = i
@@ -113,27 +117,47 @@ func trackAgreement(tracks []libraryTrack, edition []editionTrack) int {
if t.Recording != "" && strings.EqualFold(t.Recording, e.Recording) {
return true
}
- return release.LengthFits(t.LengthMs, e.LengthMs) && release.TitleAgrees(t.Title, e.Title)
+ return release.TitleAgrees(t.Title, e.Title) && (titlesEnough || release.LengthFits(t.LengthMs, e.LengthMs))
}
used := make([]bool, len(edition))
- n := 0
for _, t := range tracks {
+ at, placed := -1, false
if t.Number > 0 {
- if i, ok := byPos[[2]int{max(t.Disc, 1), t.Number}]; ok && !used[i] && agree(t, edition[i]) {
- used[i] = true
- n++
- continue
+ if i, ok := byPos[[2]int{max(t.Disc, 1), t.Number}]; ok {
+ at = i
+ placed = !used[i] && agree(t, edition[i])
+ if placed {
+ used[i] = true
+ }
}
}
for i, e := range edition {
- if !used[i] && agree(t, e) {
- used[i] = true
- n++
- break
+ if !placed && !used[i] && agree(t, e) {
+ used[i], placed = true, true
}
}
+ switch {
+ case placed:
+ n++
+ case miss != "":
+ case at >= 0:
+ e := edition[at]
+ miss = fmt.Sprintf("%q runs %s here; MusicBrainz's track %d is %q, %s",
+ t.Title, minutes(t.LengthMs), e.Position, e.Title, minutes(e.LengthMs))
+ default:
+ miss = fmt.Sprintf("no track of its edition is like %q (%s)", t.Title, minutes(t.LengthMs))
+ }
}
- return n
+ return n, miss
+}
+
+// minutes writes a length as m:ss, or "no length".
+func minutes(ms int) string {
+ if ms <= 0 {
+ return "no length"
+ }
+ sec := (ms + 500) / 1000
+ return fmt.Sprintf("%d:%02d", sec/60, sec%60)
}
// agrees: enough of the album's tracks agree for the edition to be it.
@@ -173,14 +197,14 @@ func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tr
// discography first, then a search.
tried := map[string]bool{}
var best albumMatch
- bestN, bestEdition, considered := -1, 0, 0
+ bestN, bestEdition, considered, bestMiss := -1, 0, 0, ""
check := func(cands []candidate) error {
for _, c := range cands {
if tried[c.Group] || len(tried) >= matchCandidates {
continue
}
tried[c.Group] = true
- n, size, err := s.agreementWith(ctx, c.Group, tracks)
+ n, size, miss, err := s.agreementWith(ctx, c.Group, tracks, sameTitle(c.Title, a.Title))
if err != nil {
return err
}
@@ -189,7 +213,7 @@ func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tr
// one; then the edition nearest the album's length.
st, bt := sameTitle(c.Title, a.Title), sameTitle(best.Title, a.Title)
if n > bestN || (n == bestN && ((st && !bt) || (st == bt && abs(size-len(tracks)) < abs(bestEdition-len(tracks))))) {
- best, bestN, bestEdition = albumMatch{candidate: c}, n, size
+ best, bestN, bestEdition, bestMiss = albumMatch{candidate: c}, n, size, miss
}
}
return nil
@@ -217,9 +241,15 @@ func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tr
if considered == 0 {
return albumMatch{Detail: fmt.Sprintf("MusicBrainz has no album called %q by %s.", a.Title, a.ArtistName)}, nil
}
- return albumMatch{Detail: fmt.Sprintf(
- "MusicBrainz has %d %s called %q by %s, but none has its tracks: the closest, %q, agrees on %d of its %d.",
- considered, plural(considered, "album", "albums"), a.Title, a.ArtistName, best.Title, max(bestN, 0), len(tracks))}, nil
+ detail := fmt.Sprintf("MusicBrainz has %d %s called %q by %s, but none has its tracks: the closest, %q, agrees on %d of its %d",
+ considered, plural(considered, "album", "albums"), a.Title, a.ArtistName, best.Title, max(bestN, 0), len(tracks))
+ switch {
+ case bestN < 0:
+ detail += " (MusicBrainz lists no edition of it)"
+ case bestMiss != "":
+ detail += ": " + bestMiss
+ }
+ return albumMatch{Detail: detail + "."}, nil
}
// groupCandidate reads a group the tags name. A group MusicBrainz does not
@@ -296,15 +326,15 @@ func (s *Service) searchCandidates(ctx context.Context, a dbq.ListAlbumsToMatchR
}
// agreementWith is how many of the album's tracks the group's best edition
-// agrees with, and that edition's length. A group MusicBrainz no longer has
-// agrees with none.
-func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack) (n, size int, err error) {
+// agrees with, that edition's length, and how it differs. A group
+// MusicBrainz no longer has, or lists no edition of, agrees with none (-1).
+func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack, titlesEnough bool) (n, size int, miss string, err error) {
eds, err := s.cat.Releases(ctx, group)
switch {
case errors.Is(err, catalogue.ErrNotFound):
- return -1, 0, nil
+ return -1, 0, "", nil
case err != nil:
- return 0, 0, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
+ return 0, 0, "", fmt.Errorf("%w: %v", errMusicBrainzAway, err)
}
n = -1
for _, e := range eds.Editions {
@@ -313,11 +343,11 @@ func (s *Service) agreementWith(ctx context.Context, group string, tracks []libr
edition = append(edition, editionTrack{Medium: int(t.MediumPosition), Position: int(t.Position),
Title: t.Title, LengthMs: int(t.LengthMs), Recording: t.RecordingMbid})
}
- if got := trackAgreement(tracks, edition); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) {
- n, size = got, len(edition)
+ if got, m := trackAgreement(tracks, edition, titlesEnough); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) {
+ n, size, miss = got, len(edition), m
}
}
- return n, size, nil
+ return n, size, miss, nil
}
// creditedArtist is the artist a want is filed under: the library's own
--
2.54.0
From bf409d5f3f2bac924f12f451c21491c121e93489 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 17:44:06 -0400
Subject: [PATCH 021/117] fix: a second copy of a track counts once, joining
words don't part two titles, and a newer matcher retries an older one's
refusals (M502 #5569)
The second run's refusals were mostly albums holding a track twice (a
two-track single with three files), which no edition can pair, and
release names that differ only in "+" against "/". Tracks are now read
once per disc, number and title, and "and", "&" and "+" count for nothing
when titles are compared; an edition's own title also counts as the
album's name.
library_album_matches records which matcher version decided each album.
Refusals by an older version are matched again, even after the listing is
done, so an improvement reaches them without a migration.
Co-Authored-By: Claude Opus 5.5
---
internal/db/dbq/models.go | 1 +
internal/db/dbq/wanted.sql.go | 45 +++++++++++++------
.../0088_library_matcher_version.down.sql | 1 +
.../0088_library_matcher_version.up.sql | 4 ++
internal/db/queries/wanted.sql | 30 ++++++++-----
internal/wanted/library.go | 28 +++++++++---
internal/wanted/library_test.go | 41 +++++++++++++++++
internal/wanted/match.go | 30 ++++++++++---
8 files changed, 146 insertions(+), 34 deletions(-)
create mode 100644 internal/db/migrations/0088_library_matcher_version.down.sql
create mode 100644 internal/db/migrations/0088_library_matcher_version.up.sql
diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go
index 883db4df..84b05f94 100644
--- a/internal/db/dbq/models.go
+++ b/internal/db/dbq/models.go
@@ -494,6 +494,7 @@ type LibraryAlbumMatch struct {
ReleaseGroupMbid string
Detail string
DecidedAt pgtype.Timestamptz
+ Matcher int32
}
type LibraryChange struct {
diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go
index fbe40c14..4699d712 100644
--- a/internal/db/dbq/wanted.sql.go
+++ b/internal/db/dbq/wanted.sql.go
@@ -20,6 +20,19 @@ func (q *Queries) ClearDefaultReleaseTypeProfile(ctx context.Context) error {
return err
}
+const countAlbumsToRematch = `-- name: CountAlbumsToRematch :one
+SELECT COUNT(*) FROM library_album_matches
+ WHERE outcome = 'unmatched' AND matcher < $1::int
+`
+
+// Refusals by an older matcher, which a finished listing goes back to.
+func (q *Queries) CountAlbumsToRematch(ctx context.Context, matcher int32) (int64, error) {
+ row := q.db.QueryRow(ctx, countAlbumsToRematch, matcher)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
const countWants = `-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
@@ -126,9 +139,9 @@ func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error)
const finishLibraryListing = `-- name: FinishLibraryListing :one
UPDATE acquisition_settings
- SET library_listed_at = now(),
+ SET library_listed_at = COALESCE(library_listed_at, now()),
library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
- WHERE id = true AND library_listed_at IS NULL
+ WHERE id = true
RETURNING library_listed_at, library_unlisted
`
@@ -137,7 +150,8 @@ type FinishLibraryListingRow struct {
LibraryUnlisted int32
}
-// The listing is done: count the albums no match was found for.
+// The listing is done, or done again after a newer matcher's retries:
+// count the albums no match was found for. The first finish is kept.
func (q *Queries) FinishLibraryListing(ctx context.Context) (FinishLibraryListingRow, error) {
row := q.db.QueryRow(ctx, finishLibraryListing)
var i FinishLibraryListingRow
@@ -405,13 +419,15 @@ SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.m
JOIN artists ar ON ar.id = a.artist_id
WHERE a.created_at <= $1::timestamptz
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
- AND NOT EXISTS (SELECT 1 FROM library_album_matches m WHERE m.album_id = a.id)
+ AND NOT EXISTS (SELECT 1 FROM library_album_matches m
+ WHERE m.album_id = a.id AND (m.outcome = 'listed' OR m.matcher >= $2::int))
ORDER BY a.created_at, a.id
- LIMIT $2
+ LIMIT $3
`
type ListAlbumsToMatchParams struct {
Cutoff pgtype.Timestamptz
+ Matcher int32
MaxRows int32
}
@@ -424,11 +440,11 @@ type ListAlbumsToMatchRow struct {
ArtistMbid *string
}
-// Library albums from before the cutoff that the listing has not decided:
-// each is matched to MusicBrainz (#5569). An album row with no tracks is
-// not an album in the library.
+// Library albums from before the cutoff that the listing has not decided,
+// or that an older matcher refused: each is matched to MusicBrainz (#5569).
+// An album row with no tracks is not an album in the library.
func (q *Queries) ListAlbumsToMatch(ctx context.Context, arg ListAlbumsToMatchParams) ([]ListAlbumsToMatchRow, error) {
- rows, err := q.db.Query(ctx, listAlbumsToMatch, arg.Cutoff, arg.MaxRows)
+ rows, err := q.db.Query(ctx, listAlbumsToMatch, arg.Cutoff, arg.Matcher, arg.MaxRows)
if err != nil {
return nil, err
}
@@ -937,11 +953,12 @@ func (q *Queries) MarkGroupSeen(ctx context.Context, arg MarkGroupSeenParams) er
}
const recordAlbumMatch = `-- name: RecordAlbumMatch :exec
-INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid, detail)
-VALUES ($1, $2, $3, $4, $5)
+INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid, detail, matcher)
+VALUES ($1, $2, $3, $4, $5,
+ $6)
ON CONFLICT (album_id) DO UPDATE
- SET outcome = EXCLUDED.outcome, method = EXCLUDED.method,
- release_group_mbid = EXCLUDED.release_group_mbid, detail = EXCLUDED.detail, decided_at = now()
+ SET outcome = EXCLUDED.outcome, method = EXCLUDED.method, release_group_mbid = EXCLUDED.release_group_mbid,
+ detail = EXCLUDED.detail, matcher = EXCLUDED.matcher, decided_at = now()
`
type RecordAlbumMatchParams struct {
@@ -950,6 +967,7 @@ type RecordAlbumMatchParams struct {
Method string
ReleaseGroupMbid string
Detail string
+ Matcher int32
}
func (q *Queries) RecordAlbumMatch(ctx context.Context, arg RecordAlbumMatchParams) error {
@@ -959,6 +977,7 @@ func (q *Queries) RecordAlbumMatch(ctx context.Context, arg RecordAlbumMatchPara
arg.Method,
arg.ReleaseGroupMbid,
arg.Detail,
+ arg.Matcher,
)
return err
}
diff --git a/internal/db/migrations/0088_library_matcher_version.down.sql b/internal/db/migrations/0088_library_matcher_version.down.sql
new file mode 100644
index 00000000..1e22094a
--- /dev/null
+++ b/internal/db/migrations/0088_library_matcher_version.down.sql
@@ -0,0 +1 @@
+ALTER TABLE library_album_matches DROP COLUMN matcher;
diff --git a/internal/db/migrations/0088_library_matcher_version.up.sql b/internal/db/migrations/0088_library_matcher_version.up.sql
new file mode 100644
index 00000000..112d7d6b
--- /dev/null
+++ b/internal/db/migrations/0088_library_matcher_version.up.sql
@@ -0,0 +1,4 @@
+-- Which version of the matching (#5569) decided an album. A refusal by an
+-- older matcher is tried again by a newer one, so improving the matching
+-- reaches the albums it refused without a migration each time.
+ALTER TABLE library_album_matches ADD COLUMN matcher integer NOT NULL DEFAULT 0;
diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql
index 2cb967b1..49077412 100644
--- a/internal/db/queries/wanted.sql
+++ b/internal/db/queries/wanted.sql
@@ -272,18 +272,24 @@ SELECT a.id, 'listed', 'tags', a.release_group_mbid
ON CONFLICT (album_id) DO NOTHING;
-- name: ListAlbumsToMatch :many
--- Library albums from before the cutoff that the listing has not decided:
--- each is matched to MusicBrainz (#5569). An album row with no tracks is
--- not an album in the library.
+-- Library albums from before the cutoff that the listing has not decided,
+-- or that an older matcher refused: each is matched to MusicBrainz (#5569).
+-- An album row with no tracks is not an album in the library.
SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
FROM albums a
JOIN artists ar ON ar.id = a.artist_id
WHERE a.created_at <= sqlc.arg(cutoff)::timestamptz
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
- AND NOT EXISTS (SELECT 1 FROM library_album_matches m WHERE m.album_id = a.id)
+ AND NOT EXISTS (SELECT 1 FROM library_album_matches m
+ WHERE m.album_id = a.id AND (m.outcome = 'listed' OR m.matcher >= sqlc.arg(matcher)::int))
ORDER BY a.created_at, a.id
LIMIT sqlc.arg(max_rows);
+-- name: CountAlbumsToRematch :one
+-- Refusals by an older matcher, which a finished listing goes back to.
+SELECT COUNT(*) FROM library_album_matches
+ WHERE outcome = 'unmatched' AND matcher < sqlc.arg(matcher)::int;
+
-- name: ListAlbumTracksToMatch :many
-- What an album's tracks say about themselves, to check a match against.
SELECT title, disc_number, track_number, duration_ms, mbid
@@ -308,18 +314,20 @@ VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_nam
ON CONFLICT (release_group_mbid) DO NOTHING;
-- name: RecordAlbumMatch :exec
-INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid, detail)
-VALUES (sqlc.arg(album_id), sqlc.arg(outcome), sqlc.arg(method), sqlc.arg(release_group_mbid), sqlc.arg(detail))
+INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid, detail, matcher)
+VALUES (sqlc.arg(album_id), sqlc.arg(outcome), sqlc.arg(method), sqlc.arg(release_group_mbid), sqlc.arg(detail),
+ sqlc.arg(matcher))
ON CONFLICT (album_id) DO UPDATE
- SET outcome = EXCLUDED.outcome, method = EXCLUDED.method,
- release_group_mbid = EXCLUDED.release_group_mbid, detail = EXCLUDED.detail, decided_at = now();
+ SET outcome = EXCLUDED.outcome, method = EXCLUDED.method, release_group_mbid = EXCLUDED.release_group_mbid,
+ detail = EXCLUDED.detail, matcher = EXCLUDED.matcher, decided_at = now();
-- name: FinishLibraryListing :one
--- The listing is done: count the albums no match was found for.
+-- The listing is done, or done again after a newer matcher's retries:
+-- count the albums no match was found for. The first finish is kept.
UPDATE acquisition_settings
- SET library_listed_at = now(),
+ SET library_listed_at = COALESCE(library_listed_at, now()),
library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
- WHERE id = true AND library_listed_at IS NULL
+ WHERE id = true
RETURNING library_listed_at, library_unlisted;
-- name: ListUnmatchedLibraryAlbums :many
diff --git a/internal/wanted/library.go b/internal/wanted/library.go
index 8642d846..ea56759c 100644
--- a/internal/wanted/library.go
+++ b/internal/wanted/library.go
@@ -23,6 +23,15 @@ const libraryMatchBudget = 2 * time.Minute
// libraryMatchBatch is how many albums one query hands the matching.
const libraryMatchBatch = 25
+// libraryMatcher is the matching's version. Raise it when the matching
+// changes in a way that could match an album it refused: albums an older
+// version refused are matched again, even after the listing is done.
+//
+// 1 tags, release, discography and search, checked by title and length
+// 2 titles alone under the album's own name; a refusal names the track
+// 3 a second copy of a track counts once; joining words count for nothing
+const libraryMatcher = 3
+
// listLibrary puts the library's albums on the wanted list as held, once
// (operator, 2026-10-10: "make a record saying that the import has
// happened so later changes like unwanting hold and dont auto import from
@@ -39,7 +48,8 @@ const libraryMatchBatch = 25
// of the albums no match was found for.
//
// Each album decided is recorded in library_album_matches, so an album taken
-// off the list stays off, and once done the listing never runs again.
+// off the list stays off. Once done the listing runs again only for albums
+// an older matcher refused (libraryMatcher).
func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
state, err := q.GetLibraryListing(ctx)
@@ -47,7 +57,13 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
return fmt.Errorf("wanted: read the library listing: %w", err)
}
if state.LibraryListedAt.Valid {
- return nil
+ stale, err := q.CountAlbumsToRematch(ctx, libraryMatcher)
+ if err != nil {
+ return fmt.Errorf("wanted: count refusals to retry: %w", err)
+ }
+ if stale == 0 {
+ return nil
+ }
}
cutoff := state.LibraryListingStartedAt
if !cutoff.Valid {
@@ -67,7 +83,8 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
deadline := s.now().Add(libraryMatchBudget)
for {
- albums, err := q.ListAlbumsToMatch(ctx, dbq.ListAlbumsToMatchParams{Cutoff: cutoff, MaxRows: libraryMatchBatch})
+ albums, err := q.ListAlbumsToMatch(ctx, dbq.ListAlbumsToMatchParams{
+ Cutoff: cutoff, Matcher: libraryMatcher, MaxRows: libraryMatchBatch})
if err != nil {
return fmt.Errorf("wanted: albums to match: %w", err)
}
@@ -107,7 +124,8 @@ func (s *Service) listAlbum(ctx context.Context, q *dbq.Queries, a dbq.ListAlbum
}
if !m.OK {
s.logger.Info("wanted: no MusicBrainz match for a library album", "album", a.Title, "artist", a.ArtistName, "why", m.Detail)
- return 0, q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{AlbumID: a.ID, Outcome: "unmatched", Detail: m.Detail})
+ return 0, q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
+ AlbumID: a.ID, Outcome: "unmatched", Detail: m.Detail, Matcher: libraryMatcher})
}
var listed int64
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
@@ -124,7 +142,7 @@ func (s *Service) listAlbum(ctx context.Context, q *dbq.Queries, a dbq.ListAlbum
}
listed = n
return tq.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
- AlbumID: a.ID, Outcome: "listed", Method: m.Method, ReleaseGroupMbid: m.Group,
+ AlbumID: a.ID, Outcome: "listed", Method: m.Method, ReleaseGroupMbid: m.Group, Matcher: libraryMatcher,
})
})
if err != nil {
diff --git a/internal/wanted/library_test.go b/internal/wanted/library_test.go
index 76410b7e..029252ab 100644
--- a/internal/wanted/library_test.go
+++ b/internal/wanted/library_test.go
@@ -268,7 +268,48 @@ func TestTrackAgreement(t *testing.T) {
if _, miss := trackAgreement([]libraryTrack{{Title: "Other", LengthMs: 61000}}, edition, true); miss != `no track of its edition is like "Other" (1:01)` {
t.Errorf("unplaced: %q", miss)
}
+ if !sameTitle("Brain Stew + Jaded", "Brain Stew / Jaded") || sameTitle("+++", "Grind & Hustle") || sameTitle("Blur", "The Best of Blur") {
+ t.Error("sameTitle: joining words count for nothing, and a title of none is no title")
+ }
+ n := int32(1)
+ copies := tracksToMatch([]dbq.ListAlbumTracksToMatchRow{
+ {Title: "Song", TrackNumber: &n, DurationMs: 1}, {Title: "song", TrackNumber: &n, DurationMs: 2}, {Title: "Other", TrackNumber: &n}})
+ if len(copies) != 2 {
+ t.Errorf("a second copy of a track is read once: %+v", copies)
+ }
if agrees(3, 4) || !agrees(4, 5) || agrees(0, 0) {
t.Error("agrees: 80% of the album's tracks, and an album has tracks")
}
}
+
+// A refusal by an older matcher is matched again once the matching improves,
+// even after the listing is done; a refusal by this one is not.
+func TestListLibrary_RetriesAnOlderMatchersRefusals_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ s := newService(t, pool, newFakeMB(t))
+ ballad := seedAlbum(t, q, blur, "The Ballad of Darren", "", []string{"The Ballad", "St. Charles Square"})
+ bootleg := seedAlbum(t, q, blur, "Bootleg", "", []string{"Intro"})
+ for _, a := range []pgtype.UUID{ballad.ID, bootleg.ID} {
+ if err := q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{AlbumID: a, Outcome: "unmatched", Detail: "old", Matcher: 1}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := pool.Exec(ctx, `UPDATE library_album_matches SET matcher = $1 WHERE album_id = $2`, libraryMatcher, bootleg.ID); err != nil {
+ t.Fatal(err)
+ }
+ // The reset leaves the listing done.
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ if outcome, method, _, _ := matchOf(t, pool, ballad.ID); outcome != "listed" || method != matchDiscography {
+ t.Errorf("the older refusal was decided %s by %s", outcome, method)
+ }
+ if _, _, _, detail := matchOf(t, pool, bootleg.ID); detail != "old" {
+ t.Errorf("this matcher's own refusal was retried: %q", detail)
+ }
+ if state, _ := q.GetLibraryListing(ctx); state.LibraryUnlisted != 1 {
+ t.Errorf("unlisted = %d, want the bootleg alone", state.LibraryUnlisted)
+ }
+}
diff --git a/internal/wanted/match.go b/internal/wanted/match.go
index 04c7527e..9d9d8c99 100644
--- a/internal/wanted/match.go
+++ b/internal/wanted/match.go
@@ -85,8 +85,13 @@ type albumMatch struct {
Detail string
}
+// tracksToMatch reads an album's tracks, each once: a second copy of a
+// track (the same disc, number and title, as a duplicate file in another
+// format is) would otherwise count against the match as a track no edition
+// has.
func tracksToMatch(rows []dbq.ListAlbumTracksToMatchRow) []libraryTrack {
out := make([]libraryTrack, 0, len(rows))
+ seen := make(map[string]bool, len(rows))
for _, r := range rows {
t := libraryTrack{Title: r.Title, LengthMs: int(r.DurationMs), Recording: deref(r.Mbid)}
if r.DiscNumber != nil {
@@ -95,6 +100,11 @@ func tracksToMatch(rows []dbq.ListAlbumTracksToMatchRow) []libraryTrack {
if r.TrackNumber != nil {
t.Number = int(*r.TrackNumber)
}
+ key := fmt.Sprintf("%d/%d/%s", max(t.Disc, 1), t.Number, strings.Join(release.Tokens(t.Title), " "))
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
out = append(out, t)
}
return out
@@ -165,9 +175,15 @@ func agrees(n, tracks int) bool {
return tracks > 0 && n*10 >= tracks*matchAgreement
}
-// sameTitle: the same words, not merely one holding the other.
+// sameTitle: the same words, not merely one holding the other. "And",
+// "&" and "+" join two titles however a release spells it ("Brain Stew +
+// Jaded", "Brain Stew / Jaded"), so they count for nothing.
func sameTitle(a, b string) bool {
- return slices.Equal(release.Tokens(a), release.Tokens(b))
+ words := func(s string) []string {
+ return slices.DeleteFunc(release.Tokens(s), func(w string) bool { return w == "and" })
+ }
+ wa, wb := words(a), words(b)
+ return len(wa) > 0 && slices.Equal(wa, wb)
}
// matchAlbum finds the release group a library album is. A MusicBrainz
@@ -204,7 +220,9 @@ func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tr
continue
}
tried[c.Group] = true
- n, size, miss, err := s.agreementWith(ctx, c.Group, tracks, sameTitle(c.Title, a.Title))
+ n, size, miss, err := s.agreementWith(ctx, c.Group, tracks, func(edition string) bool {
+ return sameTitle(c.Title, a.Title) || sameTitle(edition, a.Title)
+ })
if err != nil {
return err
}
@@ -328,7 +346,9 @@ func (s *Service) searchCandidates(ctx context.Context, a dbq.ListAlbumsToMatchR
// agreementWith is how many of the album's tracks the group's best edition
// agrees with, that edition's length, and how it differs. A group
// MusicBrainz no longer has, or lists no edition of, agrees with none (-1).
-func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack, titlesEnough bool) (n, size int, miss string, err error) {
+// ownName says whether an edition, by its title, is called what the album
+// is, so that its track titles are enough.
+func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack, ownName func(edition string) bool) (n, size int, miss string, err error) {
eds, err := s.cat.Releases(ctx, group)
switch {
case errors.Is(err, catalogue.ErrNotFound):
@@ -343,7 +363,7 @@ func (s *Service) agreementWith(ctx context.Context, group string, tracks []libr
edition = append(edition, editionTrack{Medium: int(t.MediumPosition), Position: int(t.Position),
Title: t.Title, LengthMs: int(t.LengthMs), Recording: t.RecordingMbid})
}
- if got, m := trackAgreement(tracks, edition, titlesEnough); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) {
+ if got, m := trackAgreement(tracks, edition, ownName(e.Release.Title)); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) {
n, size, miss = got, len(edition), m
}
}
--
2.54.0
From 1936fe77c647f8cda64df450a067dd33c5f845e8 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 17:51:42 -0400
Subject: [PATCH 022/117] fix: the matcher-retry test stamps the listing's
cutoff after its albums exist, as a listing done before them would not see
them (M502 #5569)
Co-Authored-By: Claude Opus 5.5
---
internal/wanted/library_test.go | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/internal/wanted/library_test.go b/internal/wanted/library_test.go
index 029252ab..7f1a6187 100644
--- a/internal/wanted/library_test.go
+++ b/internal/wanted/library_test.go
@@ -299,7 +299,10 @@ func TestListLibrary_RetriesAnOlderMatchersRefusals_Integration(t *testing.T) {
if _, err := pool.Exec(ctx, `UPDATE library_album_matches SET matcher = $1 WHERE album_id = $2`, libraryMatcher, bootleg.ID); err != nil {
t.Fatal(err)
}
- // The reset leaves the listing done.
+ // A listing done, with these albums from before its cutoff.
+ if _, err := pool.Exec(ctx, `UPDATE acquisition_settings SET library_listing_started_at = now(), library_listed_at = now()`); err != nil {
+ t.Fatal(err)
+ }
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
t.Fatal(err)
}
--
2.54.0
From 7ef1fca90c58e60d1bc82a2b2c73dc27b8eea1f0 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 18:14:56 -0400
Subject: [PATCH 023/117] fix: an album holding every track of an edition, and
more besides, is that release group (M502 #5569)
Savant's "Void" folder holds all 23 tracks of the VOID (DLC) edition plus
20 more, and was refused at 23 of 43. A whole edition of three or more
tracks now matches whatever else the folder holds; singles and two-track
releases still need most of the album's tracks, since many albums hold
their songs. Matcher version 4 retries the refusals.
Co-Authored-By: Claude Opus 5.5
---
internal/wanted/library.go | 3 ++-
internal/wanted/library_test.go | 5 ++++-
internal/wanted/match.go | 13 ++++++++-----
3 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/internal/wanted/library.go b/internal/wanted/library.go
index ea56759c..051fbbf1 100644
--- a/internal/wanted/library.go
+++ b/internal/wanted/library.go
@@ -30,7 +30,8 @@ const libraryMatchBatch = 25
// 1 tags, release, discography and search, checked by title and length
// 2 titles alone under the album's own name; a refusal names the track
// 3 a second copy of a track counts once; joining words count for nothing
-const libraryMatcher = 3
+// 4 an album holding every track of an edition, and more, is that group
+const libraryMatcher = 4
// listLibrary puts the library's albums on the wanted list as held, once
// (operator, 2026-10-10: "make a record saying that the import has
diff --git a/internal/wanted/library_test.go b/internal/wanted/library_test.go
index 7f1a6187..f503d67d 100644
--- a/internal/wanted/library_test.go
+++ b/internal/wanted/library_test.go
@@ -277,9 +277,12 @@ func TestTrackAgreement(t *testing.T) {
if len(copies) != 2 {
t.Errorf("a second copy of a track is read once: %+v", copies)
}
- if agrees(3, 4) || !agrees(4, 5) || agrees(0, 0) {
+ if agrees(3, 4, 0) || !agrees(4, 5, 0) || agrees(0, 0, 0) {
t.Error("agrees: 80% of the album's tracks, and an album has tracks")
}
+ if !agrees(23, 43, 23) || agrees(2, 10, 2) || agrees(22, 43, 23) {
+ t.Error("agrees: a whole edition of three or more tracks, with more besides")
+ }
}
// A refusal by an older matcher is matched again once the matching improves,
diff --git a/internal/wanted/match.go b/internal/wanted/match.go
index 9d9d8c99..f98f7b1f 100644
--- a/internal/wanted/match.go
+++ b/internal/wanted/match.go
@@ -170,9 +170,12 @@ func minutes(ms int) string {
return fmt.Sprintf("%d:%02d", sec/60, sec%60)
}
-// agrees: enough of the album's tracks agree for the edition to be it.
-func agrees(n, tracks int) bool {
- return tracks > 0 && n*10 >= tracks*matchAgreement
+// agrees: enough of the album's tracks agree for the edition to be it, or
+// the album holds every track of the edition and more besides (a folder
+// with the deluxe edition and its demos). A single or a two-track release
+// is not taken on containment: too many albums hold its songs.
+func agrees(n, tracks, edition int) bool {
+ return tracks > 0 && (n*10 >= tracks*matchAgreement || (edition >= 3 && n == edition))
}
// sameTitle: the same words, not merely one holding the other. "And",
@@ -243,7 +246,7 @@ func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tr
if err := check(discography); err != nil {
return albumMatch{}, err
}
- if !agrees(bestN, len(tracks)) {
+ if !agrees(bestN, len(tracks), bestEdition) {
found, err := s.searchCandidates(ctx, a)
if err != nil {
return albumMatch{}, err
@@ -252,7 +255,7 @@ func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tr
return albumMatch{}, err
}
}
- if agrees(bestN, len(tracks)) {
+ if agrees(bestN, len(tracks), bestEdition) {
best.OK = true
return best, nil
}
--
2.54.0
From ca57133c8a7511a086ea9cbdca459ce638f94a67 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 18:39:35 -0400
Subject: [PATCH 024/117] fix: a release group whose title names a live, remix
or demo version is wanted only when the profile allows that version (M502
#5573)
MusicBrainz types My Chemical Romance's "Common People (Recorded For BBC
Radio 1)" as a plain Single, so a studio-only follow wanted it. Passes now
reads the title's qualifier (brackets, or after a dash) for the version it
names and treats it as a secondary type; a version no type names, an
acoustic or instrumental one, no profile passes. The follow check takes off
the list an open follow want the rule now refuses. Judge counts bbc,
sessions and unplugged as live.
Co-Authored-By: Claude Opus 5.5
---
internal/release/match.go | 2 +-
internal/release/release_test.go | 2 +
internal/release/version.go | 79 ++++++++++++++++++++++++++++++++
internal/release/version_test.go | 32 +++++++++++++
internal/wanted/profiles.go | 11 ++++-
internal/wanted/wanted.go | 35 +++++++++++++-
internal/wanted/wanted_test.go | 44 ++++++++++++------
7 files changed, 188 insertions(+), 17 deletions(-)
create mode 100644 internal/release/version.go
create mode 100644 internal/release/version_test.go
diff --git a/internal/release/match.go b/internal/release/match.go
index 77ae1575..3fb25006 100644
--- a/internal/release/match.go
+++ b/internal/release/match.go
@@ -55,7 +55,7 @@ var versions = []struct {
{[]string{"karaoke"}, "a karaoke version", ""},
{[]string{"acapella", "acappella", "accapella", "acapellas"}, "an a cappella version", ""},
{[]string{"remix", "remixes", "remixed"}, "a remix release", "Remix"},
- {[]string{"live", "bootleg"}, "a live recording", "Live"},
+ {[]string{"live", "bootleg", "bbc", "sessions", "unplugged"}, "a live recording", "Live"},
{[]string{"demo", "demos"}, "demo recordings", "Demo"},
// Not "covers": in a release name that is usually the artwork scans.
{[]string{"tribute", "lullaby", "lullabies"}, "a tribute release", ""},
diff --git a/internal/release/release_test.go b/internal/release/release_test.go
index a5c07015..0bde6e41 100644
--- a/internal/release/release_test.go
+++ b/internal/release/release_test.go
@@ -62,6 +62,8 @@ func TestJudge_TheAlbumAndWhatIsNot(t *testing.T) {
"Daft Punk - Random Access Memories (Instrumentals) [MP3]": VetoVersion,
"Daft Punk - Random Access Memories Remixes": VetoVersion,
"Daft Punk - Random Access Memories (Live) FLAC": VetoVersion,
+ "Daft Punk - Random Access Memories (BBC Sessions) FLAC": VetoVersion,
+ "Daft Punk - Random Access Memories Unplugged [MP3]": VetoVersion,
"Daft Punk - Discovery (2001) [FLAC]": VetoTitle,
"Random Access Memories (2013) [FLAC]": VetoArtist,
"Daft Punk - Discography 1997-2013 FLAC": VetoTitle,
diff --git a/internal/release/version.go b/internal/release/version.go
new file mode 100644
index 00000000..c90dbf50
--- /dev/null
+++ b/internal/release/version.go
@@ -0,0 +1,79 @@
+package release
+
+import "strings"
+
+// TitleVersion is the version of a recording a release group's title names
+// in its qualifier: the words in brackets, or after a dash. MusicBrainz
+// does not always type such a group: My Chemical Romance's "Common People
+// (Recorded For BBC Radio 1)" is a plain Single (#5573). Typ is the
+// MusicBrainz secondary type the version is, or "" for one no type names
+// (an acoustic or instrumental version); Word is the word that said so.
+type TitleVersion struct {
+ Word string
+ What string
+ Typ string
+}
+
+// titleVersions are the qualifier words that name a version, in the order
+// they are looked for: "rough mix" is a demo before "mix" is a remix.
+var titleVersions = []struct {
+ words []string
+ what string
+ typ string
+}{
+ {[]string{"demo", "demos", "outtake", "outtakes", "rehearsal", "rough mix"}, "demo recordings", "Demo"},
+ {[]string{"live", "bootleg", "bbc", "session", "sessions", "unplugged", "recorded for", "in concert"}, "a live recording", "Live"},
+ {[]string{"remix", "remixes", "remixed", "rmx", "mix", "mixes"}, "a remix", "Remix"},
+ {[]string{"instrumental", "instrumentals", "acoustic", "karaoke", "acapella", "acappella", "a cappella",
+ "drumless", "nightcore", "slowed", "sped up"}, "another version", ""},
+ {[]string{"commentary", "interview", "interviews"}, "commentary or interviews", "Interview"},
+}
+
+// VersionInTitle reads a release group's title for the version it names,
+// if any. Only the qualifier counts, so "Live Through This" and "Live
+// Forever" name no version.
+func VersionInTitle(title string) (TitleVersion, bool) {
+ for _, q := range qualifiers(title) {
+ tokens := Tokens(q)
+ for _, v := range titleVersions {
+ for _, w := range v.words {
+ if contains(tokens, strings.Fields(w)) {
+ return TitleVersion{Word: w, What: v.what, Typ: v.typ}, true
+ }
+ }
+ }
+ }
+ return TitleVersion{}, false
+}
+
+// qualifiers are the parts of a title in brackets, and what follows a
+// spaced dash.
+func qualifiers(title string) []string {
+ var out []string
+ depth, start := 0, 0
+ for i, c := range title {
+ switch c {
+ case '(', '[', '{':
+ if depth == 0 {
+ start = i + 1
+ }
+ depth++
+ case ')', ']', '}':
+ if depth > 0 {
+ depth--
+ if depth == 0 {
+ out = append(out, title[start:i])
+ }
+ }
+ }
+ }
+ if depth > 0 {
+ out = append(out, title[start:])
+ }
+ for _, dash := range []string{" - ", " – ", " — "} {
+ if i := strings.Index(title, dash); i >= 0 {
+ out = append(out, title[i+len(dash):])
+ }
+ }
+ return out
+}
diff --git a/internal/release/version_test.go b/internal/release/version_test.go
new file mode 100644
index 00000000..c6a877c9
--- /dev/null
+++ b/internal/release/version_test.go
@@ -0,0 +1,32 @@
+package release
+
+import "testing"
+
+func TestVersionInTitle(t *testing.T) {
+ for title, want := range map[string]string{
+ // My Chemical Romance's groups MusicBrainz types as plain Singles (#5573).
+ "Common People (Recorded For BBC Radio 1)": "Live",
+ "Welcome to the Black Parade (Steve Aoki remix)": "Remix",
+ "The Five of Us Are Dying (Rough mix)": "Demo",
+ "I'm Not Okay (I Promise) [outtake version 2]": "Demo",
+ "Na Na Na (live from Sessions@AOL)": "Live",
+ "Song - Live at Wembley": "Live",
+ "Song – Club Mix": "Remix",
+ "Helena (Acoustic)": "",
+ "Teenagers (a cappella)": "",
+ "Hybrid Theory (20th Anniversary Edition)": "none",
+ "Live Through This": "none",
+ "Live Forever": "none",
+ "The Black Parade": "none",
+ "Warped Tour ’05: Bootleg Series (live at Warped Tour": "Live", // unclosed
+ } {
+ v, ok := VersionInTitle(title)
+ got := v.Typ
+ if !ok {
+ got = "none"
+ }
+ if got != want {
+ t.Errorf("VersionInTitle(%q) = %q (%q), want %q", title, got, v.Word, want)
+ }
+ }
+}
diff --git a/internal/wanted/profiles.go b/internal/wanted/profiles.go
index d0a466b1..3cd86f3b 100644
--- a/internal/wanted/profiles.go
+++ b/internal/wanted/profiles.go
@@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// PrimaryTypes and SecondaryTypes are MusicBrainz's release-group types, in
@@ -37,7 +38,12 @@ var (
// on profile p wants: its primary type is listed, and every secondary type
// it carries is allowed. A group with no secondary type is a studio release,
// which every profile passes on its primary type alone.
-func Passes(p dbq.ReleaseTypeProfile, primary string, secondary []string) bool {
+//
+// A version its title names counts as a secondary type MusicBrainz left
+// off (#5573): "Common People (Recorded For BBC Radio 1)" is live whatever
+// its types say, and a version no type names, an acoustic one, no profile
+// passes.
+func Passes(p dbq.ReleaseTypeProfile, title, primary string, secondary []string) bool {
if !containsFold(p.PrimaryTypes, primary) {
return false
}
@@ -46,6 +52,9 @@ func Passes(p dbq.ReleaseTypeProfile, primary string, secondary []string) bool {
return false
}
}
+ if v, ok := release.VersionInTitle(title); ok {
+ return v.Typ != "" && containsFold(p.SecondaryTypes, v.Typ)
+ }
return true
}
diff --git a/internal/wanted/wanted.go b/internal/wanted/wanted.go
index 6f21ec0c..3efbf930 100644
--- a/internal/wanted/wanted.go
+++ b/internal/wanted/wanted.go
@@ -21,6 +21,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/release"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
@@ -275,7 +276,13 @@ func (s *Service) applyFollow(ctx context.Context, q *dbq.Queries, f dbq.Follow)
wanted := 0
for _, g := range groups {
judge := (baseline && f.WantExisting) || (!baseline && !g.Seen && s.recent(g.FirstReleaseDate, f.BaselineAt.Time))
- if judge && Passes(profile, g.PrimaryType, g.SecondaryTypes) {
+ passes := Passes(profile, g.Title, g.PrimaryType, g.SecondaryTypes)
+ if !passes {
+ if err := s.dropRefusedVersion(ctx, q, g.Mbid, g.Title); err != nil {
+ return wanted, err
+ }
+ }
+ if judge && passes {
held, err := q.LibraryHoldsReleaseGroup(ctx, dbq.LibraryHoldsReleaseGroupParams{
GroupMbid: g.Mbid, Title: g.Title, ArtistMbid: f.ArtistMbid,
})
@@ -305,6 +312,32 @@ func (s *Service) applyFollow(ctx context.Context, q *dbq.Queries, f dbq.Follow)
return wanted, q.MarkFollowChecked(ctx, f.ArtistMbid)
}
+// dropRefusedVersion takes off the list a group the follow wanted before
+// Passes read the version its title names (#5573), while it is still only
+// wanted: one the operator holds, or wanted for another reason, stays.
+func (s *Service) dropRefusedVersion(ctx context.Context, q *dbq.Queries, group, title string) error {
+ v, ok := release.VersionInTitle(title)
+ if !ok {
+ return nil
+ }
+ w, err := q.GetWantByReleaseGroup(ctx, group)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("read want %s: %w", title, err)
+ }
+ if w.Reason != ReasonFollow || w.FulfilledAt.Valid {
+ return nil
+ }
+ if _, err := q.DeleteWant(ctx, w.ID); err != nil {
+ return fmt.Errorf("drop want %s: %w", title, err)
+ }
+ s.logger.Info("wanted: a followed artist's release is another version, taken off the list",
+ "want", title, "version", v.What, "word", v.Word)
+ return nil
+}
+
// recent reports whether a release dated date (MusicBrainz's partial date:
// "2026", "2026-10", "2026-10-09", or empty when not yet known) came out no
// earlier than NewReleaseGrace before the follow's baseline. A partial date
diff --git a/internal/wanted/wanted_test.go b/internal/wanted/wanted_test.go
index e0f5270e..a25e9cdf 100644
--- a/internal/wanted/wanted_test.go
+++ b/internal/wanted/wanted_test.go
@@ -30,21 +30,29 @@ func TestPasses_PrimaryListedAndEverySecondaryAllowed(t *testing.T) {
withLive := dbq.ReleaseTypeProfile{PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{"Live"}}
for _, c := range []struct {
profile dbq.ReleaseTypeProfile
+ title string
primary string
secondary []string
want bool
}{
- {standard, "Album", nil, true},
- {standard, "single", nil, true}, // MusicBrainz's case is not relied on
- {standard, "Album", []string{"Live"}, false},
- {standard, "Broadcast", nil, false},
- {withLive, "Album", []string{"Live"}, true},
- {withLive, "Album", []string{"Live", "Compilation"}, false},
- {withLive, "EP", nil, false},
+ {standard, "Revenge", "Album", nil, true},
+ {standard, "Helena", "single", nil, true}, // MusicBrainz's case is not relied on
+ {standard, "Live at Koko", "Album", []string{"Live"}, false},
+ {standard, "Radio Show", "Broadcast", nil, false},
+ {withLive, "Live at Koko", "Album", []string{"Live"}, true},
+ {withLive, "Live & Rare", "Album", []string{"Live", "Compilation"}, false},
+ {withLive, "Like Phantoms, Forever", "EP", nil, false},
+ // MusicBrainz types these plain Singles; their titles say otherwise (#5573).
+ {standard, "Common People (Recorded For BBC Radio 1)", "Single", nil, false},
+ {standard, "Welcome to the Black Parade (Steve Aoki remix)", "Single", nil, false},
+ {standard, "The Five of Us Are Dying (Rough mix)", "Single", nil, false},
+ {withLive, "Desolation Row (live at BBC Maida Vale)", "Album", nil, true},
+ {withLive, "Helena (acoustic)", "Album", nil, false},
+ {standard, "Live Through This", "Album", nil, true},
} {
- if got := Passes(c.profile, c.primary, c.secondary); got != c.want {
- t.Errorf("Passes(%v/%v, %s %v) = %v, want %v", c.profile.PrimaryTypes, c.profile.SecondaryTypes,
- c.primary, c.secondary, got, c.want)
+ if got := Passes(c.profile, c.title, c.primary, c.secondary); got != c.want {
+ t.Errorf("Passes(%v/%v, %q %s %v) = %v, want %v", c.profile.PrimaryTypes, c.profile.SecondaryTypes,
+ c.title, c.primary, c.secondary, got, c.want)
}
}
}
@@ -177,6 +185,7 @@ const (
theBallad = "11111111-0000-4000-8000-000000000003" // 2023 album, not held
newSingle = "11111111-0000-4000-8000-000000000004" // appears after the baseline
oldSingle = "11111111-0000-4000-8000-000000000005" // 1997 single, catalogued late
+ bbcSingle = "11111111-0000-4000-8000-000000000006" // live, typed a plain Single (#5573)
parkRelease = "22222222-0000-4000-8000-000000000001"
)
@@ -206,12 +215,13 @@ func newFakeMB(t *testing.T) *fakeMB {
case r.URL.Path == "/release-group" && r.URL.Query().Get("artist") == blur:
groups := group(parklife, "Parklife", "Album", "", "1994-04-25") + "," +
group(liveAtWemb, "Live at Wembley Stadium", "Album", `"Live"`, "2009-07-02") + "," +
- group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21")
- n := 3
+ group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21") + "," +
+ group(bbcSingle, "Song 2 (Recorded For BBC Radio 1)", "Single", "", "2023-08-01")
+ n := 4
if f.added.Load() {
groups += "," + group(newSingle, "New Single", "Single", "", "") +
"," + group(oldSingle, "Old Single", "Single", "", "1997-02-03")
- n = 5
+ n = 6
}
_, _ = fmt.Fprintf(w, `{"release-group-count": %d, "release-groups": [%s]}`, n, groups)
case r.URL.Path == "/release-group/"+theBallad:
@@ -328,6 +338,11 @@ func TestFollow_WantExistingWantsWhatPassesAndIsNotHeld_Integration(t *testing.T
f := newFakeMB(t)
s := newService(t, pool, f)
_ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2)
+ // Wanted before the follow read the version a title names (#5573).
+ if _, err := WantWith(ctx, q, WantParams{GroupMBID: bbcSingle, ArtistMBID: blur, ArtistName: "Blur",
+ Title: "Song 2 (Recorded For BBC Radio 1)", PrimaryType: "Single", Reason: ReasonFollow}); err != nil {
+ t.Fatal(err)
+ }
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur", WantExisting: true}); err != nil {
t.Fatal(err)
@@ -338,7 +353,8 @@ func TestFollow_WantExistingWantsWhatPassesAndIsNotHeld_Integration(t *testing.T
if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
t.Errorf("the studio album not held should be wanted: %v", err)
}
- for group, why := range map[string]string{parklife: "held", liveAtWemb: "live, and Standard is studio only"} {
+ for group, why := range map[string]string{parklife: "held", liveAtWemb: "live, and Standard is studio only",
+ bbcSingle: "recorded for the BBC, which its title says, and Standard is studio only"} {
if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
t.Errorf("%s wanted, but it is %s", group, why)
}
--
2.54.0
From a4fbe44bd5c70fd816fa33c2b5846fc90bdc99cd Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 18:43:32 -0400
Subject: [PATCH 025/117] =?UTF-8?q?feat:=20the=20Wanted=20view=20lists=20a?=
=?UTF-8?q?rtists=20A=E2=80=93Z=20with=20their=20albums=20inside,=20each?=
=?UTF-8?q?=20album's=20tracks=20a=20click=20away=20(M502=20#5572)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The list runs like the library: artists by the library's sort name, each
artist's albums oldest first, grouped under the artist (linked into the
library when it holds them). A filter narrows it to an artist or album by
name, on the server, so it finds what has not been scrolled to. Tracks
opens an album's track list: the library's own for an album it holds, a
lost file marked, otherwise MusicBrainz's standard edition, named.
Co-Authored-By: Claude Opus 5.5
---
internal/api/admin_wanted.go | 136 +++++++++-
internal/api/admin_wanted_test.go | 213 ++++++++++++++++
internal/api/api.go | 1 +
internal/db/dbq/wanted.sql.go | 89 ++++++-
internal/db/queries/wanted.sql | 28 ++-
web/src/lib/api/queries.ts | 3 +-
web/src/lib/api/types.ts | 7 +
web/src/lib/api/wanted.ts | 32 ++-
web/src/lib/components/wanted/WantList.svelte | 235 +++++++++++-------
.../lib/components/wanted/WantList.test.ts | 72 +++++-
.../lib/components/wanted/WantTracks.svelte | 52 ++++
11 files changed, 753 insertions(+), 115 deletions(-)
create mode 100644 internal/api/admin_wanted_test.go
create mode 100644 web/src/lib/components/wanted/WantTracks.svelte
diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go
index 7ed0b292..852d9cda 100644
--- a/internal/api/admin_wanted.go
+++ b/internal/api/admin_wanted.go
@@ -4,12 +4,14 @@ import (
"errors"
"net/http"
"strconv"
+ "strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
@@ -111,8 +113,9 @@ const unmatchedShown = 200
const wantsPageSize = 50
-// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&offset=.
-// wanted is everything open but missing files; held is newest first.
+// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&q=&offset=.
+// wanted is everything open but missing files. Each list runs artist by
+// artist, A–Z, like the library (#5572); q narrows it to an artist or album.
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
if state == "" {
@@ -127,7 +130,8 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
offset = 0
}
q := dbq.New(h.pool)
- rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Skip: int32(offset), MaxRows: wantsPageSize})
+ filter := strings.TrimSpace(r.URL.Query().Get("q"))
+ rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Q: filter, Skip: int32(offset), MaxRows: wantsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
return
@@ -173,6 +177,12 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
default:
out.Total = counts.Wanted
}
+ if filter != "" {
+ out.Total = 0
+ if len(rows) > 0 {
+ out.Total = rows[0].Total
+ }
+ }
for _, row := range rows {
v := wantView{
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
@@ -227,6 +237,126 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
+// wantTracksView is an album's track list for the Wanted view (#5572): the
+// library's own tracks for an album it holds or held, else MusicBrainz's
+// standard edition of what is wanted.
+type wantTracksView struct {
+ // library, musicbrainz, or none when MusicBrainz lists no edition.
+ Source string `json:"source"`
+ // Edition names the MusicBrainz release the list is from.
+ Edition string `json:"edition,omitempty"`
+ Tracks []wantTrackView `json:"tracks"`
+}
+
+type wantTrackView struct {
+ Disc int32 `json:"disc"`
+ Number int32 `json:"number"`
+ Title string `json:"title"`
+ LengthMs int32 `json:"length_ms"`
+ // Missing: the library's file for it is gone.
+ Missing bool `json:"missing"`
+}
+
+// handleWantTracks implements GET /api/admin/wanted/{id}/tracks.
+func (h *handlers) handleWantTracks(w http.ResponseWriter, r *http.Request) {
+ id, ok := requireURLUUID(w, r, "id")
+ if !ok {
+ return
+ }
+ q := dbq.New(h.pool)
+ want, err := q.GetWantAlbum(r.Context(), id)
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeErr(w, apierror.NotFound("want"))
+ return
+ }
+ if err != nil {
+ writeErrWithLog(w, h.logger, "admin wanted: tracks", apierror.Internal(err))
+ return
+ }
+ out := wantTracksView{Tracks: []wantTrackView{}}
+ if want.AlbumID.Valid {
+ rows, err := q.ListLibraryAlbumTracks(r.Context(), want.AlbumID)
+ if err != nil {
+ writeErrWithLog(w, h.logger, "admin wanted: library tracks", apierror.Internal(err))
+ return
+ }
+ out.Source = "library"
+ for _, t := range rows {
+ v := wantTrackView{Title: t.Title, LengthMs: t.DurationMs, Missing: t.Missing}
+ if t.DiscNumber != nil {
+ v.Disc = *t.DiscNumber
+ }
+ if t.TrackNumber != nil {
+ v.Number = *t.TrackNumber
+ }
+ out.Tracks = append(out.Tracks, v)
+ }
+ writeJSON(w, http.StatusOK, out)
+ return
+ }
+ eds, err := h.catalogue.Releases(r.Context(), want.ReleaseGroupMbid)
+ switch {
+ case errors.Is(err, catalogue.ErrNotFound):
+ out.Source = "none"
+ writeJSON(w, http.StatusOK, out)
+ return
+ case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
+ writeErr(w, &apierror.Error{Status: 503, Code: "musicbrainz_unreachable", Message: "MusicBrainz can't be reached", Cause: err})
+ return
+ case err != nil:
+ writeErrWithLog(w, h.logger, "admin wanted: editions", apierror.Internal(err))
+ return
+ }
+ e, ok := standardEdition(eds.Editions)
+ if !ok {
+ out.Source = "none"
+ writeJSON(w, http.StatusOK, out)
+ return
+ }
+ out.Source = "musicbrainz"
+ out.Edition = editionLabel(e.Release)
+ for _, t := range e.Tracks {
+ out.Tracks = append(out.Tracks, wantTrackView{Disc: t.MediumPosition, Number: t.Position, Title: t.Title, LengthMs: t.LengthMs})
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// standardEdition is the edition a person would call the album: an official
+// one with the fewest tracks, else any with the fewest. The catalogue lists
+// official editions first by date, so a tie goes to the earliest.
+func standardEdition(eds []catalogue.Edition) (catalogue.Edition, bool) {
+ best := -1
+ for i, e := range eds {
+ if len(e.Tracks) == 0 {
+ continue
+ }
+ if best < 0 {
+ best = i
+ continue
+ }
+ b := eds[best]
+ bo, eo := b.Release.Status == "Official", e.Release.Status == "Official"
+ if (eo && !bo) || (eo == bo && len(e.Tracks) < len(b.Tracks)) {
+ best = i
+ }
+ }
+ if best < 0 {
+ return catalogue.Edition{}, false
+ }
+ return eds[best], true
+}
+
+// editionLabel names a release for a person: "2023 · CD · GB".
+func editionLabel(r dbq.MbRelease) string {
+ var parts []string
+ for _, p := range []string{first4(r.ReleaseDate), r.Formats, r.Country} {
+ if p != "" {
+ parts = append(parts, p)
+ }
+ }
+ return strings.Join(parts, " · ")
+}
+
func first4(date string) string {
if len(date) < 4 {
return ""
diff --git a/internal/api/admin_wanted_test.go b/internal/api/admin_wanted_test.go
new file mode 100644
index 00000000..db7f9baa
--- /dev/null
+++ b/internal/api/admin_wanted_test.go
@@ -0,0 +1,213 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/auth"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
+)
+
+func doWantedReq(t *testing.T, h *handlers, path string, user dbq.User) *httptest.ResponseRecorder {
+ t.Helper()
+ r := chi.NewRouter()
+ r.Route("/api/admin", func(admin chi.Router) {
+ admin.Use(auth.RequireAdmin())
+ admin.Get("/wanted", h.handleListWants)
+ admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
+ })
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, withUser(httptest.NewRequest(http.MethodGet, path, nil), user))
+ return w
+}
+
+func seedWant(t *testing.T, h *handlers, group, artistMBID, artist, title, date string) dbq.Want {
+ t.Helper()
+ w, err := wanted.WantWith(context.Background(), dbq.New(h.pool), wanted.WantParams{
+ GroupMBID: group, ArtistMBID: artistMBID, ArtistName: artist, Title: title,
+ PrimaryType: "Album", FirstReleaseDate: date, Reason: wanted.ReasonFollow,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return w
+}
+
+func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage {
+ t.Helper()
+ w := doWantedReq(t, h, "/api/admin/wanted?"+url.Values{"q": {q}}.Encode(), user)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
+ }
+ var page wantsPage
+ if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
+ t.Fatal(err)
+ }
+ return page
+}
+
+// The list runs like the library (#5572): artists A–Z by the library's sort
+// name where the library holds the artist, each artist's albums oldest
+// first; q narrows it to an artist or an album.
+func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
+ h, _ := testHandlersWithClientFn(t)
+ admin := seedUser(t, h.pool, "admin", "pw", true)
+ ctx := context.Background()
+ beatles := "a0000000-0000-4000-8000-000000000002"
+ if _, err := dbq.New(h.pool).UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
+ t.Fatal(err)
+ }
+ seedWant(t, h, "g0000000-0000-4000-8000-000000000001", "a0000000-0000-4000-8000-000000000003", "Zeds", "Zed Album", "2001")
+ seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969-09-26")
+ seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963-03-22")
+ seedWant(t, h, "g0000000-0000-4000-8000-000000000004", "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
+
+ page := listWanted(t, h, admin, "")
+ var got []string
+ for _, it := range page.Items {
+ got = append(got, it.ArtistName+"/"+it.Title)
+ }
+ want := "[The Beatles/Please Please Me The Beatles/Abbey Road Camel/Moonmadness Zeds/Zed Album]"
+ if fmt.Sprint(got) != want {
+ t.Errorf("order = %v, want %s", got, want)
+ }
+ if page.Items[0].ArtistID == "" || page.Items[2].ArtistID != "" {
+ t.Errorf("artist ids = %q, %q: only an artist the library holds has one", page.Items[0].ArtistID, page.Items[2].ArtistID)
+ }
+
+ page = listWanted(t, h, admin, "BEATLES")
+ if len(page.Items) != 2 || page.Total != 2 {
+ t.Errorf("q=BEATLES: %d items, total %d; want the two Beatles albums", len(page.Items), page.Total)
+ }
+ page = listWanted(t, h, admin, "moon")
+ if len(page.Items) != 1 || page.Items[0].Title != "Moonmadness" || page.Total != 1 {
+ t.Errorf("q=moon: %+v, total %d", page.Items, page.Total)
+ }
+ if page = listWanted(t, h, admin, "nothing like it"); len(page.Items) != 0 || page.Total != 0 {
+ t.Errorf("no match: %d items, total %d", len(page.Items), page.Total)
+ }
+}
+
+// fakeEditions is a MusicBrainz holding one group with two editions, the
+// shorter official one the standard, or 503 when down is set.
+func fakeEditions(t *testing.T, group string, down bool) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if down {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ return
+ }
+ switch {
+ case r.URL.Path == "/release-group/"+group:
+ _, _ = fmt.Fprintf(w, `{"id": %q, "title": "Moonmadness", "primary-type": "Album", "first-release-date": "1976"}`, group)
+ case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == group:
+ _, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [
+ {"id": "r-deluxe", "title": "Moonmadness", "status": "Official", "date": "2009", "country": "GB",
+ "media": [{"position": 1, "format": "CD", "tracks": [
+ {"position": 1, "number": "1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
+ {"position": 2, "number": "2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}},
+ {"position": 3, "number": "3", "title": "Bonus", "length": 200000, "recording": {"id": "rec-3"}}]}]},
+ {"id": "r-orig", "title": "Moonmadness", "status": "Official", "date": "1976-03", "country": "GB",
+ "media": [{"position": 1, "format": "12\" Vinyl", "tracks": [
+ {"position": 1, "number": "A1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
+ {"position": 2, "number": "A2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}}]}]}]}`)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func wantTracks(t *testing.T, h *handlers, user dbq.User, id string) (int, wantTracksView) {
+ t.Helper()
+ w := doWantedReq(t, h, "/api/admin/wanted/"+id+"/tracks", user)
+ var out wantTracksView
+ if w.Code == http.StatusOK {
+ if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return w.Code, out
+}
+
+// An album not yet held shows MusicBrainz's standard edition: the shortest
+// official one.
+func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
+ const group = "g0000000-0000-4000-8000-000000000010"
+ for _, down := range []bool{false, true} {
+ h, pool := testHandlersWithClientFn(t)
+ admin := seedUser(t, h.pool, "admin", "pw", true)
+ h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL,
+ Pace: &musicbrainz.Pacer{}, Retries: -1}, h.logger)
+ w := seedWant(t, h, group, "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
+
+ code, out := wantTracks(t, h, admin, uuidToString(w.ID))
+ if down {
+ if code != http.StatusServiceUnavailable {
+ t.Errorf("MusicBrainz down: status %d, want 503", code)
+ }
+ continue
+ }
+ if code != http.StatusOK || out.Source != "musicbrainz" || len(out.Tracks) != 2 {
+ t.Fatalf("status %d, %+v: want the two-track original", code, out)
+ }
+ if out.Edition != "1976 · 12\" Vinyl · GB" {
+ t.Errorf("edition = %q", out.Edition)
+ }
+ if tr := out.Tracks[1]; tr.Disc != 1 || tr.Number != 2 || tr.Title != "Song Within a Song" || tr.LengthMs != 433000 {
+ t.Errorf("track 2 = %+v", tr)
+ }
+ }
+}
+
+// A held album shows the library's own tracks, a lost file marked.
+func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
+ h, _ := testHandlersWithClientFn(t)
+ admin := seedUser(t, h.pool, "admin", "pw", true)
+ ctx := context.Background()
+ q := dbq.New(h.pool)
+ mbid := "a0000000-0000-4000-8000-000000000001"
+ artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Camel", SortName: "Camel", Mbid: &mbid})
+ if err != nil {
+ t.Fatal(err)
+ }
+ album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Moonmadness", SortTitle: "Moonmadness", ArtistID: artist.ID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, title := range []string{"Aristillus", "Song Within a Song"} {
+ num := int32(i + 1)
+ if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
+ Title: title, AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 120000,
+ FilePath: fmt.Sprintf("/music/camel/%02d.flac", i+1), FileSize: 1, FileFormat: "flac",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := h.pool.Exec(ctx, `UPDATE tracks SET missing_since = now() WHERE title = 'Aristillus'`); err != nil {
+ t.Fatal(err)
+ }
+ w := seedWant(t, h, "g0000000-0000-4000-8000-000000000011", mbid, "Camel", "Moonmadness", "1976")
+ if err := q.FulfillWant(ctx, dbq.FulfillWantParams{ID: w.ID, AlbumID: album.ID}); err != nil {
+ t.Fatal(err)
+ }
+
+ code, out := wantTracks(t, h, admin, uuidToString(w.ID))
+ if code != http.StatusOK || out.Source != "library" || len(out.Tracks) != 2 {
+ t.Fatalf("status %d, %+v", code, out)
+ }
+ if !out.Tracks[0].Missing || out.Tracks[1].Missing || out.Tracks[1].Number != 2 {
+ t.Errorf("tracks = %+v: the first is missing, the second on disk", out.Tracks)
+ }
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index 1b7a0225..68825e18 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -257,6 +257,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
admin.Put("/acquisition/settings", h.handlePutSearchSettings)
admin.Get("/wanted/{id}/candidates", h.handleListCandidates)
+ admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
admin.Post("/wanted/{id}/search", h.handleSearchWant)
admin.Post("/wanted/{id}/grab", h.handleGrabCandidate)
admin.Post("/wanted/{id}/block", h.handleBlockCandidate)
diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go
index 4699d712..c33da297 100644
--- a/internal/db/dbq/wanted.sql.go
+++ b/internal/db/dbq/wanted.sql.go
@@ -273,6 +273,24 @@ func (q *Queries) GetReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (Re
return i, err
}
+const getWantAlbum = `-- name: GetWantAlbum :one
+SELECT id, release_group_mbid, album_id FROM wants WHERE id = $1
+`
+
+type GetWantAlbumRow struct {
+ ID pgtype.UUID
+ ReleaseGroupMbid string
+ AlbumID pgtype.UUID
+}
+
+// What the Wanted view's track list needs of a want (#5572).
+func (q *Queries) GetWantAlbum(ctx context.Context, id pgtype.UUID) (GetWantAlbumRow, error) {
+ row := q.db.QueryRow(ctx, getWantAlbum, id)
+ var i GetWantAlbumRow
+ err := row.Scan(&i.ID, &i.ReleaseGroupMbid, &i.AlbumID)
+ return i, err
+}
+
const getWantByReleaseGroup = `-- name: GetWantByReleaseGroup :one
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants WHERE release_group_mbid = $1
`
@@ -616,6 +634,49 @@ func (q *Queries) ListFollowsDue(ctx context.Context, arg ListFollowsDueParams)
return items, nil
}
+const listLibraryAlbumTracks = `-- name: ListLibraryAlbumTracks :many
+SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL)::bool AS missing
+ FROM tracks
+ WHERE album_id = $1
+ ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title
+`
+
+type ListLibraryAlbumTracksRow struct {
+ Title string
+ DiscNumber *int32
+ TrackNumber *int32
+ DurationMs int32
+ Missing bool
+}
+
+// A held or missing album's own tracks, for the Wanted view's track list;
+// missing says its file is gone.
+func (q *Queries) ListLibraryAlbumTracks(ctx context.Context, albumID pgtype.UUID) ([]ListLibraryAlbumTracksRow, error) {
+ rows, err := q.db.Query(ctx, listLibraryAlbumTracks, albumID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListLibraryAlbumTracksRow
+ for rows.Next() {
+ var i ListLibraryAlbumTracksRow
+ if err := rows.Scan(
+ &i.Title,
+ &i.DiscNumber,
+ &i.TrackNumber,
+ &i.DurationMs,
+ &i.Missing,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const listLibraryAlbums = `-- name: ListLibraryAlbums :execrows
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, wanted_at, fulfilled_at)
SELECT DISTINCT ON (a.release_group_mbid)
@@ -800,7 +861,8 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
u.username AS requested_by,
- (SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id,
+ la.id AS library_artist_id,
+ COUNT(*) OVER () AS total,
-- The search schedule's last look (#5521), and the download running.
w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error,
-- Its latest download, running or ended, and what the import made of
@@ -813,6 +875,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
+ LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = w.artist_mbid LIMIT 1) la ON true
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE $1::text
@@ -820,12 +883,18 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
- ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
- LIMIT $3 OFFSET $2
+ AND ($2::text = ''
+ OR strpos(lower(w.artist_name), lower($2::text)) > 0
+ OR strpos(lower(w.title), lower($2::text)) > 0)
+ ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), w.artist_name)), w.artist_mbid,
+ NULLIF(COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, ''), '') NULLS LAST,
+ lower(w.title), w.id
+ LIMIT $4 OFFSET $3
`
type ListWantsParams struct {
State string
+ Q string
Skip int32
MaxRows int32
}
@@ -845,6 +914,7 @@ type ListWantsRow struct {
FirstReleaseDate string
RequestedBy *string
LibraryArtistID pgtype.UUID
+ Total int64
SearchedAt pgtype.Timestamptz
SearchOutcome string
SearchConsidered int32
@@ -867,9 +937,17 @@ type ListWantsRow struct {
}
// The Wanted page: open wants (all but missing ones, or only missing ones),
-// or what was held most recently.
+// or the held ones. Ordered like the library (#5572): artists A–Z by the
+// library's sort name when it holds the artist, each artist's albums by
+// release date, so the page groups them as they arrive. q narrows to an
+// artist or album title; total counts every match, past the page.
func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWantsRow, error) {
- rows, err := q.db.Query(ctx, listWants, arg.State, arg.Skip, arg.MaxRows)
+ rows, err := q.db.Query(ctx, listWants,
+ arg.State,
+ arg.Q,
+ arg.Skip,
+ arg.MaxRows,
+ )
if err != nil {
return nil, err
}
@@ -892,6 +970,7 @@ func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWan
&i.FirstReleaseDate,
&i.RequestedBy,
&i.LibraryArtistID,
+ &i.Total,
&i.SearchedAt,
&i.SearchOutcome,
&i.SearchConsidered,
diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql
index 49077412..173e582c 100644
--- a/internal/db/queries/wanted.sql
+++ b/internal/db/queries/wanted.sql
@@ -177,7 +177,10 @@ DELETE FROM wants WHERE id = $1;
-- name: ListWants :many
-- The Wanted page: open wants (all but missing ones, or only missing ones),
--- or what was held most recently.
+-- or the held ones. Ordered like the library (#5572): artists A–Z by the
+-- library's sort name when it holds the artist, each artist's albums by
+-- release date, so the page groups them as they arrive. q narrows to an
+-- artist or album title; total counts every match, past the page.
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
-- A request names an album before the catalogue has read it: the
@@ -186,7 +189,8 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
u.username AS requested_by,
- (SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id,
+ la.id AS library_artist_id,
+ COUNT(*) OVER () AS total,
-- The search schedule's last look (#5521), and the download running.
w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error,
-- Its latest download, running or ended, and what the import made of
@@ -199,6 +203,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
+ LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = w.artist_mbid LIMIT 1) la ON true
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE sqlc.arg(state)::text
@@ -206,9 +211,26 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
- ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
+ AND (sqlc.arg(q)::text = ''
+ OR strpos(lower(w.artist_name), lower(sqlc.arg(q)::text)) > 0
+ OR strpos(lower(w.title), lower(sqlc.arg(q)::text)) > 0)
+ ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), w.artist_name)), w.artist_mbid,
+ NULLIF(COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, ''), '') NULLS LAST,
+ lower(w.title), w.id
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
+-- name: GetWantAlbum :one
+-- What the Wanted view's track list needs of a want (#5572).
+SELECT id, release_group_mbid, album_id FROM wants WHERE id = $1;
+
+-- name: ListLibraryAlbumTracks :many
+-- A held or missing album's own tracks, for the Wanted view's track list;
+-- missing says its file is gone.
+SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL)::bool AS missing
+ FROM tracks
+ WHERE album_id = $1
+ ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title;
+
-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts
index 2877ca37..b70e78f4 100644
--- a/web/src/lib/api/queries.ts
+++ b/web/src/lib/api/queries.ts
@@ -41,7 +41,8 @@ export const qk = {
['adminRequests', { status: status ?? 'all' }] as const,
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
// invalidation refreshes every tab and its counts.
- adminWants: (state: string) => ['adminWants', { state }] as const,
+ adminWants: (state: string, q: string = '') => ['adminWants', { state, q }] as const,
+ wantTracks: (id: string) => ['wantTracks', id] as const,
adminFollows: () => ['adminFollows'] as const,
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
releaseProfiles: () => ['releaseProfiles'] as const,
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index 957fc4a3..9e3950ff 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -821,6 +821,13 @@ export type LibraryListing = { listed_at: string | null; unlisted: number; unmat
export type WantsPage = Page & { counts: WantCounts; library: LibraryListing };
+// An album's tracks in the Wanted view (#5572): the library's own for an
+// album it holds (a lost file marked missing), else MusicBrainz's standard
+// edition, named by edition; none when MusicBrainz lists no edition.
+export type WantTrack = { disc: number; number: number; title: string; length_ms: number; missing: boolean };
+
+export type WantTracks = { source: 'library' | 'musicbrainz' | 'none'; edition?: string; tracks: WantTrack[] };
+
export type Follow = {
artist_mbid: string;
artist_name: string;
diff --git a/web/src/lib/api/wanted.ts b/web/src/lib/api/wanted.ts
index 147e5d28..c81f6f18 100644
--- a/web/src/lib/api/wanted.ts
+++ b/web/src/lib/api/wanted.ts
@@ -1,4 +1,4 @@
-import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
+import { createInfiniteQuery, createQuery, keepPreviousData } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type {
@@ -14,6 +14,7 @@ import type {
SearchResult,
SearchSettings,
WantState,
+ WantTracks,
WantsPage
} from './types';
@@ -21,8 +22,11 @@ import type {
// artists it follows for new releases, and the release-type profiles that
// say which kinds of release a follow wants.
-export async function listWants(state: WantState, offset: number = 0): Promise {
- return api.get(`/api/admin/wanted?state=${state}&offset=${offset}`);
+// q narrows the list to an artist or album whose name holds it (#5572).
+export async function listWants(state: WantState, offset: number = 0, q: string = ''): Promise {
+ const params = new URLSearchParams({ state, offset: String(offset) });
+ if (q) params.set('q', q);
+ return api.get(`/api/admin/wanted?${params}`);
}
export function wantsNextOffset(last: WantsPage): number | undefined {
@@ -31,16 +35,32 @@ export function wantsNextOffset(last: WantsPage): number | undefined {
}
// Loads as the page scrolls (rule 172), one tab at a time.
-export function createWantsQuery(state: WantState) {
+export function createWantsQuery(state: WantState, q: string = '') {
return createInfiniteQuery({
- queryKey: qk.adminWants(state),
- queryFn: ({ pageParam }) => listWants(state, pageParam as number),
+ queryKey: qk.adminWants(state, q),
+ queryFn: ({ pageParam }) => listWants(state, pageParam as number, q),
initialPageParam: 0,
getNextPageParam: wantsNextOffset,
+ // A new filter keeps the list it narrows on screen until its answer comes.
+ placeholderData: keepPreviousData,
staleTime: 30_000
});
}
+// An album's tracks (#5572): the library's own for an album it holds,
+// otherwise MusicBrainz's standard edition.
+export async function getWantTracks(id: string): Promise {
+ return api.get(`/api/admin/wanted/${id}/tracks`);
+}
+
+export function createWantTracksQuery(id: string) {
+ return createQuery({
+ queryKey: qk.wantTracks(id),
+ queryFn: () => getWantTracks(id),
+ staleTime: 5 * 60_000
+ });
+}
+
export async function removeWant(id: string): Promise {
await api.del(`/api/admin/wanted/${id}`);
}
diff --git a/web/src/lib/components/wanted/WantList.svelte b/web/src/lib/components/wanted/WantList.svelte
index 168f9fee..3771a890 100644
--- a/web/src/lib/components/wanted/WantList.svelte
+++ b/web/src/lib/components/wanted/WantList.svelte
@@ -1,5 +1,5 @@
+
+
+ {#if tracks.isPending}
+
Reading the tracks…
+ {:else if tracks.isError}
+
+ {away ? "MusicBrainz can't be reached, so the tracks can't be shown." : "Couldn't load the tracks."}
+
+ {:else if tracks.data.source === 'none' || list.length === 0}
+
MusicBrainz lists no edition of this album, so there are no tracks to show.
+ {:else}
+ {#if tracks.data.source === 'musicbrainz'}
+
+ MusicBrainz's standard edition{tracks.data.edition ? ` · ${tracks.data.edition}` : ''}
+
+ {/if}
+
+ {#each list as t, i (i)}
+
+ {position(t)}
+
+ {t.title}
+
+ {#if t.missing}file missing {/if}
+ {#if t.length_ms > 0}
+ {formatDuration(t.length_ms / 1000)}
+ {/if}
+
+ {/each}
+
+ {/if}
+
--
2.54.0
From 358849de77f7763309541d81c7f9b31ca0dd34ad Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 21:14:33 -0400
Subject: [PATCH 026/117] feat: the Wanted view becomes Collection, shaped like
Lidarr: an artist catalogue first, the state sections down a left rail (M502
#5577)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The section opens on a catalogue of every artist on the list, A–Z with a
letter rail, each card showing how many of their albums are held. An artist
opens on their page: every album of theirs, held or not, with its status,
search, releases and tracks. Wanted, Missing files, Activity, Following and
Blocked sit down a left rail as the sections that show state; Held is gone,
the catalogue shows it. The section moves from /wanted to /collection.
Co-Authored-By: Claude Opus 5.5
---
internal/api/admin_wanted.go | 182 +++++++++++++-----
internal/api/admin_wanted_test.go | 70 +++++++
internal/api/api.go | 1 +
internal/db/dbq/wanted.sql.go | 117 ++++++++++-
internal/db/queries/wanted.sql | 38 +++-
web/src/lib/api/queries.ts | 4 +-
web/src/lib/api/types.ts | 26 ++-
web/src/lib/api/wanted.ts | 41 +++-
.../ReacquisitionSettingsCard.svelte | 2 +-
.../lib/components/SearchSettingsCard.svelte | 2 +-
web/src/lib/components/Shell.svelte | 7 +-
web/src/lib/components/Shell.test.ts | 8 +-
web/src/lib/components/WantedTabs.svelte | 54 ------
.../wanted/CatalogueArtistCard.svelte | 46 +++++
.../components/wanted/CatalogueGrid.svelte | 110 +++++++++++
.../components/wanted/CatalogueGrid.test.ts | 103 ++++++++++
.../components/wanted/CollectionRail.svelte | 58 ++++++
web/src/lib/components/wanted/WantList.svelte | 106 ++++------
.../lib/components/wanted/WantList.test.ts | 171 +++++++++-------
.../routes/admin/missing-files/+page.svelte | 2 +-
.../admin/missing-files/missing-files.test.ts | 2 +-
web/src/routes/admin/requests/+page.svelte | 2 +-
web/src/routes/collection/+layout.svelte | 58 ++++++
.../routes/{wanted => collection}/+layout.ts | 0
web/src/routes/collection/+page.svelte | 8 +
.../activity/+page.svelte | 2 +-
.../collection/artist/[mbid]/+page.svelte | 15 ++
.../blocked/+page.svelte | 2 +-
.../collection.test.ts} | 51 ++---
.../following/+page.svelte | 2 +-
.../missing/+page.svelte | 2 +-
.../{ => collection}/wanted/+page.svelte | 2 +-
web/src/routes/wanted/+layout.svelte | 48 -----
web/src/routes/wanted/held/+page.svelte | 8 -
34 files changed, 994 insertions(+), 356 deletions(-)
delete mode 100644 web/src/lib/components/WantedTabs.svelte
create mode 100644 web/src/lib/components/wanted/CatalogueArtistCard.svelte
create mode 100644 web/src/lib/components/wanted/CatalogueGrid.svelte
create mode 100644 web/src/lib/components/wanted/CatalogueGrid.test.ts
create mode 100644 web/src/lib/components/wanted/CollectionRail.svelte
create mode 100644 web/src/routes/collection/+layout.svelte
rename web/src/routes/{wanted => collection}/+layout.ts (100%)
create mode 100644 web/src/routes/collection/+page.svelte
rename web/src/routes/{wanted => collection}/activity/+page.svelte (67%)
create mode 100644 web/src/routes/collection/artist/[mbid]/+page.svelte
rename web/src/routes/{wanted => collection}/blocked/+page.svelte (66%)
rename web/src/routes/{wanted/wanted.test.ts => collection/collection.test.ts} (52%)
rename web/src/routes/{wanted => collection}/following/+page.svelte (78%)
rename web/src/routes/{wanted => collection}/missing/+page.svelte (66%)
rename web/src/routes/{ => collection}/wanted/+page.svelte (68%)
delete mode 100644 web/src/routes/wanted/+layout.svelte
delete mode 100644 web/src/routes/wanted/held/+page.svelte
diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go
index 852d9cda..d58a20b1 100644
--- a/internal/api/admin_wanted.go
+++ b/internal/api/admin_wanted.go
@@ -69,8 +69,10 @@ type wantGrabView struct {
ImportedFiles int32 `json:"imported_files"`
}
-// wantCounts sizes each tab of the Wanted page.
+// wantCounts sizes each tab of the Wanted page. Artists: how many artists
+// the list holds albums of, its main page (#5572).
type wantCounts struct {
+ Artists int64 `json:"artists"`
Wanted int64 `json:"wanted"`
Missing int64 `json:"missing"`
Held int64 `json:"held"`
@@ -90,6 +92,34 @@ type wantsPage struct {
Counts wantCounts `json:"counts"`
// Library is the one-time listing of the library's albums (#5567).
Library libraryListingView `json:"library"`
+ // Artist heads one artist's page, when the list is narrowed to them.
+ Artist *wantArtistView `json:"artist,omitempty"`
+}
+
+// wantArtistView is one artist on the Wanted view's main page (#5572): how
+// many of their albums the list holds, of how many, like Lidarr's artist
+// index.
+type wantArtistView struct {
+ ArtistMBID string `json:"artist_mbid"`
+ ArtistName string `json:"artist_name"`
+ SortName string `json:"sort_name"`
+ ArtistID string `json:"artist_id,omitempty"`
+ ImageURL string `json:"image_url"`
+ Albums int32 `json:"albums"`
+ Held int32 `json:"held"`
+ Wanted int32 `json:"wanted"`
+ Missing int32 `json:"missing"`
+ Downloading int32 `json:"downloading"`
+ Following bool `json:"following"`
+}
+
+type wantArtistsPage struct {
+ Items []wantArtistView `json:"items"`
+ Total int64 `json:"total"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ Counts wantCounts `json:"counts"`
+ Library libraryListingView `json:"library"`
}
// libraryListingView: whether the library's albums are all on the list
@@ -113,77 +143,129 @@ const unmatchedShown = 200
const wantsPageSize = 50
-// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&q=&offset=.
+// wantArtistsPageSize fills a few rows of the artist grid a page.
+const wantArtistsPageSize = 96
+
+// wantsHeader is what every Wanted page carries: each tab's count, and how
+// the one-time library listing went. The albums no match was found for are
+// named only when asked (the main page's first page).
+func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries, unmatched bool) (wantCounts, libraryListingView, error) {
+ counts, err := q.CountWants(r.Context())
+ if err != nil {
+ return wantCounts{}, libraryListingView{}, err
+ }
+ listing, err := q.GetLibraryListing(r.Context())
+ if err != nil {
+ return wantCounts{}, libraryListingView{}, err
+ }
+ lib := libraryListingView{Unlisted: listing.LibraryUnlisted, Unmatched: []unmatchedAlbumView{}}
+ if listing.LibraryListedAt.Valid {
+ at := formatTimestamp(listing.LibraryListedAt)
+ lib.ListedAt = &at
+ }
+ if unmatched && listing.LibraryUnlisted > 0 {
+ rows, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
+ if err != nil {
+ return wantCounts{}, libraryListingView{}, err
+ }
+ for _, u := range rows {
+ lib.Unmatched = append(lib.Unmatched, unmatchedAlbumView{
+ AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
+ })
+ }
+ }
+ return wantCounts{Artists: counts.Artists, Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held,
+ Following: counts.Following, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, lib, nil
+}
+
+func offsetParam(r *http.Request) int {
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ return max(offset, 0)
+}
+
+func wantArtistViewOf(row dbq.ListWantArtistsRow) wantArtistView {
+ v := wantArtistView{
+ ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName, SortName: row.SortName,
+ ImageURL: artistArtURL(row.ArtistMbid, row.ArtistName),
+ Albums: row.Albums, Held: row.Held, Wanted: row.Wanted, Missing: row.Missing,
+ Downloading: row.Downloading, Following: row.Following,
+ }
+ if row.LibraryArtistID.Valid {
+ v.ArtistID = uuidToString(row.LibraryArtistID)
+ }
+ return v
+}
+
+// handleListWantArtists implements GET /api/admin/wanted/artists?q=&offset=:
+// the Wanted view's main page, every artist on the list A–Z (#5572).
+func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request) {
+ offset := offsetParam(r)
+ q := dbq.New(h.pool)
+ rows, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{
+ Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantArtistsPageSize})
+ if err != nil {
+ writeErrWithLog(w, h.logger, "admin wanted: artists", apierror.Internal(err))
+ return
+ }
+ counts, lib, err := h.wantsHeader(r, q, offset == 0)
+ if err != nil {
+ writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
+ return
+ }
+ out := wantArtistsPage{Items: make([]wantArtistView, 0, len(rows)), Limit: wantArtistsPageSize, Offset: offset,
+ Counts: counts, Library: lib}
+ for _, row := range rows {
+ out.Total = row.Total
+ out.Items = append(out.Items, wantArtistViewOf(row))
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// handleListWants implements
+// GET /api/admin/wanted?state=wanted|missing|held|all&artist=&q=&offset=.
// wanted is everything open but missing files. Each list runs artist by
-// artist, A–Z, like the library (#5572); q narrows it to an artist or album.
+// artist, A–Z, like the library (#5572); q narrows it to an artist or
+// album, and artist to one artist's page, which it then heads.
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
if state == "" {
state = "wanted"
}
- if state != "wanted" && state != "missing" && state != "held" {
- writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing or held"))
+ if state != "wanted" && state != "missing" && state != "held" && state != "all" {
+ writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing, held or all"))
return
}
- offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
- if offset < 0 {
- offset = 0
- }
+ offset := offsetParam(r)
+ artist := strings.TrimSpace(r.URL.Query().Get("artist"))
q := dbq.New(h.pool)
- filter := strings.TrimSpace(r.URL.Query().Get("q"))
- rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Q: filter, Skip: int32(offset), MaxRows: wantsPageSize})
+ rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, ArtistMbid: artist,
+ Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
return
}
- counts, err := q.CountWants(r.Context())
+ counts, lib, err := h.wantsHeader(r, q, false)
if err != nil {
- writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
+ writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
return
}
- listing, err := q.GetLibraryListing(r.Context())
- if err != nil {
- writeErrWithLog(w, h.logger, "admin wanted: library listing", apierror.Internal(err))
- return
- }
- out := wantsPage{
- Library: libraryListingView{Unlisted: listing.LibraryUnlisted},
- Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
- Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
- Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
- }
- out.Library.Unmatched = []unmatchedAlbumView{}
- if listing.LibraryListedAt.Valid {
- at := formatTimestamp(listing.LibraryListedAt)
- out.Library.ListedAt = &at
- }
- if state == "held" && offset == 0 && listing.LibraryUnlisted > 0 {
- unmatched, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
+ out := wantsPage{Library: lib, Counts: counts,
+ Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset}
+ if artist != "" {
+ heads, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{ArtistMbid: artist, MaxRows: 1})
if err != nil {
- writeErrWithLog(w, h.logger, "admin wanted: unmatched albums", apierror.Internal(err))
+ writeErrWithLog(w, h.logger, "admin wanted: artist", apierror.Internal(err))
return
}
- for _, u := range unmatched {
- out.Library.Unmatched = append(out.Library.Unmatched, unmatchedAlbumView{
- AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
- })
- }
- }
- switch state {
- case "missing":
- out.Total = counts.Missing
- case "held":
- out.Total = counts.Held
- default:
- out.Total = counts.Wanted
- }
- if filter != "" {
- out.Total = 0
- if len(rows) > 0 {
- out.Total = rows[0].Total
+ if len(heads) == 0 {
+ writeErr(w, apierror.NotFound("artist"))
+ return
}
+ v := wantArtistViewOf(heads[0])
+ out.Artist = &v
}
for _, row := range rows {
+ out.Total = row.Total
v := wantView{
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
diff --git a/internal/api/admin_wanted_test.go b/internal/api/admin_wanted_test.go
index db7f9baa..ecfc70da 100644
--- a/internal/api/admin_wanted_test.go
+++ b/internal/api/admin_wanted_test.go
@@ -24,6 +24,7 @@ func doWantedReq(t *testing.T, h *handlers, path string, user dbq.User) *httptes
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/wanted", h.handleListWants)
+ admin.Get("/wanted/artists", h.handleListWantArtists)
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
})
w := httptest.NewRecorder()
@@ -98,6 +99,75 @@ func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
}
}
+// The Collection's catalogue (#5572): every artist on the list A–Z, each
+// with how many of their albums are held; an artist's page lists every album
+// of theirs, held or not, under that artist.
+func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
+ h, _ := testHandlersWithClientFn(t)
+ admin := seedUser(t, h.pool, "admin", "pw", true)
+ ctx := context.Background()
+ q := dbq.New(h.pool)
+ beatles := "a0000000-0000-4000-8000-000000000002"
+ camel := "a0000000-0000-4000-8000-000000000001"
+ if _, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
+ t.Fatal(err)
+ }
+ seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969")
+ held := seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963")
+ if _, err := h.pool.Exec(ctx, `UPDATE wants SET fulfilled_at = now() WHERE id = $1`, held.ID); err != nil {
+ t.Fatal(err)
+ }
+ seedWant(t, h, "g0000000-0000-4000-8000-000000000004", camel, "Camel", "Moonmadness", "1976")
+ if _, err := h.pool.Exec(ctx, `INSERT INTO follows (artist_mbid, artist_name, profile_id)
+ SELECT $1, 'Camel', id FROM release_type_profiles WHERE is_default`, camel); err != nil {
+ t.Fatal(err)
+ }
+
+ w := doWantedReq(t, h, "/api/admin/wanted/artists", admin)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
+ }
+ var page wantArtistsPage
+ if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
+ t.Fatal(err)
+ }
+ if len(page.Items) != 2 || page.Total != 2 || page.Counts.Artists != 2 {
+ t.Fatalf("catalogue = %+v, total %d, artists %d", page.Items, page.Total, page.Counts.Artists)
+ }
+ b, c := page.Items[0], page.Items[1]
+ if b.ArtistName != "The Beatles" || b.SortName != "Beatles, The" || b.Albums != 2 || b.Held != 1 || b.Wanted != 1 ||
+ b.ArtistID == "" || b.Following || b.ImageURL == "" {
+ t.Errorf("Beatles = %+v", b)
+ }
+ if c.ArtistName != "Camel" || c.Albums != 1 || c.Held != 0 || !c.Following || c.ArtistID != "" {
+ t.Errorf("Camel = %+v", c)
+ }
+
+ // q finds an artist by an album's title.
+ w = doWantedReq(t, h, "/api/admin/wanted/artists?q=moon", admin)
+ page = wantArtistsPage{}
+ if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
+ t.Fatal(err)
+ }
+ if len(page.Items) != 1 || page.Items[0].ArtistName != "Camel" {
+ t.Errorf("q=moon: %+v", page.Items)
+ }
+
+ // The Beatles' page: both albums, held or not, headed by the artist.
+ w = doWantedReq(t, h, "/api/admin/wanted?state=all&artist="+beatles, admin)
+ var albums wantsPage
+ if err := json.Unmarshal(w.Body.Bytes(), &albums); err != nil {
+ t.Fatal(err)
+ }
+ if len(albums.Items) != 2 || albums.Items[0].Title != "Please Please Me" || albums.Items[0].HeldAt == nil ||
+ albums.Artist == nil || albums.Artist.Held != 1 {
+ t.Errorf("artist page = %+v, head %+v", albums.Items, albums.Artist)
+ }
+ if w := doWantedReq(t, h, "/api/admin/wanted?state=all&artist=nobody", admin); w.Code != http.StatusNotFound {
+ t.Errorf("an artist with nothing on the list: status %d, want 404", w.Code)
+ }
+}
+
// fakeEditions is a MusicBrainz holding one group with two editions, the
// shorter official one the standard, or 503 when down is set.
func fakeEditions(t *testing.T, group string, down bool) *httptest.Server {
diff --git a/internal/api/api.go b/internal/api/api.go
index 68825e18..b8d7d27a 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -252,6 +252,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// for, the artists it follows, and the release-type
// profiles that decide which of their releases count.
admin.Get("/wanted", h.handleListWants)
+ admin.Get("/wanted/artists", h.handleListWantArtists)
admin.Delete("/wanted/{id}", h.handleRemoveWant)
// Searching and grabbing (M502 #5521).
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go
index c33da297..22469f12 100644
--- a/internal/db/dbq/wanted.sql.go
+++ b/internal/db/dbq/wanted.sql.go
@@ -37,6 +37,7 @@ const countWants = `-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
+ COUNT(DISTINCT artist_mbid)::bigint AS artists,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.
@@ -52,6 +53,7 @@ type CountWantsRow struct {
Wanted int64
Missing int64
Held int64
+ Artists int64
Following int64
Activity int64
Attention int64
@@ -65,6 +67,7 @@ func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
&i.Wanted,
&i.Missing,
&i.Held,
+ &i.Artists,
&i.Following,
&i.Activity,
&i.Attention,
@@ -852,6 +855,98 @@ func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32)
return items, nil
}
+const listWantArtists = `-- name: ListWantArtists :many
+WITH a AS (
+ SELECT w.artist_mbid,
+ (array_agg(w.artist_name ORDER BY w.wanted_at DESC))[1] AS artist_name,
+ COUNT(*) FILTER (WHERE w.fulfilled_at IS NOT NULL)::int AS held,
+ COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason <> 'missing')::int AS wanted,
+ COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason = 'missing')::int AS missing,
+ COUNT(*)::int AS albums,
+ bool_or($1::text <> '' AND strpos(lower(w.title), lower($1::text)) > 0) AS title_match
+ FROM wants w
+ WHERE $4::text = '' OR w.artist_mbid = $4::text
+ GROUP BY w.artist_mbid
+)
+SELECT a.artist_mbid, a.artist_name::text AS artist_name, a.held, a.wanted, a.missing, a.albums,
+ COALESCE(NULLIF(la.sort_name, ''), a.artist_name)::text AS sort_name,
+ la.id AS library_artist_id,
+ (f.artist_mbid IS NOT NULL)::bool AS following,
+ (SELECT COUNT(*) FROM grabs g JOIN wants x ON x.id = g.want_id
+ WHERE x.artist_mbid = a.artist_mbid AND g.ended_at IS NULL)::int AS downloading,
+ COUNT(*) OVER () AS total
+ FROM a
+ LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = a.artist_mbid LIMIT 1) la ON true
+ LEFT JOIN follows f ON f.artist_mbid = a.artist_mbid
+ WHERE $1::text = ''
+ OR strpos(lower(a.artist_name), lower($1::text)) > 0
+ OR a.title_match
+ ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), a.artist_name)), a.artist_mbid
+ LIMIT $3 OFFSET $2
+`
+
+type ListWantArtistsParams struct {
+ Q string
+ Skip int32
+ MaxRows int32
+ ArtistMbid string
+}
+
+type ListWantArtistsRow struct {
+ ArtistMbid string
+ ArtistName string
+ Held int32
+ Wanted int32
+ Missing int32
+ Albums int32
+ SortName string
+ LibraryArtistID pgtype.UUID
+ Following bool
+ Downloading int32
+ Total int64
+}
+
+// The Wanted view's main page (#5572): every artist on the list, A–Z like
+// the library, with how many of their albums are held of how many are on
+// the list. q matches the artist's name or any of their albums' titles;
+// artist_mbid narrows to one artist, for their page's header.
+func (q *Queries) ListWantArtists(ctx context.Context, arg ListWantArtistsParams) ([]ListWantArtistsRow, error) {
+ rows, err := q.db.Query(ctx, listWantArtists,
+ arg.Q,
+ arg.Skip,
+ arg.MaxRows,
+ arg.ArtistMbid,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListWantArtistsRow
+ for rows.Next() {
+ var i ListWantArtistsRow
+ if err := rows.Scan(
+ &i.ArtistMbid,
+ &i.ArtistName,
+ &i.Held,
+ &i.Wanted,
+ &i.Missing,
+ &i.Albums,
+ &i.SortName,
+ &i.LibraryArtistID,
+ &i.Following,
+ &i.Downloading,
+ &i.Total,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const listWants = `-- name: ListWants :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
@@ -879,24 +974,27 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE $1::text
+ WHEN 'all' THEN true
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
- AND ($2::text = ''
- OR strpos(lower(w.artist_name), lower($2::text)) > 0
- OR strpos(lower(w.title), lower($2::text)) > 0)
+ AND ($2::text = '' OR w.artist_mbid = $2::text)
+ AND ($3::text = ''
+ OR strpos(lower(w.artist_name), lower($3::text)) > 0
+ OR strpos(lower(w.title), lower($3::text)) > 0)
ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), w.artist_name)), w.artist_mbid,
NULLIF(COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, ''), '') NULLS LAST,
lower(w.title), w.id
- LIMIT $4 OFFSET $3
+ LIMIT $5 OFFSET $4
`
type ListWantsParams struct {
- State string
- Q string
- Skip int32
- MaxRows int32
+ State string
+ ArtistMbid string
+ Q string
+ Skip int32
+ MaxRows int32
}
type ListWantsRow struct {
@@ -937,13 +1035,14 @@ type ListWantsRow struct {
}
// The Wanted page: open wants (all but missing ones, or only missing ones),
-// or the held ones. Ordered like the library (#5572): artists A–Z by the
+// the held ones, or all of them; artist_mbid narrows to one artist's page. Ordered like the library (#5572): artists A–Z by the
// library's sort name when it holds the artist, each artist's albums by
// release date, so the page groups them as they arrive. q narrows to an
// artist or album title; total counts every match, past the page.
func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWantsRow, error) {
rows, err := q.db.Query(ctx, listWants,
arg.State,
+ arg.ArtistMbid,
arg.Q,
arg.Skip,
arg.MaxRows,
diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql
index 173e582c..7aa34649 100644
--- a/internal/db/queries/wanted.sql
+++ b/internal/db/queries/wanted.sql
@@ -177,7 +177,7 @@ DELETE FROM wants WHERE id = $1;
-- name: ListWants :many
-- The Wanted page: open wants (all but missing ones, or only missing ones),
--- or the held ones. Ordered like the library (#5572): artists A–Z by the
+-- the held ones, or all of them; artist_mbid narrows to one artist's page. Ordered like the library (#5572): artists A–Z by the
-- library's sort name when it holds the artist, each artist's albums by
-- release date, so the page groups them as they arrive. q narrows to an
-- artist or album title; total counts every match, past the page.
@@ -207,10 +207,12 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE sqlc.arg(state)::text
+ WHEN 'all' THEN true
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
+ AND (sqlc.arg(artist_mbid)::text = '' OR w.artist_mbid = sqlc.arg(artist_mbid)::text)
AND (sqlc.arg(q)::text = ''
OR strpos(lower(w.artist_name), lower(sqlc.arg(q)::text)) > 0
OR strpos(lower(w.title), lower(sqlc.arg(q)::text)) > 0)
@@ -219,6 +221,39 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
lower(w.title), w.id
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
+-- name: ListWantArtists :many
+-- The Wanted view's main page (#5572): every artist on the list, A–Z like
+-- the library, with how many of their albums are held of how many are on
+-- the list. q matches the artist's name or any of their albums' titles;
+-- artist_mbid narrows to one artist, for their page's header.
+WITH a AS (
+ SELECT w.artist_mbid,
+ (array_agg(w.artist_name ORDER BY w.wanted_at DESC))[1] AS artist_name,
+ COUNT(*) FILTER (WHERE w.fulfilled_at IS NOT NULL)::int AS held,
+ COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason <> 'missing')::int AS wanted,
+ COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason = 'missing')::int AS missing,
+ COUNT(*)::int AS albums,
+ bool_or(sqlc.arg(q)::text <> '' AND strpos(lower(w.title), lower(sqlc.arg(q)::text)) > 0) AS title_match
+ FROM wants w
+ WHERE sqlc.arg(artist_mbid)::text = '' OR w.artist_mbid = sqlc.arg(artist_mbid)::text
+ GROUP BY w.artist_mbid
+)
+SELECT a.artist_mbid, a.artist_name::text AS artist_name, a.held, a.wanted, a.missing, a.albums,
+ COALESCE(NULLIF(la.sort_name, ''), a.artist_name)::text AS sort_name,
+ la.id AS library_artist_id,
+ (f.artist_mbid IS NOT NULL)::bool AS following,
+ (SELECT COUNT(*) FROM grabs g JOIN wants x ON x.id = g.want_id
+ WHERE x.artist_mbid = a.artist_mbid AND g.ended_at IS NULL)::int AS downloading,
+ COUNT(*) OVER () AS total
+ FROM a
+ LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = a.artist_mbid LIMIT 1) la ON true
+ LEFT JOIN follows f ON f.artist_mbid = a.artist_mbid
+ WHERE sqlc.arg(q)::text = ''
+ OR strpos(lower(a.artist_name), lower(sqlc.arg(q)::text)) > 0
+ OR a.title_match
+ ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), a.artist_name)), a.artist_mbid
+ LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
+
-- name: GetWantAlbum :one
-- What the Wanted view's track list needs of a want (#5572).
SELECT id, release_group_mbid, album_id FROM wants WHERE id = $1;
@@ -235,6 +270,7 @@ SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
+ COUNT(DISTINCT artist_mbid)::bigint AS artists,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.
diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts
index b70e78f4..4dbde645 100644
--- a/web/src/lib/api/queries.ts
+++ b/web/src/lib/api/queries.ts
@@ -41,7 +41,9 @@ export const qk = {
['adminRequests', { status: status ?? 'all' }] as const,
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
// invalidation refreshes every tab and its counts.
- adminWants: (state: string, q: string = '') => ['adminWants', { state, q }] as const,
+ adminWants: (state: string, q: string = '', artist: string = '') =>
+ ['adminWants', { state, q, artist }] as const,
+ wantArtists: (q: string = '') => ['adminWants', 'artists', { q }] as const,
wantTracks: (id: string) => ['wantTracks', id] as const,
adminFollows: () => ['adminFollows'] as const,
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index 9e3950ff..3684ccb3 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -571,7 +571,8 @@ export type ResolvedDuplicatesResponse = {
// library: in the library when the list began (#5567).
export type WantReason = 'request' | 'follow' | 'missing' | 'library';
-export type WantState = 'wanted' | 'missing' | 'held';
+// all: one artist's page, every album of theirs on the list (#5572).
+export type WantState = 'wanted' | 'missing' | 'held' | 'all';
// One release group on the wanted list. held_at is set once the library
// holds it; artist_id and album_id link into the library when it has them.
@@ -722,6 +723,8 @@ export type SearchSettings = {
};
export type WantCounts = {
+ // Artists in the Collection's catalogue (#5572).
+ artists: number;
wanted: number;
missing: number;
held: number;
@@ -819,7 +822,26 @@ export type UnmatchedAlbum = { album_id: string; title: string; artist: string;
export type LibraryListing = { listed_at: string | null; unlisted: number; unmatched: UnmatchedAlbum[] };
-export type WantsPage = Page & { counts: WantCounts; library: LibraryListing };
+// artist heads the list when it is narrowed to one artist's page.
+export type WantsPage = Page & { counts: WantCounts; library: LibraryListing; artist?: WantArtist };
+
+// One artist in the Collection's catalogue (#5572), like Lidarr's artist
+// index: how many of their albums on the list are held, of how many.
+export type WantArtist = {
+ artist_mbid: string;
+ artist_name: string;
+ sort_name: string;
+ artist_id?: string;
+ image_url: string;
+ albums: number;
+ held: number;
+ wanted: number;
+ missing: number;
+ downloading: number;
+ following: boolean;
+};
+
+export type WantArtistsPage = Page & { counts: WantCounts; library: LibraryListing };
// An album's tracks in the Wanted view (#5572): the library's own for an
// album it holds (a lost file marked missing), else MusicBrainz's standard
diff --git a/web/src/lib/api/wanted.ts b/web/src/lib/api/wanted.ts
index c81f6f18..d5a60f9f 100644
--- a/web/src/lib/api/wanted.ts
+++ b/web/src/lib/api/wanted.ts
@@ -13,6 +13,7 @@ import type {
GrabResult,
SearchResult,
SearchSettings,
+ WantArtistsPage,
WantState,
WantTracks,
WantsPage
@@ -22,10 +23,17 @@ import type {
// artists it follows for new releases, and the release-type profiles that
// say which kinds of release a follow wants.
-// q narrows the list to an artist or album whose name holds it (#5572).
-export async function listWants(state: WantState, offset: number = 0, q: string = ''): Promise {
+// q narrows the list to an artist or album whose name holds it; artist to
+// one artist's page, which the response then heads (#5572).
+export async function listWants(
+ state: WantState,
+ offset: number = 0,
+ q: string = '',
+ artist: string = ''
+): Promise {
const params = new URLSearchParams({ state, offset: String(offset) });
if (q) params.set('q', q);
+ if (artist) params.set('artist', artist);
return api.get(`/api/admin/wanted?${params}`);
}
@@ -35,10 +43,10 @@ export function wantsNextOffset(last: WantsPage): number | undefined {
}
// Loads as the page scrolls (rule 172), one tab at a time.
-export function createWantsQuery(state: WantState, q: string = '') {
+export function createWantsQuery(state: WantState, q: string = '', artist: string = '') {
return createInfiniteQuery({
- queryKey: qk.adminWants(state, q),
- queryFn: ({ pageParam }) => listWants(state, pageParam as number, q),
+ queryKey: qk.adminWants(state, q, artist),
+ queryFn: ({ pageParam }) => listWants(state, pageParam as number, q, artist),
initialPageParam: 0,
getNextPageParam: wantsNextOffset,
// A new filter keeps the list it narrows on screen until its answer comes.
@@ -47,6 +55,29 @@ export function createWantsQuery(state: WantState, q: string = '') {
});
}
+// The Collection's catalogue (#5572): every artist on the list, A–Z, with
+// how many of their albums are held. Its first page carries every section's
+// count.
+export async function listWantArtists(offset: number = 0, q: string = ''): Promise {
+ const params = new URLSearchParams({ offset: String(offset) });
+ if (q) params.set('q', q);
+ return api.get(`/api/admin/wanted/artists?${params}`);
+}
+
+export function createWantArtistsQuery(q: string = '') {
+ return createInfiniteQuery({
+ queryKey: qk.wantArtists(q),
+ queryFn: ({ pageParam }) => listWantArtists(pageParam as number, q),
+ initialPageParam: 0,
+ getNextPageParam: (last: WantArtistsPage) => {
+ const loaded = last.offset + last.items.length;
+ return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
+ },
+ placeholderData: keepPreviousData,
+ staleTime: 30_000
+ });
+}
+
// An album's tracks (#5572): the library's own for an album it holds,
// otherwise MusicBrainz's standard edition.
export async function getWantTracks(id: string): Promise {
diff --git a/web/src/lib/components/ReacquisitionSettingsCard.svelte b/web/src/lib/components/ReacquisitionSettingsCard.svelte
index 490b7664..437faeca 100644
--- a/web/src/lib/components/ReacquisitionSettingsCard.svelte
+++ b/web/src/lib/components/ReacquisitionSettingsCard.svelte
@@ -74,7 +74,7 @@
Automatic re-acquisition
When a file has been missing for a while, Minstrel can put its album back on the
- wanted list
+ wanted list
by itself. It's wanted per album, not per track: a whole folder going missing is one
want, not forty.
diff --git a/web/src/lib/components/SearchSettingsCard.svelte b/web/src/lib/components/SearchSettingsCard.svelte
index f256d50a..3bc7cb26 100644
--- a/web/src/lib/components/SearchSettingsCard.svelte
+++ b/web/src/lib/components/SearchSettingsCard.svelte
@@ -104,7 +104,7 @@
Searching and grabbing
Minstrel searches your indexers for each album on the
- wanted list , judges every
+ wanted list , judges every
release it finds, and hands the best one to your download client. Each album's releases and
the reason for every verdict are on the Wanted page.
diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte
index 5caf9946..d318f4d8 100644
--- a/web/src/lib/components/Shell.svelte
+++ b/web/src/lib/components/Shell.svelte
@@ -1,7 +1,7 @@
diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts
index 92a63aab..059a0958 100644
--- a/web/src/lib/components/Shell.test.ts
+++ b/web/src/lib/components/Shell.test.ts
@@ -67,19 +67,19 @@ describe('Shell', () => {
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
});
- test('admins get Wanted after Discover; others do not', () => {
+ test('admins get Collection after Discover; others do not', () => {
const primary = () =>
screen
.getAllByRole('link')
.filter((el) => el.closest('nav[aria-label="Primary"]') !== null)
.map((el) => el.textContent?.trim());
const { unmount } = render(Shell);
- expect(primary()).not.toContain('Wanted');
+ expect(primary()).not.toContain('Collection');
unmount();
userState.current = { id: '1', username: 'alice', is_admin: true };
render(Shell);
- expect(primary()).toEqual(['Home', 'Library', 'Discover', 'Wanted']);
- expect(screen.getByRole('link', { name: 'Wanted' })).toHaveAttribute('href', '/wanted');
+ expect(primary()).toEqual(['Home', 'Library', 'Discover', 'Collection']);
+ expect(screen.getByRole('link', { name: 'Collection' })).toHaveAttribute('href', '/collection');
});
test('main nav omits Artists/Albums/Liked/History/Playlists/Search/Settings/Admin', () => {
diff --git a/web/src/lib/components/WantedTabs.svelte b/web/src/lib/components/WantedTabs.svelte
deleted file mode 100644
index 3a9bf1a4..00000000
--- a/web/src/lib/components/WantedTabs.svelte
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
diff --git a/web/src/lib/components/wanted/CatalogueArtistCard.svelte b/web/src/lib/components/wanted/CatalogueArtistCard.svelte
new file mode 100644
index 00000000..88a69371
--- /dev/null
+++ b/web/src/lib/components/wanted/CatalogueArtistCard.svelte
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
{a.artist_name}
+
{a.held} / {a.albums} held
+ {#if notes.length}
+
{notes.join(' · ')}
+ {/if}
+
+
diff --git a/web/src/lib/components/wanted/CatalogueGrid.svelte b/web/src/lib/components/wanted/CatalogueGrid.svelte
new file mode 100644
index 00000000..f37478c2
--- /dev/null
+++ b/web/src/lib/components/wanted/CatalogueGrid.svelte
@@ -0,0 +1,110 @@
+
+
+{#if library && !library.listed_at}
+
+ Your library's albums are being added to the catalogue. Albums whose tags don't name them are
+ matched on MusicBrainz first, by their artist, title and tracks, about one a second.
+
+{:else if library && library.unlisted > 0}
+
+
+ {library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
+ {library.unlisted === 1 ? "isn't" : "aren't"} in the catalogue: no MusicBrainz album matched
+ {library.unlisted === 1 ? 'its' : 'their'} artist, title and tracks. Until
+ {library.unlisted === 1 ? "it's" : "they're"} matched, {library.unlisted === 1 ? "it isn't" : "they aren't"}
+ fetched back if {library.unlisted === 1 ? 'its' : 'their'} files go missing.
+
+ {#if library.unmatched.length > 0}
+
(showUnmatched = !showUnmatched)}
+ >
+
+ {showUnmatched ? 'Hide them' : 'Show them'}
+
+ {#if showUnmatched}
+
+ {#each library.unmatched as u (u.album_id)}
+
+ {u.title}
+ · {u.artist}
+ {u.detail}
+
+ {/each}
+
+ {#if library.unmatched.length < library.unlisted}
+
The first {library.unmatched.length} of {library.unlisted}.
+ {/if}
+ {/if}
+ {/if}
+
+{/if}
+
+{#if !artists.isPending && (items.length > 0 || q)}
+
+
+
+{/if}
+
+{#if artists.isPending}
+ Reading the catalogue…
+{:else if artists.isError && !artists.isFetchNextPageError}
+ Couldn't load the catalogue.
+{:else if items.length === 0 && q}
+
+ No artist in the catalogue, or album of theirs, is named like '{q}' .
+
+{:else if items.length === 0}
+
+
+
The catalogue is empty.
+
+ Your library's albums land here once it's been scanned, as do the albums users request and
+ the releases of artists Minstrel follows.
+
+
+{:else}
+ a.sort_name || a.artist_name}
+ hasMore={!!artists.hasNextPage}
+ onLoadMore={() => artists.fetchNextPage()}
+ >
+ {#snippet item(a: WantArtist)}
+
+ {/snippet}
+
+
+ artists.fetchNextPage()}
+ />
+{/if}
diff --git a/web/src/lib/components/wanted/CatalogueGrid.test.ts b/web/src/lib/components/wanted/CatalogueGrid.test.ts
new file mode 100644
index 00000000..3b4bc138
--- /dev/null
+++ b/web/src/lib/components/wanted/CatalogueGrid.test.ts
@@ -0,0 +1,103 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
+import { mockInfiniteQuery } from '../../../test-utils/query';
+import type { LibraryListing, WantArtist, WantArtistsPage } from '#lib/api/types.js';
+
+vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
+
+import CatalogueGrid from './CatalogueGrid.svelte';
+import { createWantArtistsQuery } from '#lib/api/wanted.js';
+
+const DAY = 24 * 3_600_000;
+const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
+
+function artist(mbid: string, name: string, extra: Partial = {}): WantArtist {
+ return {
+ artist_mbid: mbid,
+ artist_name: name,
+ sort_name: name,
+ image_url: `/api/catalogue/art/artist/${mbid}`,
+ albums: 4,
+ held: 3,
+ wanted: 1,
+ missing: 0,
+ downloading: 0,
+ following: false,
+ ...extra
+ };
+}
+
+function setup(
+ items: WantArtist[] = [
+ artist('blur', 'Blur', { following: true, downloading: 1 }),
+ artist('beatles', 'The Beatles', { sort_name: 'Beatles, The', albums: 13, held: 13, wanted: 0 })
+ ],
+ library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
+) {
+ const page: WantArtistsPage = {
+ items,
+ total: items.length,
+ limit: 96,
+ offset: 0,
+ counts: { artists: items.length, wanted: 1, missing: 0, held: 16, following: 1, activity: 1, attention: 0, blocked: 0 },
+ library
+ };
+ vi.mocked(createWantArtistsQuery).mockReturnValue(
+ mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType
+ );
+ return render(CatalogueGrid);
+}
+
+afterEach(() => vi.clearAllMocks());
+
+describe('CatalogueGrid', () => {
+ test('each artist is a card: how many albums are held, and whether they are followed', () => {
+ setup();
+ const cards = screen.getAllByTestId('catalogue-artist');
+ expect(cards.map((c) => c.getAttribute('href'))).toEqual(['/collection/artist/blur', '/collection/artist/beatles']);
+ expect(cards[0].textContent?.replace(/\s+/g, ' ')).toMatch(/Blur 3 \/ 4 held Following · 1 downloading/);
+ const bar = screen.getByRole('progressbar', { name: '13 of 13 albums held' });
+ expect(bar.getAttribute('aria-valuenow')).toBe('13');
+ expect((bar.firstElementChild as HTMLElement).style.width).toBe('100%');
+ });
+
+ test('the alphabet rail files an artist under their sort name', () => {
+ setup();
+ expect(document.getElementById('alpha-B')?.textContent).toMatch(/Blur/);
+ });
+
+ test('the filter asks the server for artists by name or album', async () => {
+ setup();
+ await fireEvent.input(screen.getByRole('searchbox', { name: /filter artists and albums/i }), {
+ target: { value: 'parklife' }
+ });
+ await waitFor(() => expect(createWantArtistsQuery).toHaveBeenLastCalledWith('parklife'));
+ });
+
+ test('an empty catalogue says what lands there', () => {
+ setup([]);
+ expect(screen.getByText(/the catalogue is empty/i)).toBeTruthy();
+ });
+
+ test('says while the library is being listed', () => {
+ setup([], { listed_at: null, unlisted: 0, unmatched: [] });
+ expect(screen.getByTestId('library-listing').textContent).toMatch(/being added to the catalogue/i);
+ });
+
+ test('names the albums no MusicBrainz album matched, on request', async () => {
+ setup(undefined, {
+ listed_at: ago(DAY),
+ unlisted: 12,
+ unmatched: [
+ { album_id: 'al-9', title: 'Demo Tape', artist: 'Blur', detail: 'MusicBrainz has no album called "Demo Tape" by Blur.' }
+ ]
+ });
+ expect(screen.getByTestId('library-listing').textContent).toMatch(/12 albums in your library\s+aren't in the catalogue/i);
+ expect(screen.queryByTestId('unmatched-albums')).toBeNull();
+ await fireEvent.click(screen.getByRole('button', { name: /show them/i }));
+ const list = screen.getByTestId('unmatched-albums');
+ expect(list.textContent).toMatch(/Demo Tape/);
+ expect(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
+ expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
+ });
+});
diff --git a/web/src/lib/components/wanted/CollectionRail.svelte b/web/src/lib/components/wanted/CollectionRail.svelte
new file mode 100644
index 00000000..a2d2e52b
--- /dev/null
+++ b/web/src/lib/components/wanted/CollectionRail.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+
diff --git a/web/src/lib/components/wanted/WantList.svelte b/web/src/lib/components/wanted/WantList.svelte
index 3771a890..bd3ae632 100644
--- a/web/src/lib/components/wanted/WantList.svelte
+++ b/web/src/lib/components/wanted/WantList.svelte
@@ -7,50 +7,51 @@
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import ListContinuation from '#lib/components/ListContinuation.svelte';
+ import Cover from '#lib/components/Cover.svelte';
+ import FollowControl from '#lib/components/FollowControl.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import WantCandidates from '#lib/components/WantCandidates.svelte';
import WantTracks from './WantTracks.svelte';
import type { SearchResult, Want, WantState } from '#lib/api/types.js';
- // One tab of the Wanted view (M502 #5520–#5522): the albums still wanted,
- // the missing albums wanted back, or the ones held. Each open want says
- // what its last search found and how its download is getting on; Releases
- // lists every candidate with its verdict, and Search runs one now. The
- // library's albums were put on the list as held once (#5567), each matched to
- // MusicBrainz (#5569); taking one off leaves its files and stops it being
- // fetched back if they go. The ones no match was found for are named here.
+ // Albums on the Collection's list (M502 #5520–#5522): the ones still
+ // wanted, the missing ones wanted back, or, given an artist, every album
+ // of theirs in the catalogue, held or not, under a header (#5572). Each
+ // open want says what its last search found and how its download is
+ // getting on; Releases lists every candidate with its verdict, and Search
+ // runs one now. Taking a held album off leaves its files and stops it
+ // being fetched back if they go.
//
// The list reads like the library (#5572): artists A–Z, each with its
// albums oldest first, and each album's tracks a click away. The filter
// narrows it to an artist or album by name, on the server, so it finds
// what has not been scrolled to yet.
- let { list }: { list: WantState } = $props();
+ let { list = 'wanted', artist = '' }: { list?: WantState; artist?: string } = $props();
const client = useQueryClient();
let filter = $state('');
const q = $derived(filter.trim());
- const wantsStore = $derived(createWantsQuery(list, q));
+ const wantsStore = $derived(artist ? createWantsQuery('all', '', artist) : createWantsQuery(list, q));
const wants = $derived($wantsStore);
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
- const library = $derived(wants.data?.pages[0]?.library);
+ const head = $derived(wants.data?.pages[0]?.artist);
// The server sends the list artist by artist; each run of one artist's
// albums is a section.
- type ArtistSection = { key: string; name: string; artistId?: string; wants: Want[] };
+ type ArtistSection = { key: string; name: string; mbid: string; wants: Want[] };
const sections = $derived.by(() => {
const out: ArtistSection[] = [];
for (const w of items) {
const key = w.artist_mbid || w.artist_name;
const last = out[out.length - 1];
if (last && last.key === key) last.wants.push(w);
- else out.push({ key, name: w.artist_name, artistId: w.artist_id, wants: [w] });
+ else out.push({ key, name: w.artist_name, mbid: w.artist_mbid, wants: [w] });
}
return out;
});
let busy = $state(null);
- let showUnmatched = $state(false);
let searching = $state(null);
let open = $state(null);
let shown = $state(null);
@@ -187,53 +188,30 @@
}
-{#if list === 'held' && library}
- {#if !library.listed_at}
-
- Your library's albums are being added to this list as held. Albums whose tags don't name them
- are matched on MusicBrainz first, by their artist, title and tracks, about one a second.
-
- {:else if library.unlisted > 0}
-
-
- {library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
- {library.unlisted === 1 ? "isn't" : "aren't"} on this list: no MusicBrainz album matched
- {library.unlisted === 1 ? 'its' : 'their'} artist, title and tracks. Until
- {library.unlisted === 1 ? "it's" : "they're"} matched, {library.unlisted === 1 ? "it isn't" : "they aren't"}
- fetched back if {library.unlisted === 1 ? 'its' : 'their'} files go missing.
-
- {#if library.unmatched.length > 0}
-
(showUnmatched = !showUnmatched)}
- >
-
- {showUnmatched ? 'Hide them' : 'Show them'}
-
- {#if showUnmatched}
-
- {#each library.unmatched as u (u.album_id)}
-
- {u.title}
- · {u.artist}
- {u.detail}
-
- {/each}
-
- {#if library.unmatched.length < library.unlisted}
-
- The first {library.unmatched.length} of {library.unlisted}.
-
- {/if}
- {/if}
- {/if}
+{#if artist && head}
+
{/if}
-{#if !wants.isPending && (items.length > 0 || q)}
+{#if !artist && !wants.isPending && (items.length > 0 || q)}
@@ -242,7 +220,7 @@
{#if wants.isPending}
Reading the wanted list…
{:else if wants.isError && !wants.isFetchNextPageError}
-
Couldn't load the wanted list.
+
Couldn't load {artist ? "this artist's albums" : 'the wanted list'}.
{:else if items.length === 0 && q}
Nothing on this list has an artist or album named like '{q}' .
@@ -251,7 +229,7 @@
- {#if list === 'held'}Nothing on the list has been held yet.
+ {#if artist}None of this artist's albums are in the catalogue.
{:else if list === 'missing'}No missing albums are wanted back.
{:else}Nothing is wanted.{/if}
@@ -268,16 +246,16 @@
{#each sections as a, i (a.key + ':' + i)}
-
+ {#if !artist}
- {#if a.artistId}
- {a.name}
+ {#if a.mbid}
+ {a.name}
{:else}
{a.name}
{/if}
{albums(a.wants.length)}
-
+ {/if}
{:else if arrived(w)}
diff --git a/web/src/lib/components/wanted/WantList.test.ts b/web/src/lib/components/wanted/WantList.test.ts
index 48f4f426..2ad923ea 100644
--- a/web/src/lib/components/wanted/WantList.test.ts
+++ b/web/src/lib/components/wanted/WantList.test.ts
@@ -3,18 +3,26 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query';
import type { LibraryListing, Want, WantGrab, WantsPage } from '#lib/api/types.js';
-vi.mock('#lib/api/wanted.js', () => ({
- createWantsQuery: vi.fn(),
- removeWant: vi.fn().mockResolvedValue(undefined),
- searchWant: vi.fn().mockResolvedValue({
- outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
- }),
- createCandidatesQuery: vi.fn(),
- createWantTracksQuery: vi.fn(),
- grabCandidate: vi.fn(),
- blockCandidate: vi.fn(),
- unblockRelease: vi.fn()
-}));
+vi.mock('#lib/api/wanted.js', async () => {
+ const { mockQuery } = await import('../../../test-utils/query');
+ return {
+ // The artist page's FollowControl.
+ createFollowQuery: vi.fn(() => mockQuery({ data: { following: true } })),
+ createReleaseProfilesQuery: vi.fn(() => mockQuery({ data: { profiles: [], primary_types: [], secondary_types: [] } })),
+ putFollow: vi.fn(),
+ deleteFollow: vi.fn(),
+ createWantsQuery: vi.fn(),
+ removeWant: vi.fn().mockResolvedValue(undefined),
+ searchWant: vi.fn().mockResolvedValue({
+ outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
+ }),
+ createCandidatesQuery: vi.fn(),
+ createWantTracksQuery: vi.fn(),
+ grabCandidate: vi.fn(),
+ blockCandidate: vi.fn(),
+ unblockRelease: vi.fn()
+ };
+});
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import WantList from './WantList.svelte';
@@ -27,53 +35,55 @@ const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function want(id: string, extra: Partial
= {}): Want {
return {
- id,
- release_group_mbid: `rg-${id}`,
- title: 'The Ballad of Darren',
- artist_mbid: 'blur',
- artist_name: 'Blur',
- artist_id: 'art-1',
- primary_type: 'Album',
- secondary_types: [],
- year: '2023',
- reason: 'follow',
- wanted_at: ago(2 * DAY),
- held_at: null,
- image_url: '',
- ...extra
+ id,
+ release_group_mbid: `rg-${id}`,
+ title: 'The Ballad of Darren',
+ artist_mbid: 'blur',
+ artist_name: 'Blur',
+ artist_id: 'art-1',
+ primary_type: 'Album',
+ secondary_types: [],
+ year: '2023',
+ reason: 'follow',
+ wanted_at: ago(2 * DAY),
+ held_at: null,
+ image_url: '',
+ ...extra
};
}
function page(
items: Want[],
- library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
+ library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] },
+ artist?: WantsPage['artist']
): WantsPage {
return {
- items,
- total: items.length,
- limit: 50,
- offset: 0,
- counts: { wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
- library
+ artist,
+ items,
+ total: items.length,
+ limit: 50,
+ offset: 0,
+ counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
+ library
};
}
function grab(extra: Partial = {}): WantGrab {
return {
- id: 'g1',
- name: 'Blur - Blur (1997) [FLAC]',
- grabbed_at: ago(60_000),
- origin: 'schedule',
- quality: 'lossless',
- live: true,
- end_reason: '',
- progress: 0,
- import_state: '',
- import_reason: '',
- end_detail: '',
- imported_at: null,
- imported_files: 0,
- ...extra
+ id: 'g1',
+ name: 'Blur - Blur (1997) [FLAC]',
+ grabbed_at: ago(60_000),
+ origin: 'schedule',
+ quality: 'lossless',
+ live: true,
+ end_reason: '',
+ progress: 0,
+ import_state: '',
+ import_reason: '',
+ end_detail: '',
+ imported_at: null,
+ imported_files: 0,
+ ...extra
};
}
@@ -133,29 +143,6 @@ describe('WantList', () => {
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w1'));
});
- test('the held tab says while the library is being listed', () => {
- setup([], 'held', { listed_at: null, unlisted: 0, unmatched: [] });
- expect(screen.getByTestId('library-listing').textContent).toMatch(/being added to this list/i);
- });
-
- test('the held tab names the albums no MusicBrainz album matched, on request', async () => {
- setup([want('w1', { held_at: ago(DAY) })], 'held', {
- listed_at: ago(DAY),
- unlisted: 12,
- unmatched: [
- { album_id: 'al-9', title: 'Demo Tape', artist: 'Blur', detail: 'MusicBrainz has no album called "Demo Tape" by Blur.' }
- ]
- });
- expect(screen.getByTestId('library-listing').textContent).toMatch(/12 albums in your library\s+aren't on this list/i);
- expect(screen.queryByTestId('unmatched-albums')).toBeNull();
- await fireEvent.click(screen.getByRole('button', { name: /show them/i }));
- const list = screen.getByTestId('unmatched-albums');
- expect(list.textContent).toMatch(/Demo Tape/);
- expect(list.textContent).toMatch(/has no album called/);
- expect(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
- expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
- });
-
test('an empty list explains what lands there', () => {
setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
@@ -184,7 +171,7 @@ describe('WantList', () => {
]);
const line = screen.getByTestId('want-search');
expect(line.textContent).toMatch(/needs you: only the lengths agree/i);
- expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/wanted/activity');
+ expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/collection/activity');
// A live download can't be searched for again.
expect((screen.getByRole('button', { name: /search for the ballad of darren now/i }) as HTMLButtonElement).disabled).toBe(true);
});
@@ -251,9 +238,9 @@ describe('WantList', () => {
expect(sections[0].querySelectorAll('[data-testid="want-row"]').length).toBe(2);
expect(sections[0].textContent).toMatch(/2 albums/);
expect(sections[1].textContent).toMatch(/1 album/);
- // The artist links into the library when it holds them.
- expect(screen.getByRole('link', { name: 'Blur' }).getAttribute('href')).toBe('/artists/art-1');
- expect(screen.queryByRole('link', { name: 'Camel' })).toBeNull();
+ // Each artist opens on their page in the catalogue.
+ expect(screen.getByRole('link', { name: 'Blur' }).getAttribute('href')).toBe('/collection/artist/blur');
+ expect(screen.getByRole('link', { name: 'Camel' }).getAttribute('href')).toBe('/collection/artist/camel');
});
test('Tracks shows the album\'s tracks, with the edition they come from', async () => {
@@ -291,4 +278,40 @@ describe('WantList', () => {
});
await waitFor(() => expect(createWantsQuery).toHaveBeenLastCalledWith('wanted', 'park'));
});
+
+ test("an artist's page lists every album of theirs under a header", () => {
+ vi.mocked(createWantsQuery).mockReturnValue(
+ mockInfiniteQuery({
+ pages: [
+ page([want('w1', { held_at: ago(DAY), album_id: 'al-1', title: 'Parklife' }), want('w2')], undefined, {
+ artist_mbid: 'blur',
+ artist_name: 'Blur',
+ sort_name: 'Blur',
+ artist_id: 'art-1',
+ image_url: '/art/blur',
+ albums: 2,
+ held: 1,
+ wanted: 1,
+ missing: 0,
+ downloading: 0,
+ following: true
+ })
+ ]
+ }) as unknown as ReturnType
+ );
+ vi.mocked(createCandidatesQuery).mockReturnValue(
+ mockQuery({ data: [] }) as unknown as ReturnType
+ );
+ render(WantList, { artist: 'blur' });
+ expect(createWantsQuery).toHaveBeenCalledWith('all', '', 'blur');
+ const head = screen.getByTestId('catalogue-artist-head');
+ expect(head.textContent).toMatch(/Blur/);
+ expect(screen.getByTestId('catalogue-artist-counts').textContent).toMatch(/1 of 2 albums held\s+·\s+1 wanted/);
+ expect(screen.getByRole('link', { name: /open in the library/i }).getAttribute('href')).toBe('/artists/art-1');
+ expect(screen.getByRole('link', { name: /catalogue/i }).getAttribute('href')).toBe('/collection');
+ // One artist: no filter, no artist headings, both albums.
+ expect(screen.queryByRole('searchbox')).toBeNull();
+ expect(screen.getAllByTestId('want-row')).toHaveLength(2);
+ expect(screen.queryByRole('link', { name: 'Blur' })).toBeNull();
+ });
});
diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte
index fb89e25d..656a3cb4 100644
--- a/web/src/routes/admin/missing-files/+page.svelte
+++ b/web/src/routes/admin/missing-files/+page.svelte
@@ -107,7 +107,7 @@
{#if r.held_at}
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
{:else if r.wanted_at}
- On the wanted list since {relativeTime(r.wanted_at)}.
{/if}
diff --git a/web/src/routes/admin/missing-files/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts
index cb804653..a9a113c9 100644
--- a/web/src/routes/admin/missing-files/missing-files.test.ts
+++ b/web/src/routes/admin/missing-files/missing-files.test.ts
@@ -140,7 +140,7 @@ describe('admin missing files', () => {
expect(
within(screen.getByTestId('reacquisition-state')).getByRole('link', { name: /wanted list/i }).getAttribute('href')
).toBe(
- '/wanted/missing'
+ '/collection/missing'
);
});
diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte
index b2952c24..325eca16 100644
--- a/web/src/routes/admin/requests/+page.svelte
+++ b/web/src/routes/admin/requests/+page.svelte
@@ -146,7 +146,7 @@
Requests
Approve or set aside what users have asked for. An approved album or track goes on the
- wanted list ; an approved
+ wanted list ; an approved
artist is followed, wanting what they've released and what they release next.
diff --git a/web/src/routes/collection/+layout.svelte b/web/src/routes/collection/+layout.svelte
new file mode 100644
index 00000000..3da3864e
--- /dev/null
+++ b/web/src/routes/collection/+layout.svelte
@@ -0,0 +1,58 @@
+
+
+
+
+ Collection
+
+ Every artist and album in your collection: what the library holds, and what Minstrel is
+ getting for it, from user requests, new releases of the artists it follows, and albums whose
+ files went missing. It searches your indexers on a schedule, checks each download against
+ MusicBrainz, and files what's right into the library. Searching and quality are set under
+ Integrations .
+
+ {#if verdict}
+
+ {verdict}
+ {#if counts?.attention}
+ See Activity
+ {/if}
+
+ {/if}
+
+
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/web/src/routes/wanted/+layout.ts b/web/src/routes/collection/+layout.ts
similarity index 100%
rename from web/src/routes/wanted/+layout.ts
rename to web/src/routes/collection/+layout.ts
diff --git a/web/src/routes/collection/+page.svelte b/web/src/routes/collection/+page.svelte
new file mode 100644
index 00000000..5a07e2e2
--- /dev/null
+++ b/web/src/routes/collection/+page.svelte
@@ -0,0 +1,8 @@
+
+
+{pageTitle('Collection')}
+
+
diff --git a/web/src/routes/wanted/activity/+page.svelte b/web/src/routes/collection/activity/+page.svelte
similarity index 67%
rename from web/src/routes/wanted/activity/+page.svelte
rename to web/src/routes/collection/activity/+page.svelte
index c98588ee..d36f1008 100644
--- a/web/src/routes/wanted/activity/+page.svelte
+++ b/web/src/routes/collection/activity/+page.svelte
@@ -3,6 +3,6 @@
import DownloadList from '#lib/components/wanted/DownloadList.svelte';
-{pageTitle('Wanted · Activity')}
+{pageTitle('Collection · Activity')}
diff --git a/web/src/routes/collection/artist/[mbid]/+page.svelte b/web/src/routes/collection/artist/[mbid]/+page.svelte
new file mode 100644
index 00000000..9732e908
--- /dev/null
+++ b/web/src/routes/collection/artist/[mbid]/+page.svelte
@@ -0,0 +1,15 @@
+
+
+{pageTitle('Collection · Artist')}
+
+{#key mbid}
+
+{/key}
diff --git a/web/src/routes/wanted/blocked/+page.svelte b/web/src/routes/collection/blocked/+page.svelte
similarity index 66%
rename from web/src/routes/wanted/blocked/+page.svelte
rename to web/src/routes/collection/blocked/+page.svelte
index 3c4ea0d0..07c7b02d 100644
--- a/web/src/routes/wanted/blocked/+page.svelte
+++ b/web/src/routes/collection/blocked/+page.svelte
@@ -3,6 +3,6 @@
import BlockList from '#lib/components/wanted/BlockList.svelte';
-{pageTitle('Wanted · Blocked')}
+{pageTitle('Collection · Blocked')}
diff --git a/web/src/routes/wanted/wanted.test.ts b/web/src/routes/collection/collection.test.ts
similarity index 52%
rename from web/src/routes/wanted/wanted.test.ts
rename to web/src/routes/collection/collection.test.ts
index 4b1bb38d..ce1aab53 100644
--- a/web/src/routes/wanted/wanted.test.ts
+++ b/web/src/routes/collection/collection.test.ts
@@ -3,23 +3,23 @@ import { render, screen } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import { mockInfiniteQuery } from '../../test-utils/query';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
-import type { WantCounts, WantsPage } from '#lib/api/types.js';
+import type { WantArtistsPage, WantCounts } from '#lib/api/types.js';
-const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/wanted') }));
+const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/collection') }));
vi.mock('$app/state', () => pageUrlModule(state));
-vi.mock('#lib/api/wanted.js', () => ({ createWantsQuery: vi.fn() }));
+vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
import WantedLayout from './+layout.svelte';
-import { createWantsQuery } from '#lib/api/wanted.js';
+import { createWantArtistsQuery } from '#lib/api/wanted.js';
-const counts: WantCounts = { wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
+const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
const children = createRawSnippet(() => ({ render: () => `body
` }));
afterEach(() => vi.clearAllMocks());
-function setup(c: WantCounts = counts, path = '/wanted') {
+function setup(c: WantCounts = counts, path = '/collection') {
state.pageUrl = new URL(`http://localhost${path}`);
- const page: WantsPage = {
+ const page: WantArtistsPage = {
items: [],
total: 0,
limit: 50,
@@ -27,42 +27,47 @@ function setup(c: WantCounts = counts, path = '/wanted') {
counts: c,
library: { listed_at: null, unlisted: 0, unmatched: [] }
};
- vi.mocked(createWantsQuery).mockReturnValue(
- mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType
+ vi.mocked(createWantArtistsQuery).mockReturnValue(
+ mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return render(WantedLayout, { children } as any);
}
-describe('/wanted layout', () => {
- test('tabs are links carrying their counts, the current one marked', () => {
- setup(counts, '/wanted/activity');
- const nav = screen.getByRole('navigation', { name: /wanted sections/i });
+describe('/collection layout', () => {
+ test('the sections down the left carry their counts, the current one marked', () => {
+ setup(counts, '/collection/activity');
+ const nav = screen.getByRole('navigation', { name: /collection sections/i });
const links = Array.from(nav.querySelectorAll('a')).map((a) => [a.getAttribute('href'), a.textContent?.replace(/\s+/g, ' ').trim()]);
expect(links).toEqual([
- ['/wanted', 'Wanted 5'],
- ['/wanted/activity', 'Activity 3'],
- ['/wanted/missing', 'Missing files 1'],
- ['/wanted/held', 'Held 9'],
- ['/wanted/following', 'Following 2'],
- ['/wanted/blocked', 'Blocked 4']
+ ['/collection', 'Catalogue 7'],
+ ['/collection/wanted', 'Wanted 5'],
+ ['/collection/missing', 'Missing files 1'],
+ ['/collection/activity', 'Activity 3'],
+ ['/collection/following', 'Following 2'],
+ ['/collection/blocked', 'Blocked 4']
]);
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
- expect(screen.getByRole('link', { name: /^wanted/i })).not.toHaveAttribute('aria-current');
+ expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
expect(screen.getByTestId('tab-body')).toBeTruthy();
});
test('when nothing needs a person the verdict says so', () => {
setup();
- expect(screen.getByTestId('wanted-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
+ expect(screen.getByTestId('collection-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
expect(screen.queryByTestId('tab-attention')).toBeNull();
});
test('downloads waiting on a person lead the page and mark Activity', () => {
setup({ ...counts, attention: 2 });
- const verdict = screen.getByTestId('wanted-verdict');
+ const verdict = screen.getByTestId('collection-verdict');
expect(verdict.textContent).toMatch(/2 downloads need you/);
- expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/wanted/activity');
+ expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/collection/activity');
expect(screen.getByTestId('tab-attention').textContent).toBe('2');
});
+
+ test("an artist's page sits under Catalogue", () => {
+ setup(counts, '/collection/artist/blur');
+ expect(screen.getByRole('link', { name: /^catalogue/i })).toHaveAttribute('aria-current', 'page');
+ });
});
diff --git a/web/src/routes/wanted/following/+page.svelte b/web/src/routes/collection/following/+page.svelte
similarity index 78%
rename from web/src/routes/wanted/following/+page.svelte
rename to web/src/routes/collection/following/+page.svelte
index 7303fe95..b891a091 100644
--- a/web/src/routes/wanted/following/+page.svelte
+++ b/web/src/routes/collection/following/+page.svelte
@@ -4,7 +4,7 @@
import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte';
-{pageTitle('Wanted · Following')}
+{pageTitle('Collection · Following')}
diff --git a/web/src/routes/wanted/missing/+page.svelte b/web/src/routes/collection/missing/+page.svelte
similarity index 66%
rename from web/src/routes/wanted/missing/+page.svelte
rename to web/src/routes/collection/missing/+page.svelte
index 35163ed2..a06ded29 100644
--- a/web/src/routes/wanted/missing/+page.svelte
+++ b/web/src/routes/collection/missing/+page.svelte
@@ -3,6 +3,6 @@
import WantList from '#lib/components/wanted/WantList.svelte';
-
{pageTitle('Wanted · Missing files')}
+
{pageTitle('Collection · Missing files')}
diff --git a/web/src/routes/wanted/+page.svelte b/web/src/routes/collection/wanted/+page.svelte
similarity index 68%
rename from web/src/routes/wanted/+page.svelte
rename to web/src/routes/collection/wanted/+page.svelte
index 9d6f7d36..9655cd2c 100644
--- a/web/src/routes/wanted/+page.svelte
+++ b/web/src/routes/collection/wanted/+page.svelte
@@ -3,6 +3,6 @@
import WantList from '#lib/components/wanted/WantList.svelte';
-
{pageTitle('Wanted')}
+
{pageTitle('Collection · Wanted')}
diff --git a/web/src/routes/wanted/+layout.svelte b/web/src/routes/wanted/+layout.svelte
deleted file mode 100644
index 0e2116bf..00000000
--- a/web/src/routes/wanted/+layout.svelte
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
-
- Wanted
-
- Albums Minstrel is getting: ones users requested, new releases from the artists it follows,
- and albums whose files went missing. It searches your indexers on a schedule, checks each
- download against MusicBrainz, and files what's right into the library. Searching and quality
- are set under Integrations .
-
- {#if verdict}
-
- {verdict}
- {#if counts?.attention}
- See Activity
- {/if}
-
- {/if}
-
-
-
-
- {@render children?.()}
-
diff --git a/web/src/routes/wanted/held/+page.svelte b/web/src/routes/wanted/held/+page.svelte
deleted file mode 100644
index 652d8b20..00000000
--- a/web/src/routes/wanted/held/+page.svelte
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
{pageTitle('Wanted · Held')}
-
-
--
2.54.0
From 10be068dccb2ec2d002a0861fcd9d6b00360ce84 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 22:12:29 -0400
Subject: [PATCH 027/117] fix: the player bar's heart is Lucide's, outlined
then filled as on Android, and its title and artist read at the size of the
rest of the page (#5580)
Co-Authored-By: Claude Opus 5.5
---
web/src/lib/components/LikeButton.svelte | 17 ++++++++++++-----
web/src/lib/components/PlayerBar.svelte | 16 ++++++++++------
2 files changed, 22 insertions(+), 11 deletions(-)
diff --git a/web/src/lib/components/LikeButton.svelte b/web/src/lib/components/LikeButton.svelte
index 34fffcf7..b4b1df5c 100644
--- a/web/src/lib/components/LikeButton.svelte
+++ b/web/src/lib/components/LikeButton.svelte
@@ -1,4 +1,5 @@
{#if library && !library.listed_at}
@@ -29,41 +27,6 @@
Your library's albums are being added to the catalogue. Albums whose tags don't name them are
matched on MusicBrainz first, by their artist, title and tracks, about one a second.
-{:else if library && library.unlisted > 0}
-
-
- {library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
- {library.unlisted === 1 ? "isn't" : "aren't"} in the catalogue: no MusicBrainz album matched
- {library.unlisted === 1 ? 'its' : 'their'} artist, title and tracks. Until
- {library.unlisted === 1 ? "it's" : "they're"} matched, {library.unlisted === 1 ? "it isn't" : "they aren't"}
- fetched back if {library.unlisted === 1 ? 'its' : 'their'} files go missing.
-
- {#if library.unmatched.length > 0}
-
(showUnmatched = !showUnmatched)}
- >
-
- {showUnmatched ? 'Hide them' : 'Show them'}
-
- {#if showUnmatched}
-
- {#each library.unmatched as u (u.album_id)}
-
- {u.title}
- · {u.artist}
- {u.detail}
-
- {/each}
-
- {#if library.unmatched.length < library.unlisted}
-
The first {library.unmatched.length} of {library.unlisted}.
- {/if}
- {/if}
- {/if}
-
{/if}
{#if !artists.isPending && (items.length > 0 || q)}
diff --git a/web/src/lib/components/wanted/CatalogueGrid.test.ts b/web/src/lib/components/wanted/CatalogueGrid.test.ts
index 3b4bc138..cceb1cdc 100644
--- a/web/src/lib/components/wanted/CatalogueGrid.test.ts
+++ b/web/src/lib/components/wanted/CatalogueGrid.test.ts
@@ -32,14 +32,14 @@ function setup(
artist('blur', 'Blur', { following: true, downloading: 1 }),
artist('beatles', 'The Beatles', { sort_name: 'Beatles, The', albums: 13, held: 13, wanted: 0 })
],
- library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
+ library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 }
) {
const page: WantArtistsPage = {
items,
total: items.length,
limit: 96,
offset: 0,
- counts: { artists: items.length, wanted: 1, missing: 0, held: 16, following: 1, activity: 1, attention: 0, blocked: 0 },
+ counts: { artists: items.length, wanted: 1, missing: 0, held: 16, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
library
};
vi.mocked(createWantArtistsQuery).mockReturnValue(
@@ -80,24 +80,7 @@ describe('CatalogueGrid', () => {
});
test('says while the library is being listed', () => {
- setup([], { listed_at: null, unlisted: 0, unmatched: [] });
+ setup([], { listed_at: null, unlisted: 0 });
expect(screen.getByTestId('library-listing').textContent).toMatch(/being added to the catalogue/i);
});
-
- test('names the albums no MusicBrainz album matched, on request', async () => {
- setup(undefined, {
- listed_at: ago(DAY),
- unlisted: 12,
- unmatched: [
- { album_id: 'al-9', title: 'Demo Tape', artist: 'Blur', detail: 'MusicBrainz has no album called "Demo Tape" by Blur.' }
- ]
- });
- expect(screen.getByTestId('library-listing').textContent).toMatch(/12 albums in your library\s+aren't in the catalogue/i);
- expect(screen.queryByTestId('unmatched-albums')).toBeNull();
- await fireEvent.click(screen.getByRole('button', { name: /show them/i }));
- const list = screen.getByTestId('unmatched-albums');
- expect(list.textContent).toMatch(/Demo Tape/);
- expect(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
- expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
- });
});
diff --git a/web/src/lib/components/wanted/CollectionRail.svelte b/web/src/lib/components/wanted/CollectionRail.svelte
index a2d2e52b..6f58c691 100644
--- a/web/src/lib/components/wanted/CollectionRail.svelte
+++ b/web/src/lib/components/wanted/CollectionRail.svelte
@@ -5,7 +5,9 @@
// The Collection's sections, down the left like Lidarr's (#5572): the
// catalogue of artists first, then the sections that show state. On a
// narrow screen the rail runs across the top. Activity carries a second,
- // accented count when downloads are waiting on a person.
+ // accented count when downloads are waiting on a person. Unmatched, like
+ // Lidarr's Unmapped Files, holds the library albums no MusicBrainz album
+ // matched (#5579).
let { counts }: { counts?: WantCounts } = $props();
@@ -17,7 +19,8 @@
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
{ href: '/collection/following', label: 'Following', count: counts?.following },
- { href: '/collection/blocked', label: 'Blocked', count: counts?.blocked }
+ { href: '/collection/blocked', label: 'Blocked', count: counts?.blocked },
+ { href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched }
]);
// An artist's page belongs to the catalogue.
diff --git a/web/src/lib/components/wanted/UnmatchedList.svelte b/web/src/lib/components/wanted/UnmatchedList.svelte
new file mode 100644
index 00000000..85d6ce19
--- /dev/null
+++ b/web/src/lib/components/wanted/UnmatchedList.svelte
@@ -0,0 +1,150 @@
+
+
+{#if unmatched.isPending}
+ Reading the unmatched albums…
+{:else if unmatched.isError}
+ Couldn't load the unmatched albums.
+{:else}
+ {#if toMatch.length === 0}
+
+
+
Every album in your library is matched.
+
+ An album no MusicBrainz album matches by its artist, title and tracks lands here, to be
+ matched by hand or left off.
+
+
+ {:else}
+
+ No MusicBrainz album matched {toMatch.length === 1 ? 'this album' : `these ${toMatch.length} albums`}
+ by artist, title and tracks, so {toMatch.length === 1 ? "it isn't" : "they aren't"} fetched back if
+ {toMatch.length === 1 ? 'its' : 'their'} files go missing. Match each to its MusicBrainz album, or
+ leave it off if MusicBrainz doesn't have it.
+
+
+ {#each toMatch as u (u.album_id)}
+
+
+
+
{u.title}
+
{u.artist} · {tracks(u.tracks)}
+
{u.detail}
+
+
+ (open = open === u.album_id ? null : u.album_id)}
+ class="rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover"
+ >
+ Match…
+
+ onLeaveOff(u)}
+ class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
+ >
+ Leave it off
+
+
+
+ {#if open === u.album_id}
+ onMatch(u, ref)} />
+ {/if}
+
+ {/each}
+
+ {/if}
+
+ {#if leftOff.length > 0}
+
+ Left off
+
+ Kept in the library, never matched again, and not fetched back if their files go missing.
+
+
+ {#each leftOff as u (u.album_id)}
+
+
+
{u.title}
+
+ {u.artist} · {tracks(u.tracks)} · left off {relativeTime(u.decided_at)}
+
+
+ onPutBack(u)}
+ class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
+ >
+ Put back
+
+
+ {/each}
+
+
+ {/if}
+{/if}
diff --git a/web/src/lib/components/wanted/UnmatchedList.test.ts b/web/src/lib/components/wanted/UnmatchedList.test.ts
new file mode 100644
index 00000000..2245246c
--- /dev/null
+++ b/web/src/lib/components/wanted/UnmatchedList.test.ts
@@ -0,0 +1,109 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
+import type { CatalogueSearchResult, UnmatchedAlbum } from '#lib/api/types.js';
+
+vi.mock('#lib/api/wanted.js', async () => {
+ const { mockQuery } = await import('../../../test-utils/query');
+ return {
+ createUnmatchedQuery: vi.fn(() => mockQuery({ data: [] })),
+ matchUnmatched: vi.fn().mockResolvedValue({
+ release_group_mbid: 'rg-1',
+ title: 'Songs for the Deaf',
+ artist: 'Queens of the Stone Age',
+ agree: 9,
+ tracks: 10
+ }),
+ leaveUnmatchedOff: vi.fn().mockResolvedValue(undefined),
+ putUnmatchedBack: vi.fn().mockResolvedValue(undefined)
+ };
+});
+vi.mock('#lib/api/catalogue.js', () => ({ createCatalogueSearchQuery: vi.fn() }));
+vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
+
+import UnmatchedList from './UnmatchedList.svelte';
+import { mockQuery } from '../../../test-utils/query';
+import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js';
+import { createCatalogueSearchQuery } from '#lib/api/catalogue.js';
+import { pushToast } from '#lib/stores/toast.svelte.js';
+
+const DAY = 24 * 3_600_000;
+
+function album(id: string, title: string, artist: string, extra: Partial = {}): UnmatchedAlbum {
+ return {
+ album_id: id,
+ title,
+ artist,
+ detail: `MusicBrainz has no album called "${title}" by ${artist}.`,
+ tracks: 10,
+ left_off: false,
+ decided_at: new Date(Date.now() - 2 * DAY).toISOString(),
+ ...extra
+ };
+}
+
+const result: CatalogueSearchResult = {
+ mbid: 'rg-1',
+ name: 'Songs for the Deaf',
+ secondary_text: 'Queens of the Stone Age · 2002 · Album',
+ image_url: '',
+ artist_mbid: 'qotsa',
+ album_mbid: '',
+ in_library: false,
+ requested: false
+};
+
+afterEach(() => vi.clearAllMocks());
+
+function setup(items: UnmatchedAlbum[], results: CatalogueSearchResult[] = [result]) {
+ vi.mocked(createUnmatchedQuery).mockReturnValue(
+ mockQuery({ data: items }) as unknown as ReturnType
+ );
+ vi.mocked(createCatalogueSearchQuery).mockReturnValue(
+ mockQuery({ data: results }) as unknown as ReturnType
+ );
+ return render(UnmatchedList);
+}
+
+describe('UnmatchedList', () => {
+ test('each unmatched album says why, with its tracks, and links to the album', () => {
+ setup([album('al-1', 'Dial Tone', 'Annadale', { tracks: 1 })]);
+ const row = screen.getByTestId('unmatched-row');
+ expect(row.textContent).toMatch(/Annadale · 1 track/);
+ expect(row.textContent).toMatch(/no album called "Dial Tone"/);
+ expect(screen.getByRole('link', { name: 'Dial Tone' }).getAttribute('href')).toBe('/albums/al-1');
+ });
+
+ test('Match searches MusicBrainz for the album by its own title and artist, and matches a result', async () => {
+ setup([album('al-2', 'Songs for the Deaf', 'Queens of the Stone Age')]);
+ await fireEvent.click(screen.getByRole('button', { name: /match…/i }));
+ expect(createCatalogueSearchQuery).toHaveBeenLastCalledWith('Songs for the Deaf Queens of the Stone Age', 'album');
+ await fireEvent.click(screen.getByRole('button', { name: /match to songs for the deaf/i }));
+ await waitFor(() => expect(matchUnmatched).toHaveBeenCalledWith('al-2', 'rg-1'));
+ await waitFor(() => expect(pushToast).toHaveBeenCalledWith(expect.stringMatching(/9 of 10 tracks agree/)));
+ });
+
+ test('a pasted MusicBrainz link matches the album', async () => {
+ setup([album('al-3', '+++', 'DROELOE')]);
+ await fireEvent.click(screen.getByRole('button', { name: /match…/i }));
+ const link = 'https://musicbrainz.org/release/11111111-0000-4000-8000-000000000003';
+ await fireEvent.input(screen.getByRole('textbox', { name: /musicbrainz album link/i }), { target: { value: link } });
+ await fireEvent.click(screen.getByRole('button', { name: /^match$/i }));
+ await waitFor(() => expect(matchUnmatched).toHaveBeenCalledWith('al-3', link));
+ });
+
+ test("a local band's album is left off, and one left off can be put back", async () => {
+ setup([album('al-1', 'Dial Tone', 'Annadale'), album('al-4', 'Demo', 'Annadale', { left_off: true })]);
+ await fireEvent.click(screen.getByRole('button', { name: /leave it off/i }));
+ await waitFor(() => expect(leaveUnmatchedOff).toHaveBeenCalledWith('al-1'));
+ const leftOff = screen.getByTestId('left-off-row');
+ expect(leftOff.textContent).toMatch(/left off 2d ago/);
+ await fireEvent.click(screen.getByRole('button', { name: /put back/i }));
+ await waitFor(() => expect(putUnmatchedBack).toHaveBeenCalledWith('al-4'));
+ });
+
+ test('with every album matched it says so, and still lists those left off', () => {
+ setup([album('al-4', 'Demo', 'Annadale', { left_off: true })]);
+ expect(screen.getByText(/every album in your library is matched/i)).toBeTruthy();
+ expect(screen.getByTestId('left-off')).toBeTruthy();
+ });
+});
diff --git a/web/src/lib/components/wanted/UnmatchedMatch.svelte b/web/src/lib/components/wanted/UnmatchedMatch.svelte
new file mode 100644
index 00000000..d0ac3942
--- /dev/null
+++ b/web/src/lib/components/wanted/UnmatchedMatch.svelte
@@ -0,0 +1,106 @@
+
+
+
+
+
+ {#if search.isPending && asked}
+
Asking MusicBrainz…
+ {:else if search.isError}
+
Couldn't reach MusicBrainz. A pasted link works the same.
+ {:else if asked && results.length === 0}
+
MusicBrainz has no album like that.
+ {:else if results.length > 0}
+
+ {#each results as r (r.mbid)}
+
+
+
{r.name}
+
{r.secondary_text}
+
+ MusicBrainz
+ onMatch(r.mbid)}
+ aria-label="Match to {r.name}"
+ class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover disabled:opacity-50"
+ >
+ This one
+
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/web/src/lib/components/wanted/WantList.test.ts b/web/src/lib/components/wanted/WantList.test.ts
index 2ad923ea..cdf212ce 100644
--- a/web/src/lib/components/wanted/WantList.test.ts
+++ b/web/src/lib/components/wanted/WantList.test.ts
@@ -54,7 +54,7 @@ function want(id: string, extra: Partial = {}): Want {
function page(
items: Want[],
- library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] },
+ library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 },
artist?: WantsPage['artist']
): WantsPage {
return {
@@ -63,7 +63,7 @@ function page(
total: items.length,
limit: 50,
offset: 0,
- counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
+ counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
library
};
}
diff --git a/web/src/routes/collection/+layout.svelte b/web/src/routes/collection/+layout.svelte
index 3da3864e..136345e1 100644
--- a/web/src/routes/collection/+layout.svelte
+++ b/web/src/routes/collection/+layout.svelte
@@ -21,8 +21,14 @@
}
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`];
if (counts.activity) parts.push(`${counts.activity} downloading`);
+ // Library albums no MusicBrainz album matched wait on a person too (#5579).
+ if (counts.unmatched) {
+ const n = counts.unmatched === 1 ? '1 album in your library needs' : `${counts.unmatched} albums in your library need`;
+ return parts.join(', ') + `. ${n} matching.`;
+ }
return parts.join(', ') + '. Nothing needs you.';
});
+ const needsYou = $derived(!!counts?.attention || !!counts?.unmatched);
@@ -36,10 +42,12 @@
Integrations .
{#if verdict}
-
+
{verdict}
{#if counts?.attention}
See Activity
+ {:else if counts?.unmatched}
+ See Unmatched
{/if}
{/if}
diff --git a/web/src/routes/collection/collection.test.ts b/web/src/routes/collection/collection.test.ts
index ce1aab53..5fa70f2c 100644
--- a/web/src/routes/collection/collection.test.ts
+++ b/web/src/routes/collection/collection.test.ts
@@ -12,7 +12,7 @@ vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
import WantedLayout from './+layout.svelte';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
-const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
+const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4, unmatched: 3 };
const children = createRawSnippet(() => ({ render: () => `
body
` }));
afterEach(() => vi.clearAllMocks());
@@ -25,7 +25,7 @@ function setup(c: WantCounts = counts, path = '/collection') {
limit: 50,
offset: 0,
counts: c,
- library: { listed_at: null, unlisted: 0, unmatched: [] }
+ library: { listed_at: null, unlisted: 0 }
};
vi.mocked(createWantArtistsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType
@@ -45,7 +45,8 @@ describe('/collection layout', () => {
['/collection/missing', 'Missing files 1'],
['/collection/activity', 'Activity 3'],
['/collection/following', 'Following 2'],
- ['/collection/blocked', 'Blocked 4']
+ ['/collection/blocked', 'Blocked 4'],
+ ['/collection/unmatched', 'Unmatched 3']
]);
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
@@ -53,7 +54,7 @@ describe('/collection layout', () => {
});
test('when nothing needs a person the verdict says so', () => {
- setup();
+ setup({ ...counts, unmatched: 0 });
expect(screen.getByTestId('collection-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
expect(screen.queryByTestId('tab-attention')).toBeNull();
});
@@ -66,6 +67,14 @@ describe('/collection layout', () => {
expect(screen.getByTestId('tab-attention').textContent).toBe('2');
});
+ test('library albums waiting to be matched need a person, and link to Unmatched', () => {
+ setup();
+ expect(screen.getByTestId('collection-verdict').textContent).toMatch(
+ /5 albums wanted, 3 downloading\. 3 albums in your library need matching\./
+ );
+ expect(screen.getByRole('link', { name: /see unmatched/i }).getAttribute('href')).toBe('/collection/unmatched');
+ });
+
test("an artist's page sits under Catalogue", () => {
setup(counts, '/collection/artist/blur');
expect(screen.getByRole('link', { name: /^catalogue/i })).toHaveAttribute('aria-current', 'page');
diff --git a/web/src/routes/collection/unmatched/+page.svelte b/web/src/routes/collection/unmatched/+page.svelte
new file mode 100644
index 00000000..44fa469c
--- /dev/null
+++ b/web/src/routes/collection/unmatched/+page.svelte
@@ -0,0 +1,8 @@
+
+
+{pageTitle('Collection · Unmatched')}
+
+
--
2.54.0
From 2f2573dd07a5727331b0d27b319b2080ffc1eaa2 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 22:23:16 -0400
Subject: [PATCH 029/117] fix: the re-tag test sets its clock one statement at
a time, as a prepared statement takes only one (M502 #5579)
Co-Authored-By: Claude Opus 5.5
---
internal/wanted/manual_test.go | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/internal/wanted/manual_test.go b/internal/wanted/manual_test.go
index 21ea8d5e..7478fb8a 100644
--- a/internal/wanted/manual_test.go
+++ b/internal/wanted/manual_test.go
@@ -131,11 +131,16 @@ func TestListLibrary_MatchesARetaggedAlbum_Integration(t *testing.T) {
AlbumID: untagged.ID, Outcome: "unmatched", Detail: "no match", Matcher: libraryMatcher}); err != nil {
t.Fatal(err)
}
- if _, err := pool.Exec(ctx, `
- UPDATE albums SET created_at = now() - interval '2 hours' WHERE id = $1;
- UPDATE tracks SET added_at = now() - interval '2 hours' WHERE album_id = $1;
- UPDATE acquisition_settings SET library_listing_started_at = now() - interval '1 hour',
- library_listed_at = now() - interval '1 hour', library_unlisted = 1`, untagged.ID); err != nil {
+ for _, stmt := range []string{
+ `UPDATE albums SET created_at = now() - interval '2 hours' WHERE id = $1`,
+ `UPDATE tracks SET added_at = now() - interval '2 hours' WHERE album_id = $1`,
+ } {
+ if _, err := pool.Exec(ctx, stmt, untagged.ID); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := pool.Exec(ctx, `UPDATE acquisition_settings SET library_listing_started_at = now() - interval '1 hour',
+ library_listed_at = now() - interval '1 hour', library_unlisted = 1`); err != nil {
t.Fatal(err)
}
// The re-tag: the same tracks, under the album their new tags name.
--
2.54.0
From c0c0f8a71fde88a2298bb9cf049f89852294ec0f Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 10 Oct 2026 23:05:05 -0400
Subject: [PATCH 030/117] feat: Now Playing's cover grows to fill the screen,
and the page takes the cover's dominant colour as Android does (#5592)
The web colour helper now picks Palette's dominant swatch (5-bit
quantise, Palette's default filter, median cut to 16, the most-populated
box) instead of the 1x1 mean, so the web and Android agree; PlayerBar's
accent strip shares it. Now Playing draws Android's gradient (55% at the
top, 18% at 45%, then the page) as a masked background colour that fades
over 600ms between tracks, holding the old colour until the new resolves.
Co-Authored-By: Claude Opus 5.5
---
web/src/lib/media/dominantColor.test.ts | 39 ++++++
web/src/lib/media/dominantColor.ts | 153 +++++++++++++++++++++---
web/src/routes/now-playing/+page.svelte | 53 ++++++--
3 files changed, 220 insertions(+), 25 deletions(-)
create mode 100644 web/src/lib/media/dominantColor.test.ts
diff --git a/web/src/lib/media/dominantColor.test.ts b/web/src/lib/media/dominantColor.test.ts
new file mode 100644
index 00000000..e450d18a
--- /dev/null
+++ b/web/src/lib/media/dominantColor.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from 'vitest';
+import { dominantOf } from './dominantColor';
+
+// Pixels as RGBA, n of each colour.
+function pixels(...runs: [number, number, number, number][]): Uint8ClampedArray {
+ const out: number[] = [];
+ for (const [r, g, b, n] of runs) for (let i = 0; i < n; i++) out.push(r, g, b, 255);
+ return new Uint8ClampedArray(out);
+}
+
+const near = (got: { r: number; g: number; b: number } | null, r: number, g: number, b: number) =>
+ !!got && Math.abs(got.r - r) <= 8 && Math.abs(got.g - g) <= 8 && Math.abs(got.b - b) <= 8;
+
+describe('dominantOf', () => {
+ test('the colour covering the most of the cover wins, not a small bright accent', () => {
+ const got = dominantOf(pixels([40, 70, 140, 700], [250, 20, 60, 100], [30, 160, 60, 200]));
+ expect(near(got, 40, 70, 140)).toBe(true);
+ });
+
+ test('is not the mean: two halves of different colours give one of them', () => {
+ const got = dominantOf(pixels([200, 40, 40, 520], [40, 40, 200, 480]));
+ expect(near(got, 200, 40, 40)).toBe(true);
+ });
+
+ test('near black and near white are passed over, as Palette does', () => {
+ const got = dominantOf(pixels([0, 0, 0, 800], [255, 255, 255, 600], [60, 120, 90, 100]));
+ expect(near(got, 60, 120, 90)).toBe(true);
+ });
+
+ test('a cover of nothing but black and white has no colour', () => {
+ expect(dominantOf(pixels([0, 0, 0, 500], [255, 255, 255, 500]))).toBeNull();
+ });
+
+ test('transparent pixels are not counted', () => {
+ const data = pixels([40, 70, 140, 100], [200, 40, 40, 300]);
+ for (let i = 100 * 4; i < data.length; i += 4) data[i + 3] = 0;
+ expect(near(dominantOf(data), 40, 70, 140)).toBe(true);
+ });
+});
diff --git a/web/src/lib/media/dominantColor.ts b/web/src/lib/media/dominantColor.ts
index 16b76a13..72687480 100644
--- a/web/src/lib/media/dominantColor.ts
+++ b/web/src/lib/media/dominantColor.ts
@@ -1,19 +1,26 @@
-// Cheap dominant-color extractor: loads an image, draws it scaled
-// to a 1x1 canvas, reads the resulting pixel. The browser's bilinear
-// downsample produces an arithmetic-mean color, which approximates
-// the "dominant" tone close enough for an ambient accent strip.
-// Not aiming for ColorThief-grade clustering — that adds ~5KB and
-// noticeable extraction latency. The user-visible feature is just
-// "the page subtly takes on the current track's hue" and a mean
-// color delivers that.
+// The colour that covers the most of a cover, as Android takes it
+// (player/ui/DominantColor.kt): androidx.palette's dominant swatch, not
+// the mean and not the most vibrant. A mean turns a busy cover to mud;
+// a vibrant pick lets a small bright accent tint the whole page. The
+// dominant swatch matches what the eye sees most of (#5592).
//
-// Cover URLs are same-origin (/api/albums//cover), so no CORS
-// dance is needed. Returns null on any failure (load error, decode
-// error, opaque-canvas getImageData reject) so the caller can fall
-// back to the static accent.
+// Palette's method, kept close so the web and Android agree: shrink the
+// image, quantise each pixel to 5 bits a channel, drop the colours its
+// default filter drops (near black, near white, and the skin-tone band
+// on the red "I line"), split what's left by median cut into at most 16
+// boxes, and take the box holding the most pixels.
+//
+// Cover URLs are same-origin (/api/albums//cover), so the canvas is
+// readable. Returns null on any failure (load or decode error, no 2D
+// context, a cover with nothing but black and white) and the caller
+// keeps what it showed before.
export type Rgb = { r: number; g: number; b: number };
+// Palette shrinks a bitmap to about 112×112 before quantising.
+const SAMPLE = 112;
+const MAX_COLORS = 16;
+
const cache = new Map>();
export function dominantColorFromUrl(url: string): Promise {
@@ -32,13 +39,12 @@ function sample(url: string): Promise {
img.onload = () => {
try {
const canvas = document.createElement('canvas');
- canvas.width = 1;
- canvas.height = 1;
+ canvas.width = SAMPLE;
+ canvas.height = SAMPLE;
const ctx = canvas.getContext('2d');
if (!ctx) return resolve(null);
- ctx.drawImage(img, 0, 0, 1, 1);
- const data = ctx.getImageData(0, 0, 1, 1).data;
- resolve({ r: data[0], g: data[1], b: data[2] });
+ ctx.drawImage(img, 0, 0, SAMPLE, SAMPLE);
+ resolve(dominantOf(ctx.getImageData(0, 0, SAMPLE, SAMPLE).data));
} catch {
resolve(null);
}
@@ -48,6 +54,119 @@ function sample(url: string): Promise {
});
}
+type Color5 = { r: number; g: number; b: number; n: number };
+
+// dominantOf picks the dominant colour of RGBA pixel data.
+export function dominantOf(data: Uint8ClampedArray): Rgb | null {
+ const hist = new Map();
+ for (let i = 0; i < data.length; i += 4) {
+ if (data[i + 3] < 128) continue;
+ const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3);
+ hist.set(key, (hist.get(key) ?? 0) + 1);
+ }
+ const colors: Color5[] = [];
+ for (const [key, n] of hist) {
+ const c = { r: (key >> 10) & 31, g: (key >> 5) & 31, b: key & 31, n };
+ if (!ignored(to8(c))) colors.push(c);
+ }
+ if (colors.length === 0) return null;
+
+ let best: Rgb | null = null;
+ let bestN = 0;
+ for (const box of medianCut(colors, MAX_COLORS)) {
+ let n = 0, r = 0, g = 0, b = 0;
+ for (const c of box) {
+ n += c.n;
+ r += c.r * c.n;
+ g += c.g * c.n;
+ b += c.b * c.n;
+ }
+ const avg = to8({ r: r / n, g: g / n, b: b / n });
+ if (n > bestN && !ignored(avg)) {
+ best = avg;
+ bestN = n;
+ }
+ }
+ return best;
+}
+
+// medianCut splits the colours into at most max boxes, always splitting
+// the box with the largest volume, at the population median of its
+// longest side.
+function medianCut(colors: Color5[], max: number): Color5[][] {
+ const boxes: Color5[][] = [colors];
+ while (boxes.length < max) {
+ let pick = -1;
+ let pickVolume = 0;
+ boxes.forEach((box, i) => {
+ if (box.length < 2) return;
+ const v = volume(box);
+ if (v > pickVolume) {
+ pick = i;
+ pickVolume = v;
+ }
+ });
+ if (pick < 0) break;
+ const box = boxes[pick];
+ const side = longestSide(box);
+ box.sort((a, b) => a[side] - b[side]);
+ const total = box.reduce((s, c) => s + c.n, 0);
+ let run = 0;
+ let at = box.length - 1;
+ for (let i = 0; i < box.length - 1; i++) {
+ run += box[i].n;
+ if (run >= total / 2) {
+ at = i + 1;
+ break;
+ }
+ }
+ boxes.splice(pick, 1, box.slice(0, at), box.slice(at));
+ }
+ return boxes;
+}
+
+function range(box: Color5[], side: 'r' | 'g' | 'b'): number {
+ let lo = 31, hi = 0;
+ for (const c of box) {
+ lo = Math.min(lo, c[side]);
+ hi = Math.max(hi, c[side]);
+ }
+ return hi - lo + 1;
+}
+
+function volume(box: Color5[]): number {
+ return range(box, 'r') * range(box, 'g') * range(box, 'b');
+}
+
+function longestSide(box: Color5[]): 'r' | 'g' | 'b' {
+ const r = range(box, 'r'), g = range(box, 'g'), b = range(box, 'b');
+ return r >= g && r >= b ? 'r' : g >= b ? 'g' : 'b';
+}
+
+function to8(c: { r: number; g: number; b: number }): Rgb {
+ return { r: Math.round((c.r * 255) / 31), g: Math.round((c.g * 255) / 31), b: Math.round((c.b * 255) / 31) };
+}
+
+// Palette's default filter: near black, near white, and the band of
+// hues around skin tones (10°–37°, not fully saturated).
+function ignored({ r, g, b }: Rgb): boolean {
+ const [h, s, l] = hsl(r, g, b);
+ return l <= 0.05 || l >= 0.95 || (h >= 10 && h <= 37 && s <= 0.82);
+}
+
+function hsl(r8: number, g8: number, b8: number): [number, number, number] {
+ const r = r8 / 255, g = g8 / 255, b = b8 / 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
+ const l = (max + min) / 2;
+ const d = max - min;
+ if (d === 0) return [0, 0, l];
+ const s = d / (1 - Math.abs(2 * l - 1));
+ let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
+ h *= 60;
+ if (h < 0) h += 360;
+ return [h, s, l];
+}
+
export function rgbToCssString({ r, g, b }: Rgb): string {
return `rgb(${r} ${g} ${b})`;
}
diff --git a/web/src/routes/now-playing/+page.svelte b/web/src/routes/now-playing/+page.svelte
index e9c90166..4ef02562 100644
--- a/web/src/routes/now-playing/+page.svelte
+++ b/web/src/routes/now-playing/+page.svelte
@@ -14,6 +14,7 @@
} from '#lib/player/store.svelte.js';
import { formatDuration } from '#lib/media/duration.js';
import { FALLBACK_COVER, coverUrl } from '#lib/media/covers.js';
+ import { dominantColorFromUrl, rgbToCssString } from '#lib/media/dominantColor.js';
import { useSmoothPosition } from '#lib/player/smoothPosition.svelte.js';
import LikeButton from '#lib/components/LikeButton.svelte';
import TrackMenu from '#lib/components/TrackMenu.svelte';
@@ -41,6 +42,22 @@
player.volume < 0.5 ? Volume1 : Volume2
);
+ // The backdrop takes the cover's dominant colour, as Android's Now
+ // Playing does (#5592). The colour is held until the next cover's
+ // resolves, so a track change fades old to new rather than dipping to
+ // the plain background between them; a track with no cover clears it.
+ let tint = $state('transparent');
+ $effect(() => {
+ const albumId = current?.album_id;
+ if (!albumId) {
+ tint = 'transparent';
+ return;
+ }
+ dominantColorFromUrl(coverUrl(albumId)).then((rgb) => {
+ if (rgb && current?.album_id === albumId) tint = rgbToCssString(rgb);
+ });
+ });
+
function onSeekInput(e: Event) {
const v = Number((e.currentTarget as HTMLInputElement).value);
seekTo(v);
@@ -66,7 +83,17 @@
{pageTitle(current ? `${current.title} · ${current.artist_name}` : 'Now playing')}
-