refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s

The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.

Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.

Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
2026-08-10 14:35:57 -04:00
co-authored by Claude Sonnet 5
parent cbd82a5a74
commit 65333857e2
62 changed files with 4067 additions and 2524 deletions
+2 -1
View File
@@ -273,6 +273,7 @@ func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
PathTemplate: cfg.PathTemplate,
})
yj.downloads.SetMaxConcurrent(cfg.MaxConcurrent)
yj.downloads.SetPreferences(cfg.AutoDownloadPrefs())
go func() {
if err := yj.downloads.Reload(ctx); err != nil {
@@ -289,7 +290,7 @@ func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
yj.wanted.SetInterval(cfg.WantedInterval())
yj.wanted.SetBatch(cfg.WantedBatch)
yj.wanted.SetOnChange(func() {
wailsruntime.EventsEmit(ctx, events.WantedListChanged)
wailsruntime.EventsEmit(ctx, events.RequestsChanged)
})
yj.wanted.Start(ctx)
}
+44
View File
@@ -364,6 +364,50 @@ func (c *Config) SetScanConcurrency(mode string) error {
return nil
}
// GetDownloadPreferences returns the configured auto-download
// guardrails.
func (c *Config) GetDownloadPreferences() download.AutoDownloadPrefs {
if c.Downloads == nil {
return download.AutoDownloadPrefs{}
}
return c.Downloads.AutoDownloadPrefs()
}
// SetDownloadPreferences saves new auto-download guardrails. This only
// persists them; the download package cannot depend on config (config
// already depends on download for UserConfig), so making the change
// live without a restart is the caller's job — the frontend settings
// save calls this and download.Service.SetPreferences in the same
// action, and app.go's initDownloadRuntime applies the saved value to
// the running Manager at startup.
func (c *Config) SetDownloadPreferences(prefs download.AutoDownloadPrefs) error {
if c.Downloads == nil {
c.Downloads = &download.UserConfig{}
c.Downloads.ApplyDefaults()
}
formats := make([]string, 0, len(prefs.AllowedFormats))
for _, f := range prefs.AllowedFormats {
formats = append(formats, string(f))
}
c.Downloads.MinFileSizeMB = prefs.MinSizeMB
c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB
c.Downloads.PreferredFileSizeMB = prefs.PreferredSizeMB
c.Downloads.AllowedFormats = formats
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.logger.Info("download auto-pick preferences updated")
return nil
}
// GetThemeAccentColor returns the configured accent colour.
func (c *Config) GetThemeAccentColor() string {
if c.Theme == nil {
+8
View File
@@ -306,6 +306,14 @@ func applySchema(ctx context.Context, db *sql.DB) error {
return fmt.Errorf("could not apply migrations: %w", err)
}
// The download subsystem's Want/Request rename reuses table names
// (download_requests names a different table before and after), so
// it cannot be a plain sql/migrations file the way an ADD COLUMN
// migration can; see download_rename_migration.go for why.
if err := migrateDownloadRename(ctx, db); err != nil {
return fmt.Errorf("could not migrate download rename: %w", err)
}
return nil
}
@@ -0,0 +1,141 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
)
// migrateDownloadRename performs the download subsystem's table rename
// for existing databases that still carry the old table names: the
// durable "I asked for this" record moved from download_wants to
// download_requests, and the one-shot search-and-grab attempt moved
// from download_requests to download_downloads (see CLAUDE.md and
// .planning/NOTES.md for the full Want->Request / Request->Download
// rename).
//
// This cannot be a plain sql/migrations file the way an ADD COLUMN
// migration is. That pattern's tolerance for "duplicate column name"
// works because a fresh database's sql/schemas pass already produces
// the identical target shape under the identical table name, so
// replaying the ALTER TABLE against it is a safe no-op. Here the name
// "download_requests" is reused for a different table before and after
// the rename, so a fresh database's schema pass creates a real, empty,
// correctly-shaped download_downloads AND a real, empty,
// correctly-shaped (new) download_requests before this ever runs.
// Blindly replaying "ALTER TABLE download_requests RENAME TO
// download_downloads" against that fresh database would rename the new,
// empty Request table into Download's place, destroying the fresh
// install rather than no-opping. Gating on whether the OLD
// download_wants table still exists — a name nothing creates or
// references once this has run — is what tells an old database and a
// fresh (or already migrated) one apart without executing anything
// destructive on the fresh path.
func migrateDownloadRename(ctx context.Context, db *sql.DB) error {
var name string
err := db.QueryRowContext(
ctx,
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
).Scan(&name)
switch {
case errors.Is(err, sql.ErrNoRows):
// Nothing to migrate: either a fresh install (sql/schemas
// already produced the target shape) or a database this has
// already run against.
case err != nil:
return fmt.Errorf("check for download_wants table: %w", err)
default:
if err := runDownloadRename(ctx, db); err != nil {
return err
}
}
return ensureDownloadIndexes(ctx, db)
}
// runDownloadRename performs the actual rename dance against a
// database confirmed to still have the old download_wants table.
func runDownloadRename(ctx context.Context, db *sql.DB) error {
stmts := []string{
// The schema pass already created an empty, correctly-shaped
// download_downloads placeholder under this name (it never
// existed under the old naming), which would otherwise collide
// with the rename below.
`DROP TABLE IF EXISTS download_downloads`,
// 1. Free the "download_requests" name: the old one-shot
// attempt table becomes download_downloads.
`ALTER TABLE download_requests RENAME TO download_downloads`,
`ALTER TABLE download_downloads RENAME COLUMN want_id TO request_id`,
// 2. Claim the now-free "download_requests" name for the
// durable-intent table.
`ALTER TABLE download_wants RENAME TO download_requests`,
// 3. The transfer table's FK now points at download_downloads.
`ALTER TABLE download_items RENAME COLUMN request_id TO download_id`,
// Named indexes survive a table/column rename attached to their
// old name, so drop them here; ensureDownloadIndexes recreates
// them under the names sql/schemas' comments describe.
`DROP INDEX IF EXISTS idx_download_requests_created`,
`DROP INDEX IF EXISTS idx_download_requests_state`,
`DROP INDEX IF EXISTS idx_download_wants_due`,
`DROP INDEX IF EXISTS idx_download_wants_entity`,
`DROP INDEX IF EXISTS idx_download_wants_parent`,
`DROP INDEX IF EXISTS idx_download_items_request`,
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin download rename migration: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range stmts {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("download rename migration %q: %w", stmt, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit download rename migration: %w", err)
}
return nil
}
// ensureDownloadIndexes creates the indexes sql/schemas deliberately
// omits inline for the renamed table/columns (see
// migrateDownloadRename), under their final names. Safe to call
// unconditionally: IF NOT EXISTS makes it a no-op once created, and by
// the time this runs every column/table involved is guaranteed to be
// in its final shape on both a fresh and a migrated database.
func ensureDownloadIndexes(ctx context.Context, db *sql.DB) error {
stmts := []string{
`CREATE INDEX IF NOT EXISTS idx_download_downloads_created
ON download_downloads(created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_download_downloads_state
ON download_downloads(state)`,
`CREATE INDEX IF NOT EXISTS idx_download_requests_due
ON download_requests(next_try_at) WHERE state = 'wanted'`,
`CREATE INDEX IF NOT EXISTS idx_download_requests_entity
ON download_requests(entity, state)`,
`CREATE INDEX IF NOT EXISTS idx_download_requests_parent
ON download_requests(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_download_items_download
ON download_items(download_id)`,
}
for _, stmt := range stmts {
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("ensure download index: %w", err)
}
}
return nil
}
@@ -0,0 +1,365 @@
package database
import (
"database/sql"
"errors"
"testing"
)
// oldDownloadRequestsDDL, oldDownloadWantsDDL and oldDownloadItemsDDL
// are frozen snapshots of the download subsystem's tables exactly as
// they read before the Want/Request rename (see
// download_rename_migration.go) — i.e. what a real user's existing
// database looks like today, before upgrading to a build that includes
// this migration.
const oldDownloadRequestsDDL = `
CREATE TABLE IF NOT EXISTS download_requests (
id TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
release_mbid TEXT,
release_group_mbid TEXT,
recording_mbid TEXT,
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
expected TEXT NOT NULL DEFAULT '[]',
state TEXT NOT NULL DEFAULT 'searching'
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
'verifying', 'tagging', 'importing',
'complete', 'cancelled', 'failed')),
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_requests_created
ON download_requests(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_requests_state
ON download_requests(state);
`
const oldDownloadWantsDDL = `
CREATE TABLE IF NOT EXISTS download_wants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mbid TEXT NOT NULL,
entity TEXT NOT NULL
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
library_id INTEGER NOT NULL,
artist TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'future'
CHECK(scope IN ('future', 'all')),
secondary INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'wanted'
CHECK(state IN ('wanted', 'satisfied', 'paused')),
parent_id INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
last_tried_at DATETIME,
next_try_at DATETIME,
external_ids TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(mbid, library_id),
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_wants_due
ON download_wants(next_try_at)
WHERE state = 'wanted';
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
ON download_wants(entity, state);
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
ON download_wants(parent_id);
`
const oldDownloadItemsDDL = `
CREATE TABLE IF NOT EXISTS download_items (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
provider_id INTEGER NOT NULL,
transport_id INTEGER,
external_id TEXT NOT NULL DEFAULT '',
candidate TEXT NOT NULL DEFAULT '{}',
state TEXT NOT NULL DEFAULT 'queued'
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
'verifying', 'tagging', 'importing',
'complete', 'cancelled', 'failed')),
staging_dir TEXT NOT NULL DEFAULT '',
bytes_done INTEGER NOT NULL DEFAULT 0,
bytes_total INTEGER NOT NULL DEFAULT 0,
imported_paths TEXT NOT NULL DEFAULT '[]',
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_items_live
ON download_items(state)
WHERE state NOT IN ('complete', 'cancelled', 'failed');
CREATE INDEX IF NOT EXISTS idx_download_items_request
ON download_items(request_id);
CREATE INDEX IF NOT EXISTS idx_download_items_state
ON download_items(state);
`
// seedOldDownloadSchema builds the pre-rename download tables and
// inserts one row of real data into each, standing in for a real
// user's database at the moment it upgrades.
func seedOldDownloadSchema(t *testing.T, db *sql.DB) {
t.Helper()
for _, ddl := range []string{
oldDownloadWantsDDL, oldDownloadRequestsDDL, oldDownloadItemsDDL,
} {
if _, err := db.ExecContext(t.Context(), ddl); err != nil {
t.Fatalf("create old download schema: %v", err)
}
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
); err != nil {
t.Fatalf("seed library: %v", err)
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO download_wants
(id, mbid, entity, library_id, artist, title, state)
VALUES (1, 'artist-mbid', 'artist', 1, 'Radiohead', 'Radiohead', 'wanted')`,
); err != nil {
t.Fatalf("seed download_wants: %v", err)
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO download_requests
(id, library_id, source, want_id, release_group_mbid, artist, album, state)
VALUES ('dl-1', 1, 'wanted', 1, 'rg-mbid', 'Radiohead', 'OK Computer', 'complete')`,
); err != nil {
t.Fatalf("seed download_requests: %v", err)
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO download_items
(id, request_id, provider_id, state)
VALUES ('item-1', 'dl-1', 1, 'complete')`,
); err != nil {
t.Fatalf("seed download_items: %v", err)
}
}
// TestDownloadRename_FreshInstallUntouched confirms applySchema on a
// brand-new database produces the target shape directly and that
// migrateDownloadRename's gate (checking for the old download_wants
// table) is a no-op there — the destructive path this test guards
// against is exactly the one described in download_rename_migration.go:
// blindly replaying the rename against a fresh database's already-
// correct, empty download_requests/download_downloads tables.
func TestDownloadRename_FreshInstallUntouched(t *testing.T) {
t.Parallel()
db := openMemDB(t)
if err := applySchema(t.Context(), db); err != nil {
t.Fatalf("apply schema (fresh): %v", err)
}
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
var name string
err := db.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
table,
).Scan(&name)
if err != nil {
t.Errorf("expected table %q to exist on a fresh install: %v", table, err)
}
}
var stray string
err := db.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
).Scan(&stray)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("old download_wants table should not exist on a fresh install, err=%v", err)
}
// Both auto-download guardrail indexes sql/schemas deliberately
// omits (see ensureDownloadIndexes) must still exist.
for _, idx := range []string{
"idx_download_requests_due",
"idx_download_requests_entity",
"idx_download_requests_parent",
"idx_download_items_download",
} {
var name string
err := db.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`,
idx,
).Scan(&name)
if err != nil {
t.Errorf("expected index %q to exist on a fresh install: %v", idx, err)
}
}
}
// TestDownloadRename_UpgradesExistingDatabase is the regression test
// for the rename itself: an old-shaped database (download_wants +
// old-style download_requests, both with real rows) must end up with
// the same table names, column names, and data a fresh install would
// have — nothing dropped, nothing silently emptied.
func TestDownloadRename_UpgradesExistingDatabase(t *testing.T) {
t.Parallel()
fresh := openMemDB(t)
if err := applySchema(t.Context(), fresh); err != nil {
t.Fatalf("apply schema (fresh): %v", err)
}
upgraded := openMemDB(t)
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
if err != nil {
t.Fatalf("read libraries schema: %v", err)
}
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
t.Fatalf("create libraries table: %v", err)
}
seedOldDownloadSchema(t, upgraded)
if err := applySchema(t.Context(), upgraded); err != nil {
t.Fatalf("apply schema (upgrade path): %v", err)
}
// Column order must match a fresh install's, for the same reason
// TestMigrations_ColumnOrderMatchesFreshInstall checks tagging_items:
// sqlc's `SELECT *` binds positionally.
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
freshCols := tableColumns(t, fresh, table)
upgradedCols := tableColumns(t, upgraded, table)
if len(freshCols) != len(upgradedCols) {
t.Fatalf(
"%s: column count mismatch: fresh has %d (%v), upgraded has %d (%v)",
table, len(freshCols), freshCols, len(upgradedCols), upgradedCols,
)
}
for i := range freshCols {
if freshCols[i] != upgradedCols[i] {
t.Errorf(
"%s: column order mismatch at %d: fresh %q, upgraded %q\nfresh: %v\nupgraded: %v",
table,
i,
freshCols[i],
upgradedCols[i],
freshCols,
upgradedCols,
)
}
}
}
// The seeded rows survived the rename under their new names.
var (
requestMBID string
requestEntity string
)
err = upgraded.QueryRowContext(
t.Context(), `SELECT mbid, entity FROM download_requests WHERE id = 1`,
).Scan(&requestMBID, &requestEntity)
if err != nil {
t.Fatalf("seeded request row missing after rename: %v", err)
}
if requestMBID != "artist-mbid" || requestEntity != "artist" {
t.Errorf("request row corrupted: mbid=%q entity=%q", requestMBID, requestEntity)
}
var (
downloadRequestID sql.NullInt64
downloadAlbum string
)
err = upgraded.QueryRowContext(
t.Context(),
`SELECT request_id, album FROM download_downloads WHERE id = 'dl-1'`,
).Scan(&downloadRequestID, &downloadAlbum)
if err != nil {
t.Fatalf("seeded download row missing after rename: %v", err)
}
if !downloadRequestID.Valid || downloadRequestID.Int64 != 1 {
t.Errorf("download.request_id = %v, want 1 (renamed from want_id)", downloadRequestID)
}
if downloadAlbum != "OK Computer" {
t.Errorf("download.album = %q, want OK Computer", downloadAlbum)
}
var itemDownloadID string
err = upgraded.QueryRowContext(
t.Context(),
`SELECT download_id FROM download_items WHERE id = 'item-1'`,
).Scan(&itemDownloadID)
if err != nil {
t.Fatalf("seeded item row missing after rename: %v", err)
}
if itemDownloadID != "dl-1" {
t.Errorf("item.download_id = %q, want dl-1 (renamed from request_id)", itemDownloadID)
}
// The old table is gone, not just emptied.
var stray string
err = upgraded.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
).Scan(&stray)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("old download_wants table should be gone after migration, err=%v", err)
}
// Running the whole thing again (as a second app startup would) is
// a no-op: the gate sees no download_wants table and does nothing
// further, so this must not error or duplicate anything.
if err := applySchema(t.Context(), upgraded); err != nil {
t.Fatalf("apply schema a second time: %v", err)
}
var count int
if err := upgraded.QueryRowContext(
t.Context(), `SELECT COUNT(*) FROM download_requests`,
).Scan(&count); err != nil {
t.Fatalf("count download_requests: %v", err)
}
if count != 1 {
t.Errorf("download_requests has %d rows after a second migration pass, want 1", count)
}
}
+67 -59
View File
@@ -22,69 +22,81 @@ WHERE id = ?;
DELETE FROM download_providers
WHERE id = ?;
-- name: CreateDownloadRequest :exec
INSERT INTO download_requests (
id, library_id, source, want_id, release_mbid, release_group_mbid,
-- ---------------------------------------------------------------------
-- Downloads (one-shot search+grab attempts)
-- ---------------------------------------------------------------------
-- name: CreateDownload :exec
INSERT INTO download_downloads (
id, library_id, source, request_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
-- name: GetDownloadRequest :one
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
-- name: GetDownload :one
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
FROM download_downloads
WHERE id = ?;
-- name: ListDownloadRequests :many
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
-- name: ListDownloads :many
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
FROM download_downloads
ORDER BY created_at DESC
LIMIT ?;
-- name: ListLiveDownloadRequests :many
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
-- name: ListLiveDownloads :many
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
FROM download_downloads
WHERE state NOT IN ('complete', 'cancelled', 'failed')
ORDER BY created_at;
-- name: SetDownloadRequestState :exec
UPDATE download_requests
-- name: SetDownloadState :exec
UPDATE download_downloads
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: DeleteDownloadRequest :exec
DELETE FROM download_requests
-- name: DeleteDownload :exec
DELETE FROM download_downloads
WHERE id = ?;
-- name: DeleteFinishedDownloads :exec
DELETE FROM download_downloads
WHERE state IN ('complete', 'cancelled', 'failed');
-- ---------------------------------------------------------------------
-- Items (transfer records within a download)
-- ---------------------------------------------------------------------
-- name: CreateDownloadItem :exec
INSERT INTO download_items (
id, request_id, provider_id, transport_id, external_id,
id, download_id, provider_id, transport_id, external_id,
candidate, state, staging_dir, bytes_total
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
-- name: GetDownloadItem :one
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
WHERE id = ?;
-- name: ListDownloadItemsForRequest :many
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
-- name: ListDownloadItemsForDownload :many
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
WHERE request_id = ?
WHERE download_id = ?
ORDER BY created_at;
-- name: ListLiveDownloadItems :many
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
@@ -112,72 +124,68 @@ SET imported_paths = ?, state = 'complete', error = '',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: DeleteFinishedDownloadRequests :exec
DELETE FROM download_requests
WHERE state IN ('complete', 'cancelled', 'failed');
-- ---------------------------------------------------------------------
-- Wants
-- Requests (durable "I asked for this" records)
-- ---------------------------------------------------------------------
-- name: UpsertDownloadWant :one
-- Adding something already wanted is not an error and must not reset
-- the retry clock, so the conflict path only refreshes display text and
-- un-pauses nothing. scope and secondary are updated because asking
-- again with a wider scope is a real change of intent.
INSERT INTO download_wants (
-- name: UpsertDownloadRequest :one
-- Adding something already requested is not an error and must not
-- reset the retry clock, so the conflict path only refreshes display
-- text and un-pauses nothing. scope and secondary are updated because
-- asking again with a wider scope is a real change of intent.
INSERT INTO download_requests (
mbid, entity, library_id, artist, title, scope, secondary,
parent_id, next_try_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(mbid, library_id) DO UPDATE SET
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
ELSE download_wants.artist END,
ELSE download_requests.artist END,
title = CASE WHEN excluded.title <> '' THEN excluded.title
ELSE download_wants.title END,
ELSE download_requests.title END,
scope = excluded.scope,
secondary = excluded.secondary,
updated_at = CURRENT_TIMESTAMP
RETURNING id;
-- name: GetDownloadWant :one
SELECT * FROM download_wants WHERE id = ?;
-- name: GetDownloadRequest :one
SELECT * FROM download_requests WHERE id = ?;
-- name: GetDownloadWantByMBID :one
SELECT * FROM download_wants WHERE mbid = ? AND library_id = ?;
-- name: GetDownloadRequestByMBID :one
SELECT * FROM download_requests WHERE mbid = ? AND library_id = ?;
-- name: ListDownloadWants :many
SELECT * FROM download_wants
-- name: ListDownloadRequests :many
SELECT * FROM download_requests
ORDER BY
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
artist, title;
-- name: ListDownloadWantsByEntity :many
SELECT * FROM download_wants
-- name: ListDownloadRequestsByEntity :many
SELECT * FROM download_requests
WHERE entity = ? AND state = ?
ORDER BY id;
-- name: ListDueDownloadWants :many
-- name: ListDueDownloadRequests :many
-- Everything the reconciler should act on this pass: wanted, not an
-- artist subscription (those expand rather than download), and either
-- never tried or past its backoff.
SELECT * FROM download_wants
SELECT * FROM download_requests
WHERE state = 'wanted'
AND entity <> 'artist'
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
ORDER BY attempts, created_at
LIMIT ?;
-- name: ListChildDownloadWants :many
SELECT * FROM download_wants WHERE parent_id = ? ORDER BY id;
-- name: ListChildDownloadRequests :many
SELECT * FROM download_requests WHERE parent_id = ? ORDER BY id;
-- name: SetDownloadWantState :exec
UPDATE download_wants
-- name: SetDownloadRequestState :exec
UPDATE download_requests
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: RecordDownloadWantAttempt :exec
UPDATE download_wants
-- name: RecordDownloadRequestAttempt :exec
UPDATE download_requests
SET attempts = attempts + 1,
last_error = ?,
last_tried_at = CURRENT_TIMESTAMP,
@@ -185,19 +193,19 @@ SET attempts = attempts + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: SatisfyDownloadWant :exec
UPDATE download_wants
-- name: SatisfyDownloadRequest :exec
UPDATE download_requests
SET state = 'satisfied', last_error = '', next_try_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: SetDownloadWantExternalIDs :exec
UPDATE download_wants
-- name: SetDownloadRequestExternalIDs :exec
UPDATE download_requests
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: DeleteDownloadWant :exec
DELETE FROM download_wants WHERE id = ?;
-- name: DeleteDownloadRequest :exec
DELETE FROM download_requests WHERE id = ?;
-- name: DeleteSatisfiedDownloadWants :exec
DELETE FROM download_wants WHERE state = 'satisfied';
-- name: DeleteSatisfiedDownloadRequests :exec
DELETE FROM download_requests WHERE state = 'satisfied';
@@ -0,0 +1,53 @@
-- One row per "go find me this", from the moment a search is fired
-- until the files are in the library or the attempt is abandoned. A
-- Download is one attempt: it searches, it grabs, it succeeds or
-- fails, and then it is history. See download_requests.sql for the
-- durable record a Download may be attached to.
--
-- release_mbid / release_group_mbid are the anchor: a download that
-- carries one can be matched against a known tracklist at import time,
-- which is what makes unattended completion safe. Free-text downloads
-- (both NULL) are always presented to the user for confirmation.
--
-- `expected` caches the anchor's tracklist as JSON so ranking and
-- import do not have to re-resolve it, and so a download survives the
-- explore index being rebuilt underneath it.
CREATE TABLE IF NOT EXISTS download_downloads (
id TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
-- source records where the download came from: 'explore-album',
-- 'explore-artist', 'missing-album', 'wanted', 'manual'.
source TEXT NOT NULL DEFAULT 'manual',
-- request_id is set when this download is attached to a durable
-- Request (see download_requests.sql), whether raised by the
-- reconciler or attached to a manual anchored download. NULL for
-- a free-text download with nothing stable to attach to.
-- Requests are durable and downloads are disposable, so the delete
-- is a SET NULL rather than a cascade in either direction.
request_id INTEGER REFERENCES download_requests(id) ON DELETE SET NULL,
release_mbid TEXT,
release_group_mbid TEXT,
-- recording_mbid anchors a single-track download raised from a
-- track-level request.
recording_mbid TEXT,
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
expected TEXT NOT NULL DEFAULT '[]',
state TEXT NOT NULL DEFAULT 'searching'
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
'verifying', 'tagging', 'importing',
'complete', 'cancelled', 'failed')),
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_downloads_created
ON download_downloads(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_downloads_state
ON download_downloads(state);
@@ -1,4 +1,4 @@
-- One row per grab attempt against one candidate. A request can have
-- One row per grab attempt against one candidate. A download can have
-- several: the first pick stalls, the user picks another, or a
-- search-only provider's candidate is fetched by a separate transport
-- (in which case provider_id is the searcher and transport_id is the
@@ -17,7 +17,7 @@
CREATE TABLE IF NOT EXISTS download_items (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
download_id TEXT NOT NULL,
provider_id INTEGER NOT NULL,
transport_id INTEGER,
external_id TEXT NOT NULL DEFAULT '',
@@ -35,15 +35,18 @@ CREATE TABLE IF NOT EXISTS download_items (
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
FOREIGN KEY(download_id) REFERENCES download_downloads(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_items_live
ON download_items(state)
WHERE state NOT IN ('complete', 'cancelled', 'failed');
CREATE INDEX IF NOT EXISTS idx_download_items_request
ON download_items(request_id);
CREATE INDEX IF NOT EXISTS idx_download_items_state
ON download_items(state);
-- idx_download_items_download is deliberately NOT declared here: on an
-- existing database this table already exists at schema-pass time with
-- its old column still named request_id, so an inline CREATE INDEX on
-- download_id would fail outright. See ensureDownloadIndexes in
-- backend/database/download_rename_migration.go.
@@ -1,49 +1,79 @@
-- One row per "go find me this", from the moment the user asks until
-- the files are in the library or the attempt is abandoned.
-- A Request is a persistent "I want this", stored as a MusicBrainz ID
-- and almost nothing else. It outlives every download attempt made on
-- its behalf: nothing being findable today is the normal case for
-- obscure music, and the correct response is to try again next week,
-- not to show the user a failed row they have to remember to retry.
--
-- release_mbid / release_group_mbid are the anchor: a request that
-- carries one can be matched against a known tracklist at import time,
-- which is what makes unattended completion safe. Free-text requests
-- (both NULL) are always presented to the user for confirmation.
--
-- `expected` caches the anchor's tracklist as JSON so ranking and
-- import do not have to re-resolve it, and so a request survives the
-- explore index being rebuilt underneath it.
-- Because a Request is only an MBID, it stays true when everything
-- around it changes: the explore index is rebuilt, a provider is
-- swapped out, the release the user originally saw is superseded by a
-- remaster. The display fields are a cache for the list view and are
-- never consulted for matching.
CREATE TABLE IF NOT EXISTS download_requests (
id TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
-- source records where the request came from: 'explore-album',
-- 'explore-artist', 'missing-album', 'wanted', 'manual'.
source TEXT NOT NULL DEFAULT 'manual',
-- want_id is set when the reconciler raised this request from the
-- wanted list, so the outcome can be written back to the want.
-- NULL for one-off requests the user started by hand. Requests are
-- disposable and wants are not, so the delete is a SET NULL rather
-- than a cascade in either direction.
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
release_mbid TEXT,
release_group_mbid TEXT,
-- recording_mbid anchors a single-track request raised from a
-- track-level want.
recording_mbid TEXT,
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
expected TEXT NOT NULL DEFAULT '[]',
state TEXT NOT NULL DEFAULT 'searching'
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
'verifying', 'tagging', 'importing',
'complete', 'cancelled', 'failed')),
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
id INTEGER PRIMARY KEY AUTOINCREMENT,
mbid TEXT NOT NULL,
entity TEXT NOT NULL
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
library_id INTEGER NOT NULL,
-- Display text, cached so the list renders without touching the
-- explore index. Neither is authoritative; the MBID is.
artist TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'future'
CHECK(scope IN ('future', 'all')),
-- secondary controls whether an artist request's expansion includes
-- compilations, live albums and remixes. Off by default: someone
-- subscribing to an artist wants the albums, not six versions of
-- the same greatest-hits package.
secondary INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'wanted'
CHECK(state IN ('wanted', 'satisfied', 'paused')),
-- parent_id links a request the reconciler derived from an artist
-- request. Deleting the artist takes its derived children with
-- it, but children the user pinned themselves have no parent and
-- stay.
parent_id INTEGER,
-- Retry bookkeeping. attempts drives the backoff; last_error is
-- the most recent reason it did not work out, which for a request
-- is information rather than a failure.
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
last_tried_at DATETIME,
next_try_at DATETIME,
-- external_ids maps provider row ID to that provider's own
-- identifier for this request, for clients that keep their own
-- persistent list (a Lidarr artist ID). JSON object.
external_ids TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- One request per thing per library. Asking twice is not two
-- requests, and this is what lets an artist expansion re-run every
-- reconcile without accumulating duplicates.
UNIQUE(mbid, library_id),
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
FOREIGN KEY(parent_id) REFERENCES download_requests(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_requests_created
ON download_requests(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_requests_state
ON download_requests(state);
-- idx_download_requests_{due,entity,parent} are deliberately NOT
-- declared here. This table name is reused from the old one-shot
-- attempt table (also called download_requests before the Want/Request
-- rename), so on an existing database this CREATE TABLE is a no-op
-- against a table that, at schema-pass time, is still shaped like the
-- OLD attempts table and lacks these columns entirely — an inline
-- CREATE INDEX here would fail outright rather than just no-op. See
-- migrateDownloadRename/ensureDownloadIndexes in
-- backend/database/download_rename_migration.go, which create these
-- once the rename has actually happened (or immediately, on a fresh
-- database where the columns exist from the start).
@@ -1,76 +0,0 @@
-- One row per "go find me this", from the moment the user asks until
-- the files are in the library or the attempt is abandoned.
--
-- release_mbid / release_group_mbid are the anchor: a request that
-- carries one can be matched against a known tracklist at import time,
-- which is what makes unattended completion safe. Free-text requests
-- (both NULL) are always presented to the user for confirmation.
--
-- `expected` caches the anchor's tracklist as JSON so ranking and
-- import do not have to re-resolve it, and so a request survives the
-- explore index being rebuilt underneath it.
CREATE TABLE IF NOT EXISTS download_wants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mbid TEXT NOT NULL,
entity TEXT NOT NULL
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
library_id INTEGER NOT NULL,
-- Display text, cached so the wanted list renders without touching
-- the explore index. Neither is authoritative; the MBID is.
artist TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'future'
CHECK(scope IN ('future', 'all')),
-- secondary controls whether an artist want's expansion includes
-- compilations, live albums and remixes. Off by default: someone
-- subscribing to an artist wants the albums, not six versions of
-- the same greatest-hits package.
secondary INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'wanted'
CHECK(state IN ('wanted', 'satisfied', 'paused')),
-- parent_id links a want the reconciler derived from an artist
-- want. Deleting the artist takes its derived children with it,
-- but children the user pinned themselves have no parent and stay.
parent_id INTEGER,
-- Retry bookkeeping. attempts drives the backoff; last_error is
-- the most recent reason it did not work out, which for a wanted
-- item is information rather than a failure.
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
last_tried_at DATETIME,
next_try_at DATETIME,
-- external_ids maps provider row ID to that provider's own
-- identifier for this want, for clients that keep their own
-- persistent list (a Lidarr artist ID). JSON object.
external_ids TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- One want per thing per library. Asking twice is not two wants,
-- and this is what lets an artist expansion re-run every reconcile
-- without accumulating duplicates.
UNIQUE(mbid, library_id),
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_wants_due
ON download_wants(next_try_at)
WHERE state = 'wanted';
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
ON download_wants(entity, state);
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
ON download_wants(parent_id);
+292 -284
View File
@@ -10,9 +10,55 @@ import (
"database/sql"
)
const createDownload = `-- name: CreateDownload :exec
INSERT INTO download_downloads (
id, library_id, source, request_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
type CreateDownloadParams struct {
ID string
LibraryID int64
Source string
RequestID sql.NullInt64
ReleaseMbid sql.NullString
ReleaseGroupMbid sql.NullString
RecordingMbid sql.NullString
Artist string
Album string
Query string
Expected string
State string
}
// ---------------------------------------------------------------------
// Downloads (one-shot search+grab attempts)
// ---------------------------------------------------------------------
func (q *Queries) CreateDownload(ctx context.Context, arg CreateDownloadParams) error {
_, err := q.db.ExecContext(ctx, createDownload,
arg.ID,
arg.LibraryID,
arg.Source,
arg.RequestID,
arg.ReleaseMbid,
arg.ReleaseGroupMbid,
arg.RecordingMbid,
arg.Artist,
arg.Album,
arg.Query,
arg.Expected,
arg.State,
)
return err
}
const createDownloadItem = `-- name: CreateDownloadItem :exec
INSERT INTO download_items (
id, request_id, provider_id, transport_id, external_id,
id, download_id, provider_id, transport_id, external_id,
candidate, state, staging_dir, bytes_total
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -20,7 +66,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
type CreateDownloadItemParams struct {
ID string
RequestID string
DownloadID string
ProviderID int64
TransportID sql.NullInt64
ExternalID string
@@ -30,10 +76,13 @@ type CreateDownloadItemParams struct {
BytesTotal int64
}
// ---------------------------------------------------------------------
// Items (transfer records within a download)
// ---------------------------------------------------------------------
func (q *Queries) CreateDownloadItem(ctx context.Context, arg CreateDownloadItemParams) error {
_, err := q.db.ExecContext(ctx, createDownloadItem,
arg.ID,
arg.RequestID,
arg.DownloadID,
arg.ProviderID,
arg.TransportID,
arg.ExternalID,
@@ -72,44 +121,13 @@ func (q *Queries) CreateDownloadProvider(ctx context.Context, arg CreateDownload
return id, err
}
const createDownloadRequest = `-- name: CreateDownloadRequest :exec
INSERT INTO download_requests (
id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
const deleteDownload = `-- name: DeleteDownload :exec
DELETE FROM download_downloads
WHERE id = ?
`
type CreateDownloadRequestParams struct {
ID string
LibraryID int64
Source string
WantID sql.NullInt64
ReleaseMbid sql.NullString
ReleaseGroupMbid sql.NullString
RecordingMbid sql.NullString
Artist string
Album string
Query string
Expected string
State string
}
func (q *Queries) CreateDownloadRequest(ctx context.Context, arg CreateDownloadRequestParams) error {
_, err := q.db.ExecContext(ctx, createDownloadRequest,
arg.ID,
arg.LibraryID,
arg.Source,
arg.WantID,
arg.ReleaseMbid,
arg.ReleaseGroupMbid,
arg.RecordingMbid,
arg.Artist,
arg.Album,
arg.Query,
arg.Expected,
arg.State,
)
func (q *Queries) DeleteDownload(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteDownload, id)
return err
}
@@ -124,45 +142,66 @@ func (q *Queries) DeleteDownloadProvider(ctx context.Context, id int64) error {
}
const deleteDownloadRequest = `-- name: DeleteDownloadRequest :exec
DELETE FROM download_requests
WHERE id = ?
DELETE FROM download_requests WHERE id = ?
`
func (q *Queries) DeleteDownloadRequest(ctx context.Context, id string) error {
func (q *Queries) DeleteDownloadRequest(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteDownloadRequest, id)
return err
}
const deleteDownloadWant = `-- name: DeleteDownloadWant :exec
DELETE FROM download_wants WHERE id = ?
`
func (q *Queries) DeleteDownloadWant(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteDownloadWant, id)
return err
}
const deleteFinishedDownloadRequests = `-- name: DeleteFinishedDownloadRequests :exec
DELETE FROM download_requests
const deleteFinishedDownloads = `-- name: DeleteFinishedDownloads :exec
DELETE FROM download_downloads
WHERE state IN ('complete', 'cancelled', 'failed')
`
func (q *Queries) DeleteFinishedDownloadRequests(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteFinishedDownloadRequests)
func (q *Queries) DeleteFinishedDownloads(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteFinishedDownloads)
return err
}
const deleteSatisfiedDownloadWants = `-- name: DeleteSatisfiedDownloadWants :exec
DELETE FROM download_wants WHERE state = 'satisfied'
const deleteSatisfiedDownloadRequests = `-- name: DeleteSatisfiedDownloadRequests :exec
DELETE FROM download_requests WHERE state = 'satisfied'
`
func (q *Queries) DeleteSatisfiedDownloadWants(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteSatisfiedDownloadWants)
func (q *Queries) DeleteSatisfiedDownloadRequests(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteSatisfiedDownloadRequests)
return err
}
const getDownload = `-- name: GetDownload :one
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_downloads
WHERE id = ?
`
func (q *Queries) GetDownload(ctx context.Context, id string) (DownloadDownload, error) {
row := q.db.QueryRowContext(ctx, getDownload, id)
var i DownloadDownload
err := row.Scan(
&i.ID,
&i.LibraryID,
&i.Source,
&i.RequestID,
&i.ReleaseMbid,
&i.ReleaseGroupMbid,
&i.RecordingMbid,
&i.Artist,
&i.Album,
&i.Query,
&i.Expected,
&i.State,
&i.Error,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDownloadItem = `-- name: GetDownloadItem :one
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
@@ -174,7 +213,7 @@ func (q *Queries) GetDownloadItem(ctx context.Context, id string) (DownloadItem,
var i DownloadItem
err := row.Scan(
&i.ID,
&i.RequestID,
&i.DownloadID,
&i.ProviderID,
&i.TransportID,
&i.ExternalID,
@@ -213,43 +252,12 @@ func (q *Queries) GetDownloadProvider(ctx context.Context, id int64) (DownloadPr
}
const getDownloadRequest = `-- name: GetDownloadRequest :one
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
WHERE id = ?
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests WHERE id = ?
`
func (q *Queries) GetDownloadRequest(ctx context.Context, id string) (DownloadRequest, error) {
func (q *Queries) GetDownloadRequest(ctx context.Context, id int64) (DownloadRequest, error) {
row := q.db.QueryRowContext(ctx, getDownloadRequest, id)
var i DownloadRequest
err := row.Scan(
&i.ID,
&i.LibraryID,
&i.Source,
&i.WantID,
&i.ReleaseMbid,
&i.ReleaseGroupMbid,
&i.RecordingMbid,
&i.Artist,
&i.Album,
&i.Query,
&i.Expected,
&i.State,
&i.Error,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDownloadWant = `-- name: GetDownloadWant :one
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE id = ?
`
func (q *Queries) GetDownloadWant(ctx context.Context, id int64) (DownloadWant, error) {
row := q.db.QueryRowContext(ctx, getDownloadWant, id)
var i DownloadWant
err := row.Scan(
&i.ID,
&i.Mbid,
@@ -272,18 +280,18 @@ func (q *Queries) GetDownloadWant(ctx context.Context, id int64) (DownloadWant,
return i, err
}
const getDownloadWantByMBID = `-- name: GetDownloadWantByMBID :one
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE mbid = ? AND library_id = ?
const getDownloadRequestByMBID = `-- name: GetDownloadRequestByMBID :one
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests WHERE mbid = ? AND library_id = ?
`
type GetDownloadWantByMBIDParams struct {
type GetDownloadRequestByMBIDParams struct {
Mbid string
LibraryID int64
}
func (q *Queries) GetDownloadWantByMBID(ctx context.Context, arg GetDownloadWantByMBIDParams) (DownloadWant, error) {
row := q.db.QueryRowContext(ctx, getDownloadWantByMBID, arg.Mbid, arg.LibraryID)
var i DownloadWant
func (q *Queries) GetDownloadRequestByMBID(ctx context.Context, arg GetDownloadRequestByMBIDParams) (DownloadRequest, error) {
row := q.db.QueryRowContext(ctx, getDownloadRequestByMBID, arg.Mbid, arg.LibraryID)
var i DownloadRequest
err := row.Scan(
&i.ID,
&i.Mbid,
@@ -306,19 +314,19 @@ func (q *Queries) GetDownloadWantByMBID(ctx context.Context, arg GetDownloadWant
return i, err
}
const listChildDownloadWants = `-- name: ListChildDownloadWants :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE parent_id = ? ORDER BY id
const listChildDownloadRequests = `-- name: ListChildDownloadRequests :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests WHERE parent_id = ? ORDER BY id
`
func (q *Queries) ListChildDownloadWants(ctx context.Context, parentID sql.NullInt64) ([]DownloadWant, error) {
rows, err := q.db.QueryContext(ctx, listChildDownloadWants, parentID)
func (q *Queries) ListChildDownloadRequests(ctx context.Context, parentID sql.NullInt64) ([]DownloadRequest, error) {
rows, err := q.db.QueryContext(ctx, listChildDownloadRequests, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadWant
var items []DownloadRequest
for rows.Next() {
var i DownloadWant
var i DownloadRequest
if err := rows.Scan(
&i.ID,
&i.Mbid,
@@ -351,17 +359,17 @@ func (q *Queries) ListChildDownloadWants(ctx context.Context, parentID sql.NullI
return items, nil
}
const listDownloadItemsForRequest = `-- name: ListDownloadItemsForRequest :many
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
const listDownloadItemsForDownload = `-- name: ListDownloadItemsForDownload :many
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
WHERE request_id = ?
WHERE download_id = ?
ORDER BY created_at
`
func (q *Queries) ListDownloadItemsForRequest(ctx context.Context, requestID string) ([]DownloadItem, error) {
rows, err := q.db.QueryContext(ctx, listDownloadItemsForRequest, requestID)
func (q *Queries) ListDownloadItemsForDownload(ctx context.Context, downloadID string) ([]DownloadItem, error) {
rows, err := q.db.QueryContext(ctx, listDownloadItemsForDownload, downloadID)
if err != nil {
return nil, err
}
@@ -371,7 +379,7 @@ func (q *Queries) ListDownloadItemsForRequest(ctx context.Context, requestID str
var i DownloadItem
if err := rows.Scan(
&i.ID,
&i.RequestID,
&i.DownloadID,
&i.ProviderID,
&i.TransportID,
&i.ExternalID,
@@ -436,16 +444,14 @@ func (q *Queries) ListDownloadProviders(ctx context.Context) ([]DownloadProvider
}
const listDownloadRequests = `-- name: ListDownloadRequests :many
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
ORDER BY created_at DESC
LIMIT ?
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
ORDER BY
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
artist, title
`
func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]DownloadRequest, error) {
rows, err := q.db.QueryContext(ctx, listDownloadRequests, limit)
func (q *Queries) ListDownloadRequests(ctx context.Context) ([]DownloadRequest, error) {
rows, err := q.db.QueryContext(ctx, listDownloadRequests)
if err != nil {
return nil, err
}
@@ -453,11 +459,113 @@ func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]Down
var items []DownloadRequest
for rows.Next() {
var i DownloadRequest
if err := rows.Scan(
&i.ID,
&i.Mbid,
&i.Entity,
&i.LibraryID,
&i.Artist,
&i.Title,
&i.Scope,
&i.Secondary,
&i.State,
&i.ParentID,
&i.Attempts,
&i.LastError,
&i.LastTriedAt,
&i.NextTryAt,
&i.ExternalIds,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDownloadRequestsByEntity = `-- name: ListDownloadRequestsByEntity :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
WHERE entity = ? AND state = ?
ORDER BY id
`
type ListDownloadRequestsByEntityParams struct {
Entity string
State string
}
func (q *Queries) ListDownloadRequestsByEntity(ctx context.Context, arg ListDownloadRequestsByEntityParams) ([]DownloadRequest, error) {
rows, err := q.db.QueryContext(ctx, listDownloadRequestsByEntity, arg.Entity, arg.State)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadRequest
for rows.Next() {
var i DownloadRequest
if err := rows.Scan(
&i.ID,
&i.Mbid,
&i.Entity,
&i.LibraryID,
&i.Artist,
&i.Title,
&i.Scope,
&i.Secondary,
&i.State,
&i.ParentID,
&i.Attempts,
&i.LastError,
&i.LastTriedAt,
&i.NextTryAt,
&i.ExternalIds,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDownloads = `-- name: ListDownloads :many
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_downloads
ORDER BY created_at DESC
LIMIT ?
`
func (q *Queries) ListDownloads(ctx context.Context, limit int64) ([]DownloadDownload, error) {
rows, err := q.db.QueryContext(ctx, listDownloads, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadDownload
for rows.Next() {
var i DownloadDownload
if err := rows.Scan(
&i.ID,
&i.LibraryID,
&i.Source,
&i.WantID,
&i.RequestID,
&i.ReleaseMbid,
&i.ReleaseGroupMbid,
&i.RecordingMbid,
@@ -483,108 +591,8 @@ func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]Down
return items, nil
}
const listDownloadWants = `-- name: ListDownloadWants :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
ORDER BY
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
artist, title
`
func (q *Queries) ListDownloadWants(ctx context.Context) ([]DownloadWant, error) {
rows, err := q.db.QueryContext(ctx, listDownloadWants)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadWant
for rows.Next() {
var i DownloadWant
if err := rows.Scan(
&i.ID,
&i.Mbid,
&i.Entity,
&i.LibraryID,
&i.Artist,
&i.Title,
&i.Scope,
&i.Secondary,
&i.State,
&i.ParentID,
&i.Attempts,
&i.LastError,
&i.LastTriedAt,
&i.NextTryAt,
&i.ExternalIds,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDownloadWantsByEntity = `-- name: ListDownloadWantsByEntity :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
WHERE entity = ? AND state = ?
ORDER BY id
`
type ListDownloadWantsByEntityParams struct {
Entity string
State string
}
func (q *Queries) ListDownloadWantsByEntity(ctx context.Context, arg ListDownloadWantsByEntityParams) ([]DownloadWant, error) {
rows, err := q.db.QueryContext(ctx, listDownloadWantsByEntity, arg.Entity, arg.State)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadWant
for rows.Next() {
var i DownloadWant
if err := rows.Scan(
&i.ID,
&i.Mbid,
&i.Entity,
&i.LibraryID,
&i.Artist,
&i.Title,
&i.Scope,
&i.Secondary,
&i.State,
&i.ParentID,
&i.Attempts,
&i.LastError,
&i.LastTriedAt,
&i.NextTryAt,
&i.ExternalIds,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDueDownloadWants = `-- name: ListDueDownloadWants :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
const listDueDownloadRequests = `-- name: ListDueDownloadRequests :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
WHERE state = 'wanted'
AND entity <> 'artist'
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
@@ -595,15 +603,15 @@ LIMIT ?
// Everything the reconciler should act on this pass: wanted, not an
// artist subscription (those expand rather than download), and either
// never tried or past its backoff.
func (q *Queries) ListDueDownloadWants(ctx context.Context, limit int64) ([]DownloadWant, error) {
rows, err := q.db.QueryContext(ctx, listDueDownloadWants, limit)
func (q *Queries) ListDueDownloadRequests(ctx context.Context, limit int64) ([]DownloadRequest, error) {
rows, err := q.db.QueryContext(ctx, listDueDownloadRequests, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadWant
var items []DownloadRequest
for rows.Next() {
var i DownloadWant
var i DownloadRequest
if err := rows.Scan(
&i.ID,
&i.Mbid,
@@ -637,7 +645,7 @@ func (q *Queries) ListDueDownloadWants(ctx context.Context, limit int64) ([]Down
}
const listLiveDownloadItems = `-- name: ListLiveDownloadItems :many
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
@@ -656,7 +664,7 @@ func (q *Queries) ListLiveDownloadItems(ctx context.Context) ([]DownloadItem, er
var i DownloadItem
if err := rows.Scan(
&i.ID,
&i.RequestID,
&i.DownloadID,
&i.ProviderID,
&i.TransportID,
&i.ExternalID,
@@ -683,29 +691,29 @@ func (q *Queries) ListLiveDownloadItems(ctx context.Context) ([]DownloadItem, er
return items, nil
}
const listLiveDownloadRequests = `-- name: ListLiveDownloadRequests :many
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
const listLiveDownloads = `-- name: ListLiveDownloads :many
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
FROM download_downloads
WHERE state NOT IN ('complete', 'cancelled', 'failed')
ORDER BY created_at
`
func (q *Queries) ListLiveDownloadRequests(ctx context.Context) ([]DownloadRequest, error) {
rows, err := q.db.QueryContext(ctx, listLiveDownloadRequests)
func (q *Queries) ListLiveDownloads(ctx context.Context) ([]DownloadDownload, error) {
rows, err := q.db.QueryContext(ctx, listLiveDownloads)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadRequest
var items []DownloadDownload
for rows.Next() {
var i DownloadRequest
var i DownloadDownload
if err := rows.Scan(
&i.ID,
&i.LibraryID,
&i.Source,
&i.WantID,
&i.RequestID,
&i.ReleaseMbid,
&i.ReleaseGroupMbid,
&i.RecordingMbid,
@@ -731,8 +739,8 @@ func (q *Queries) ListLiveDownloadRequests(ctx context.Context) ([]DownloadReque
return items, nil
}
const recordDownloadWantAttempt = `-- name: RecordDownloadWantAttempt :exec
UPDATE download_wants
const recordDownloadRequestAttempt = `-- name: RecordDownloadRequestAttempt :exec
UPDATE download_requests
SET attempts = attempts + 1,
last_error = ?,
last_tried_at = CURRENT_TIMESTAMP,
@@ -741,26 +749,26 @@ SET attempts = attempts + 1,
WHERE id = ?
`
type RecordDownloadWantAttemptParams struct {
type RecordDownloadRequestAttemptParams struct {
LastError string
NextTryAt sql.NullTime
ID int64
}
func (q *Queries) RecordDownloadWantAttempt(ctx context.Context, arg RecordDownloadWantAttemptParams) error {
_, err := q.db.ExecContext(ctx, recordDownloadWantAttempt, arg.LastError, arg.NextTryAt, arg.ID)
func (q *Queries) RecordDownloadRequestAttempt(ctx context.Context, arg RecordDownloadRequestAttemptParams) error {
_, err := q.db.ExecContext(ctx, recordDownloadRequestAttempt, arg.LastError, arg.NextTryAt, arg.ID)
return err
}
const satisfyDownloadWant = `-- name: SatisfyDownloadWant :exec
UPDATE download_wants
const satisfyDownloadRequest = `-- name: SatisfyDownloadRequest :exec
UPDATE download_requests
SET state = 'satisfied', last_error = '', next_try_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
func (q *Queries) SatisfyDownloadWant(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, satisfyDownloadWant, id)
func (q *Queries) SatisfyDownloadRequest(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, satisfyDownloadRequest, id)
return err
}
@@ -831,53 +839,53 @@ func (q *Queries) SetDownloadItemState(ctx context.Context, arg SetDownloadItemS
return err
}
const setDownloadRequestState = `-- name: SetDownloadRequestState :exec
const setDownloadRequestExternalIDs = `-- name: SetDownloadRequestExternalIDs :exec
UPDATE download_requests
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadRequestStateParams struct {
State string
Error string
ID string
}
func (q *Queries) SetDownloadRequestState(ctx context.Context, arg SetDownloadRequestStateParams) error {
_, err := q.db.ExecContext(ctx, setDownloadRequestState, arg.State, arg.Error, arg.ID)
return err
}
const setDownloadWantExternalIDs = `-- name: SetDownloadWantExternalIDs :exec
UPDATE download_wants
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadWantExternalIDsParams struct {
type SetDownloadRequestExternalIDsParams struct {
ExternalIds string
ID int64
}
func (q *Queries) SetDownloadWantExternalIDs(ctx context.Context, arg SetDownloadWantExternalIDsParams) error {
_, err := q.db.ExecContext(ctx, setDownloadWantExternalIDs, arg.ExternalIds, arg.ID)
func (q *Queries) SetDownloadRequestExternalIDs(ctx context.Context, arg SetDownloadRequestExternalIDsParams) error {
_, err := q.db.ExecContext(ctx, setDownloadRequestExternalIDs, arg.ExternalIds, arg.ID)
return err
}
const setDownloadWantState = `-- name: SetDownloadWantState :exec
UPDATE download_wants
const setDownloadRequestState = `-- name: SetDownloadRequestState :exec
UPDATE download_requests
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadWantStateParams struct {
type SetDownloadRequestStateParams struct {
State string
LastError string
ID int64
}
func (q *Queries) SetDownloadWantState(ctx context.Context, arg SetDownloadWantStateParams) error {
_, err := q.db.ExecContext(ctx, setDownloadWantState, arg.State, arg.LastError, arg.ID)
func (q *Queries) SetDownloadRequestState(ctx context.Context, arg SetDownloadRequestStateParams) error {
_, err := q.db.ExecContext(ctx, setDownloadRequestState, arg.State, arg.LastError, arg.ID)
return err
}
const setDownloadState = `-- name: SetDownloadState :exec
UPDATE download_downloads
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadStateParams struct {
State string
Error string
ID string
}
func (q *Queries) SetDownloadState(ctx context.Context, arg SetDownloadStateParams) error {
_, err := q.db.ExecContext(ctx, setDownloadState, arg.State, arg.Error, arg.ID)
return err
}
@@ -906,25 +914,25 @@ func (q *Queries) UpdateDownloadProvider(ctx context.Context, arg UpdateDownload
return err
}
const upsertDownloadWant = `-- name: UpsertDownloadWant :one
const upsertDownloadRequest = `-- name: UpsertDownloadRequest :one
INSERT INTO download_wants (
INSERT INTO download_requests (
mbid, entity, library_id, artist, title, scope, secondary,
parent_id, next_try_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(mbid, library_id) DO UPDATE SET
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
ELSE download_wants.artist END,
ELSE download_requests.artist END,
title = CASE WHEN excluded.title <> '' THEN excluded.title
ELSE download_wants.title END,
ELSE download_requests.title END,
scope = excluded.scope,
secondary = excluded.secondary,
updated_at = CURRENT_TIMESTAMP
RETURNING id
`
type UpsertDownloadWantParams struct {
type UpsertDownloadRequestParams struct {
Mbid string
Entity string
LibraryID int64
@@ -936,14 +944,14 @@ type UpsertDownloadWantParams struct {
}
// ---------------------------------------------------------------------
// Wants
// Requests (durable "I asked for this" records)
// ---------------------------------------------------------------------
// Adding something already wanted is not an error and must not reset
// the retry clock, so the conflict path only refreshes display text and
// un-pauses nothing. scope and secondary are updated because asking
// again with a wider scope is a real change of intent.
func (q *Queries) UpsertDownloadWant(ctx context.Context, arg UpsertDownloadWantParams) (int64, error) {
row := q.db.QueryRowContext(ctx, upsertDownloadWant,
// Adding something already requested is not an error and must not
// reset the retry clock, so the conflict path only refreshes display
// text and un-pauses nothing. scope and secondary are updated because
// asking again with a wider scope is a real change of intent.
func (q *Queries) UpsertDownloadRequest(ctx context.Context, arg UpsertDownloadRequestParams) (int64, error) {
row := q.db.QueryRowContext(ctx, upsertDownloadRequest,
arg.Mbid,
arg.Entity,
arg.LibraryID,
+19 -19
View File
@@ -74,9 +74,27 @@ type CoverArt struct {
MimeType string
}
type DownloadDownload struct {
ID string
LibraryID int64
Source string
RequestID sql.NullInt64
ReleaseMbid sql.NullString
ReleaseGroupMbid sql.NullString
RecordingMbid sql.NullString
Artist string
Album string
Query string
Expected string
State string
Error string
CreatedAt time.Time
UpdatedAt time.Time
}
type DownloadItem struct {
ID string
RequestID string
DownloadID string
ProviderID int64
TransportID sql.NullInt64
ExternalID string
@@ -102,24 +120,6 @@ type DownloadProvider struct {
}
type DownloadRequest struct {
ID string
LibraryID int64
Source string
WantID sql.NullInt64
ReleaseMbid sql.NullString
ReleaseGroupMbid sql.NullString
RecordingMbid sql.NullString
Artist string
Album string
Query string
Expected string
State string
Error string
CreatedAt time.Time
UpdatedAt time.Time
}
type DownloadWant struct {
ID int64
Mbid string
Entity string
+15 -13
View File
@@ -142,10 +142,17 @@ var tables = []Table{
"only; the sized variants beside it are derived filenames and " +
"must be expanded when deleting (see library.coverArtFileSet).",
},
{
Name: "download_downloads", Kind: Authored, Lifetime: Cascade,
Note: "One row per 'go find me this', i.e. one search-and-grab " +
"attempt. Cascades from libraries, and cascades onward to " +
"download_items. Terminal rows are history the user clears " +
"explicitly.",
},
{
Name: "download_items", Kind: Authored, Lifetime: Cascade,
Note: "One grab attempt per row, with the ranked candidate stored " +
"as JSON. Cascades from download_requests. The candidate blob " +
"as JSON. Cascades from download_downloads. The candidate blob " +
"is kept rather than re-derived because a provider's result " +
"set is ephemeral — the peer that had the files may be gone, " +
"and the row still has to explain why it was chosen.",
@@ -159,18 +166,13 @@ var tables = []Table{
},
{
Name: "download_requests", Kind: Authored, Lifetime: Cascade,
Note: "One row per 'go find me this'. Cascades from libraries, " +
"and cascades onward to download_items. Terminal rows are " +
"history the user clears explicitly.",
},
{
Name: "download_wants", Kind: Authored, Lifetime: Cascade,
Note: "The wanted list: one MBID per row, plus retry bookkeeping. " +
"Cascades from libraries, and from a parent artist want to " +
"the album wants it derived. Unlike download_requests these " +
"are not history — a want outlives every attempt made on it " +
"and is only removed by the user or by the library coming " +
"to own what it names.",
Note: "The durable request list: one MBID per row, plus retry " +
"bookkeeping. Cascades from libraries, and from a parent " +
"artist request to the album requests it derived. Unlike " +
"download_downloads these are not history — a request " +
"outlives every download attempt made on it and is only " +
"removed by the user or by the library coming to own what it " +
"names.",
},
{
Name: "explore_champion_fts", Kind: Cache, Lifetime: Retained, FTS: true,
+11 -10
View File
@@ -223,22 +223,23 @@ func TestAuthoredCascadesAreDeliberate(t *testing.T) {
// Download history is scoped to the library it imported into.
// When that library is removed the files it acquired go with
// it, so a request describing "fetch this into library 3" has
// it, so a download describing "fetch this into library 3" has
// nothing left to mean. Keeping the rows would leave history
// pointing at a library the user deleted.
"download_requests": true,
"download_downloads": true,
// Items belong to their request and have no independent
// Items belong to their download and have no independent
// meaning; they cascade with it.
"download_items": true,
// A want says "put this in library 3". Delete that library and
// there is no longer anywhere for it to go, so the want has
// nothing left to mean — the same reasoning as its requests.
// The second cascade, artist want to derived album wants, is
// the point of the subscription: unsubscribing from an artist
// must stop the albums it queued on the user's behalf.
"download_wants": true,
// A request says "put this in library 3". Delete that library
// and there is no longer anywhere for it to go, so the request
// has nothing left to mean — the same reasoning as its
// downloads. The second cascade, artist request to derived
// album requests, is the point of the subscription:
// unsubscribing from an artist must stop the albums it queued
// on the user's behalf.
"download_requests": true,
}
for _, entry := range datamap.ByKind(datamap.Authored) {
+10 -10
View File
@@ -84,17 +84,17 @@ func TestPerProviderCapSerializesTransfers(t *testing.T) {
// Three requests against the same one-at-a-time provider.
for i := range 3 {
req := fourTrackRequest()
req.ID = "req-" + string(rune('a'+i))
dl := fourTrackDownload()
dl.ID = "dl-" + string(rune('a'+i))
if err := f.store.CreateRequest(ctx, req); err != nil {
t.Fatalf("CreateRequest: %v", err)
if err := f.store.CreateDownload(ctx, dl); err != nil {
t.Fatalf("CreateDownload: %v", err)
}
candidate := slow.Candidates[0]
candidate.ProviderID = 1
go f.manager.grab(ctx, req, candidate, nil)
go f.manager.grab(ctx, dl, candidate, nil)
}
// Give all three a chance to reach the transport, then check how
@@ -139,17 +139,17 @@ func TestPerProviderCapAllowsParallelWhereSafe(t *testing.T) {
ctx := context.Background()
for i := range 3 {
req := fourTrackRequest()
req.ID = "req-" + string(rune('a'+i))
dl := fourTrackDownload()
dl.ID = "dl-" + string(rune('a'+i))
if err := f.store.CreateRequest(ctx, req); err != nil {
t.Fatalf("CreateRequest: %v", err)
if err := f.store.CreateDownload(ctx, dl); err != nil {
t.Fatalf("CreateDownload: %v", err)
}
candidate := fast.Candidates[0]
candidate.ProviderID = 1
go f.manager.grab(ctx, req, candidate, nil)
go f.manager.grab(ctx, dl, candidate, nil)
}
waitFor(
+28
View File
@@ -33,6 +33,34 @@ type UserConfig struct {
// for. A large list should be worked through steadily rather than
// in one burst that every provider sees as a flood.
WantedBatch int `toml:"WantedBatch"`
// MinFileSizeMB, MaxFileSizeMB and PreferredFileSizeMB bound and
// nudge what auto-pick (interactive or via the request list) may
// grab without asking. Zero on any of them is permissive: see
// AutoDownloadPrefs.
MinFileSizeMB int `toml:"MinFileSizeMB"`
MaxFileSizeMB int `toml:"MaxFileSizeMB"`
PreferredFileSizeMB int `toml:"PreferredFileSizeMB"`
// AllowedFormats restricts auto-pick to these formats. Empty means
// no restriction. Values are Format strings ("flac", "mp3", ...).
AllowedFormats []string `toml:"AllowedFormats"`
}
// AutoDownloadPrefs converts the persisted guardrail fields to the
// runtime type Manager and the ranker consume.
func (c *UserConfig) AutoDownloadPrefs() AutoDownloadPrefs {
formats := make([]Format, 0, len(c.AllowedFormats))
for _, f := range c.AllowedFormats {
formats = append(formats, Format(f))
}
return AutoDownloadPrefs{
MinSizeMB: c.MinFileSizeMB,
MaxSizeMB: c.MaxFileSizeMB,
PreferredSizeMB: c.PreferredFileSizeMB,
AllowedFormats: formats,
}
}
// ApplyDefaults fills unset fields.
+2 -2
View File
@@ -92,7 +92,7 @@ func (f *FakeProvider) Close() error {
// Search returns the configured candidates.
func (f *FakeProvider) Search(
ctx context.Context,
_ Request,
_ Download,
) ([]Candidate, error) {
f.mu.Lock()
f.SearchCalls++
@@ -208,7 +208,7 @@ var errNoDelegateStatus = errors.New("fake: no delegate status configured")
// Delegate records the call and returns a fixed external ID.
func (f *FakeProvider) Delegate(
_ context.Context,
_ Request,
_ Download,
) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
+21 -21
View File
@@ -125,7 +125,7 @@ type ImportResult struct {
// retry or inspect it; only a fully successful import releases staging.
func (i *Importer) Import(
ctx context.Context,
req Request,
dl Download,
result Result,
opts ImportOptions,
) (ImportResult, error) {
@@ -139,13 +139,13 @@ func (i *Importer) Import(
return ImportResult{}, ErrNoAudio
}
if err := checkCompleteness(len(audio), req); err != nil {
if err := checkCompleteness(len(audio), dl); err != nil {
return ImportResult{}, err
}
// Align staged files to the expected tracklist so tags and
// filenames reflect the release, not the uploader's naming.
plan := i.planFiles(audio, req)
plan := i.planFiles(audio, dl)
out := ImportResult{
Paths: make([]string, 0, len(plan)),
@@ -158,7 +158,7 @@ func (i *Importer) Import(
}
if opts.WriteTags {
if err := i.tagFile(p, req); err != nil {
if err := i.tagFile(p, dl); err != nil {
// A file that cannot be tagged is still worth importing
// — the scanner will read whatever tags it has, and the
// autotag queue can pick it up later. Losing the whole
@@ -173,7 +173,7 @@ func (i *Importer) Import(
}
}
dest, err := i.destinationFor(p, req, opts)
dest, err := i.destinationFor(p, dl, opts)
if err != nil {
return out, err
}
@@ -199,7 +199,7 @@ type plannedFile struct {
}
// planFiles aligns staged files to the expected tracklist.
func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
func (i *Importer) planFiles(audio []string, dl Download) []plannedFile {
files := make([]CandidateFile, 0, len(audio))
for _, a := range audio {
@@ -211,10 +211,10 @@ func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
})
}
matched, _ := matchFiles(files, req.Expected)
matched, _ := matchFiles(files, dl.Expected)
byPosition := make(map[int]ExpectedTrack, len(req.Expected))
for _, e := range req.Expected {
byPosition := make(map[int]ExpectedTrack, len(dl.Expected))
for _, e := range dl.Expected {
byPosition[e.Position] = e
}
@@ -253,14 +253,14 @@ func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
}
// tagFile writes the release's metadata onto a staged file.
func (i *Importer) tagFile(p plannedFile, req Request) error {
func (i *Importer) tagFile(p plannedFile, dl Download) error {
if i.tags == nil || !p.Matched {
return nil
}
changes := tagwriter.TagChanges{
tagwriter.FieldAlbum: req.Album,
tagwriter.FieldAlbumArtist: req.Artist,
tagwriter.FieldAlbum: dl.Album,
tagwriter.FieldAlbumArtist: dl.Artist,
tagwriter.FieldTitle: p.Track.Title,
tagwriter.FieldTrackNumber: p.Track.Position,
}
@@ -268,7 +268,7 @@ func (i *Importer) tagFile(p plannedFile, req Request) error {
if p.Track.Artist != "" {
changes[tagwriter.FieldArtist] = p.Track.Artist
} else {
changes[tagwriter.FieldArtist] = req.Artist
changes[tagwriter.FieldArtist] = dl.Artist
}
if p.Track.DiscNumber > 0 {
@@ -285,7 +285,7 @@ func (i *Importer) tagFile(p plannedFile, req Request) error {
// destinationFor computes a file's library path from the template.
func (i *Importer) destinationFor(
p plannedFile,
req Request,
dl Download,
opts ImportOptions,
) (string, error) {
if opts.LibraryRoot == "" {
@@ -311,13 +311,13 @@ func (i *Importer) destinationFor(
artist := p.Track.Artist
if artist == "" {
artist = req.Artist
artist = dl.Artist
}
repl := strings.NewReplacer(
"{albumartist}", sanitizePathPart(fallback(req.Artist, "Unknown Artist")),
"{albumartist}", sanitizePathPart(fallback(dl.Artist, "Unknown Artist")),
"{artist}", sanitizePathPart(fallback(artist, "Unknown Artist")),
"{album}", sanitizePathPart(fallback(req.Album, "Unknown Album")),
"{album}", sanitizePathPart(fallback(dl.Album, "Unknown Album")),
"{title}", sanitizePathPart(title),
"{track}", trackToken(p.Track.Position),
"{disc}", strconv.Itoa(p.Track.DiscNumber),
@@ -443,16 +443,16 @@ func copyFile(src, dest string) error {
// checkCompleteness rejects an anchored download that is missing too
// much of its tracklist.
func checkCompleteness(got int, req Request) error {
if len(req.Expected) == 0 {
func checkCompleteness(got int, dl Download) error {
if len(dl.Expected) == 0 {
return nil
}
ratio := float64(got) / float64(len(req.Expected))
ratio := float64(got) / float64(len(dl.Expected))
if ratio < minCompleteness {
return fmt.Errorf(
"%w: got %d of %d tracks",
ErrTooIncomplete, got, len(req.Expected),
ErrTooIncomplete, got, len(dl.Expected),
)
}
+20 -20
View File
@@ -117,9 +117,9 @@ func newImportFixture(t *testing.T, names ...string) importFixture {
}
}
func fourTrackRequest() Request {
return Request{
ID: "req-1",
func fourTrackDownload() Download {
return Download{
ID: "dl-1",
LibraryID: 1,
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
@@ -145,7 +145,7 @@ func TestImportPlacesAndTagsFiles(t *testing.T) {
got, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -184,12 +184,12 @@ func TestImportTagsBeforeMoving(t *testing.T) {
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
if _, err := f.importer.Import(
context.Background(),
req,
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
); err != nil {
@@ -227,7 +227,7 @@ func TestImportContinuesWhenTaggingFails(t *testing.T) {
got, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -251,7 +251,7 @@ func TestImportRejectsTooIncomplete(t *testing.T) {
_, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -273,7 +273,7 @@ func TestImportRejectsNoAudio(t *testing.T) {
_, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -297,7 +297,7 @@ func TestImportSkipsNonAudioFiles(t *testing.T) {
got, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -322,8 +322,8 @@ func TestImportNeverOverwrites(t *testing.T) {
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
existing := filepath.Join(
f.root, "Radiohead", "OK Computer", "01 Airbag.flac",
@@ -339,7 +339,7 @@ func TestImportNeverOverwrites(t *testing.T) {
got, err := f.importer.Import(
context.Background(),
req,
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -366,12 +366,12 @@ func TestImportCustomPathTemplate(t *testing.T) {
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
got, err := f.importer.Import(
context.Background(),
req,
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{
LibraryRoot: f.root,
@@ -423,12 +423,12 @@ func TestImportRequiresLibraryRoot(t *testing.T) {
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
_, err := f.importer.Import(
context.Background(),
req,
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{WriteTags: true},
)
+132 -102
View File
@@ -124,6 +124,10 @@ type Manager struct {
optsMu sync.RWMutex
opts ImportOptions
// prefs gates and scores what auto-pick may grab without asking.
prefsMu sync.RWMutex
prefs AutoDownloadPrefs
// providers caches built provider instances by config ID. Rebuilt
// whenever config changes, so a settings edit takes effect without
// a restart.
@@ -206,6 +210,32 @@ func (m *Manager) importOptions() ImportOptions {
return m.opts
}
// SetPreferences configures the auto-download guardrails: the size
// window and format list AutoPickable is allowed to grab without
// asking. Live-settable so a settings change takes effect immediately,
// the same way SetImportOptions does.
func (m *Manager) SetPreferences(prefs AutoDownloadPrefs) {
m.prefsMu.Lock()
defer m.prefsMu.Unlock()
m.prefs = prefs
}
// preferences returns the current auto-download guardrails.
func (m *Manager) preferences() AutoDownloadPrefs {
m.prefsMu.RLock()
defer m.prefsMu.RUnlock()
return m.prefs
}
// AutoPickable wraps the package function with this manager's current
// preferences, so callers do not need direct field access to apply the
// live guardrails.
func (m *Manager) AutoPickable(dl Download, ranked []Candidate) bool {
return AutoPickable(dl, ranked, m.preferences())
}
// Reload rebuilds every provider from stored config. Called at startup
// and after any provider settings change.
//
@@ -293,12 +323,12 @@ func (m *Manager) Sweep(ctx context.Context) {
)
}
if err := m.store.SetRequestState(
ctx, item.RequestID, StateFailed, "interrupted by restart",
if err := m.store.SetDownloadState(
ctx, item.DownloadID, StateFailed, "interrupted by restart",
); err != nil {
m.logger.Warn(
"could not fail interrupted download request",
"request", item.RequestID, "error", err,
"request", item.DownloadID, "error", err,
)
}
}
@@ -399,7 +429,7 @@ func (m *Manager) syncSemaphores(configs map[int64]Config) {
}
}
// listers returns every enabled provider that keeps a persistent wanted
// listers returns every enabled provider that keeps a persistent
// list of its own, keyed by provider ID.
func (m *Manager) listers() map[int64]Lister {
m.provMu.RLock()
@@ -434,7 +464,7 @@ func (m *Manager) priorityFor(id int64) int {
// and skipped, because partial results beat no results.
func (m *Manager) Search(
ctx context.Context,
req Request,
dl Download,
) ([]Candidate, error) {
providers := m.enabledProviders()
if len(providers) == 0 {
@@ -462,7 +492,7 @@ func (m *Manager) Search(
sctx, cancel := context.WithTimeout(ctx, searchTimeout)
defer cancel()
c, err := s.Search(sctx, req)
c, err := s.Search(sctx, dl)
results <- found{candidates: c, err: err, id: id}
}(id, s)
}
@@ -497,7 +527,7 @@ func (m *Manager) Search(
return nil, ErrNoCandidates
}
return Rank(req, all, m.priorityFor), nil
return Rank(dl, all, m.priorityFor, m.preferences()), nil
}
// Start creates a request, searches for it, and either grabs the clear
@@ -506,31 +536,31 @@ func (m *Manager) Search(
// in the background under a job.
func (m *Manager) Start(
ctx context.Context,
req Request,
dl Download,
) ([]Candidate, error) {
if req.ID == "" {
req.ID = newID()
if dl.ID == "" {
dl.ID = newID()
}
if err := m.store.CreateRequest(ctx, req); err != nil {
if err := m.store.CreateDownload(ctx, dl); err != nil {
return nil, err
}
job := m.startJob(req)
job := m.startJob(dl)
ranked, err := m.Search(ctx, req)
ranked, err := m.Search(ctx, dl)
if err != nil {
m.failRequest(ctx, job, req.ID, err)
m.failDownload(ctx, job, dl.ID, err)
return nil, err
}
m.resMu.Lock()
m.results[req.ID] = ranked
m.results[dl.ID] = ranked
m.resMu.Unlock()
if err := m.store.SetRequestState(
ctx, req.ID, StateFound, "",
if err := m.store.SetDownloadState(
ctx, dl.ID, StateFound, "",
); err != nil {
m.logger.Warn("could not record found state", "error", err)
}
@@ -541,12 +571,12 @@ func (m *Manager) Start(
))
}
if AutoPickable(req, ranked) {
if m.AutoPickable(dl, ranked) {
if job != nil {
job.Logf(jobs.LevelInfo, "Auto-selected best candidate")
}
go m.grab(context.WithoutCancel(ctx), req, ranked[0], job)
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
return ranked, nil
}
@@ -559,30 +589,30 @@ func (m *Manager) Start(
return ranked, nil
}
// Attempt searches on behalf of the wanted list and starts a download
// Attempt searches on behalf of the request list and starts a download
// only if there is a clear winner. It returns whether it started and,
// when it did not, a sentence the wanted list can show the user.
// when it did not, a sentence the request list can show the user.
//
// Unlike Start it persists nothing when it does not act. A want that
// Unlike Start it persists nothing when it does not act. A request that
// is retried weekly for a year would otherwise leave fifty failed
// request rows behind it, all saying the same thing the want itself
// download rows behind it, all saying the same thing the request itself
// already says — and none of them anything the user can do something
// about. Nobody is watching a reconcile pass, so the only two honest
// outcomes are "downloading it now" and "still looking".
func (m *Manager) Attempt(
ctx context.Context,
req Request,
dl Download,
) (bool, string, error) {
if req.ID == "" {
req.ID = newID()
if dl.ID == "" {
dl.ID = newID()
}
ranked, err := m.Search(ctx, req)
ranked, err := m.Search(ctx, dl)
if err != nil {
return false, "", err
}
if !AutoPickable(req, ranked) {
if !m.AutoPickable(dl, ranked) {
best := ranked[0]
return false, fmt.Sprintf(
@@ -594,28 +624,28 @@ func (m *Manager) Attempt(
), nil
}
if err := m.store.CreateRequest(ctx, req); err != nil {
if err := m.store.CreateDownload(ctx, dl); err != nil {
return false, "", err
}
m.resMu.Lock()
m.results[req.ID] = ranked
m.results[dl.ID] = ranked
m.resMu.Unlock()
if err := m.store.SetRequestState(ctx, req.ID, StateFound, ""); err != nil {
if err := m.store.SetDownloadState(ctx, dl.ID, StateFound, ""); err != nil {
m.logger.Warn("could not record found state", "error", err)
}
job := m.startJob(req)
job := m.startJob(dl)
if job != nil {
job.Logf(jobs.LevelInfo, fmt.Sprintf(
"Wanted list: auto-selected the best of %d candidates",
"Request list: auto-selected the best of %d candidates",
len(ranked),
))
}
go m.grab(context.WithoutCancel(ctx), req, ranked[0], job)
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
return true, "", nil
}
@@ -623,15 +653,15 @@ func (m *Manager) Attempt(
// Pick starts the transfer for a candidate the user chose.
func (m *Manager) Pick(
ctx context.Context,
requestID, candidateID string,
downloadID, candidateID string,
) error {
req, err := m.store.GetRequest(ctx, requestID)
dl, err := m.store.GetDownload(ctx, downloadID)
if err != nil {
return err
}
m.resMu.RLock()
ranked := m.results[requestID]
ranked := m.results[downloadID]
m.resMu.RUnlock()
var chosen *Candidate
@@ -648,25 +678,25 @@ func (m *Manager) Pick(
return fmt.Errorf("%w: %s", ErrCandidateGone, candidateID)
}
job := m.startJob(req)
job := m.startJob(dl)
go m.grab(context.WithoutCancel(ctx), req, *chosen, job)
go m.grab(context.WithoutCancel(ctx), dl, *chosen, job)
return nil
}
// Cancel aborts a live request.
func (m *Manager) Cancel(ctx context.Context, requestID string) error {
func (m *Manager) Cancel(ctx context.Context, downloadID string) error {
m.actMu.Lock()
cancel, ok := m.active[requestID]
cancel, ok := m.active[downloadID]
m.actMu.Unlock()
if ok {
cancel()
}
if err := m.store.SetRequestState(
ctx, requestID, StateCancelled, "",
if err := m.store.SetDownloadState(
ctx, downloadID, StateCancelled, "",
); err != nil {
return err
}
@@ -678,7 +708,7 @@ func (m *Manager) Cancel(ctx context.Context, requestID string) error {
// own goroutine and owns the job from here on.
func (m *Manager) grab(
ctx context.Context,
req Request,
dl Download,
c Candidate,
job *jobs.Handle,
) {
@@ -686,12 +716,12 @@ func (m *Manager) grab(
defer cancel()
m.actMu.Lock()
m.active[req.ID] = cancel
m.active[dl.ID] = cancel
m.actMu.Unlock()
defer func() {
m.actMu.Lock()
delete(m.active, req.ID)
delete(m.active, dl.ID)
m.actMu.Unlock()
}()
@@ -701,9 +731,9 @@ func (m *Manager) grab(
// happening inside another system, which is doing its own limiting,
// and blocking a local slot on it would be counting someone else's
// work against our budget.
plan, err := m.planTransfer(req, c)
plan, err := m.planTransfer(dl, c)
if err != nil {
m.failRequest(ctx, job, req.ID, err)
m.failDownload(ctx, job, dl.ID, err)
return
}
@@ -715,7 +745,7 @@ func (m *Manager) grab(
case provSem <- struct{}{}:
defer func() { <-provSem }()
case <-ctx.Done():
m.failRequest(ctx, job, req.ID, ctx.Err())
m.failDownload(ctx, job, dl.ID, ctx.Err())
return
}
@@ -726,15 +756,15 @@ func (m *Manager) grab(
case globalSem <- struct{}{}:
defer func() { <-globalSem }()
case <-ctx.Done():
m.failRequest(ctx, job, req.ID, ctx.Err())
m.failDownload(ctx, job, dl.ID, ctx.Err())
return
}
}
item := Item{
item := DownloadItem{
ID: newID(),
RequestID: req.ID,
DownloadID: dl.ID,
ProviderID: c.ProviderID,
Candidate: c,
State: StateQueued,
@@ -743,7 +773,7 @@ func (m *Manager) grab(
dir, err := m.staging.Reserve(item.ID)
if err != nil {
m.failRequest(ctx, job, req.ID, err)
m.failDownload(ctx, job, dl.ID, err)
return
}
@@ -751,19 +781,19 @@ func (m *Manager) grab(
item.StagingDir = dir
if err := m.store.CreateItem(ctx, item); err != nil {
m.failRequest(ctx, job, req.ID, err)
m.failDownload(ctx, job, dl.ID, err)
return
}
result, err := m.transfer(ctx, req, item, plan, job)
result, err := m.transfer(ctx, dl, item, plan, job)
if err != nil {
m.failItem(ctx, job, item, req.ID, err)
m.failItem(ctx, job, item, dl.ID, err)
return
}
m.setStates(ctx, req.ID, item.ID, StateImporting)
m.setStates(ctx, dl.ID, item.ID, StateImporting)
if job != nil {
job.SetPhase("Importing")
@@ -790,17 +820,17 @@ func (m *Manager) grab(
opts := m.importOptions()
opts.WriteTags = true
opts.LibraryRoot, err = m.library.LibraryPath(req.LibraryID)
opts.LibraryRoot, err = m.library.LibraryPath(dl.LibraryID)
if err != nil {
m.failItem(ctx, job, item, req.ID,
m.failItem(ctx, job, item, dl.ID,
fmt.Errorf("resolve library root: %w", err))
return
}
imported, err = m.importer.Import(ctx, req, result, opts)
imported, err = m.importer.Import(ctx, dl, result, opts)
if err != nil {
m.failItem(ctx, job, item, req.ID, err)
m.failItem(ctx, job, item, dl.ID, err)
return
}
@@ -812,21 +842,21 @@ func (m *Manager) grab(
m.logger.Warn("could not record imported paths", "error", err)
}
if err := m.store.SetRequestState(
ctx, req.ID, StateComplete, "",
if err := m.store.SetDownloadState(
ctx, dl.ID, StateComplete, "",
); err != nil {
m.logger.Warn("could not record complete state", "error", err)
}
// A request raised from the wanted list retires its want here
// A download raised from a durable Request retires it here
// rather than waiting for the next reconcile pass to notice the
// files, so the wanted list is right the moment the download
// files, so the request list is right the moment the download
// finishes. The pass would reach the same conclusion by asking the
// library; this is the same answer, sooner.
if req.WantID != 0 {
if err := m.store.SatisfyWant(ctx, req.WantID); err != nil {
if dl.RequestID != 0 {
if err := m.store.SatisfyRequest(ctx, dl.RequestID); err != nil {
m.logger.Warn(
"could not satisfy want", "want", req.WantID, "error", err,
"could not satisfy request", "request", dl.RequestID, "error", err,
)
}
}
@@ -835,7 +865,7 @@ func (m *Manager) grab(
// mid-flight. Holding it after the download completes would leak a
// few hundred candidates per request for the life of the process.
m.resMu.Lock()
delete(m.results, req.ID)
delete(m.results, dl.ID)
m.resMu.Unlock()
// Staging is only released on a fully successful import; a failure
@@ -845,10 +875,10 @@ func (m *Manager) grab(
}
if m.library != nil {
if err := m.library.ScanLibrary(req.LibraryID); err != nil {
if err := m.library.ScanLibrary(dl.LibraryID); err != nil {
m.logger.Warn(
"could not trigger scan after import",
"library", req.LibraryID,
"library", dl.LibraryID,
"error", err,
)
}
@@ -871,16 +901,16 @@ func (m *Manager) grab(
// delegates the whole thing.
func (m *Manager) transfer(
ctx context.Context,
req Request,
item Item,
dl Download,
item DownloadItem,
plan transferPlan,
job *jobs.Handle,
) (Result, error) {
if plan.delegated() {
return m.delegate(ctx, req, item, plan.delegate, job)
return m.delegate(ctx, dl, item, plan.delegate, job)
}
m.setStates(ctx, req.ID, item.ID, StateGrabbing)
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
if job != nil {
job.SetPhase("Downloading")
@@ -896,7 +926,7 @@ func (m *Manager) transfer(
return Result{}, fmt.Errorf("grab failed: %w", err)
}
m.setStates(ctx, req.ID, item.ID, StateVerifying)
m.setStates(ctx, dl.ID, item.ID, StateVerifying)
if job != nil {
job.SetPhase("Verifying")
@@ -966,7 +996,7 @@ type transferPlan struct {
func (p transferPlan) delegated() bool { return p.delegate != nil }
// planTransfer decides how a candidate will be fetched.
func (m *Manager) planTransfer(_ Request, c Candidate) (transferPlan, error) {
func (m *Manager) planTransfer(_ Download, c Candidate) (transferPlan, error) {
providers := m.enabledProviders()
source, ok := providers[c.ProviderID]
@@ -992,12 +1022,12 @@ func (m *Manager) planTransfer(_ Request, c Candidate) (transferPlan, error) {
// reports terminal state.
func (m *Manager) delegate(
ctx context.Context,
req Request,
item Item,
dl Download,
item DownloadItem,
d Delegator,
job *jobs.Handle,
) (Result, error) {
externalID, err := d.Delegate(ctx, req)
externalID, err := d.Delegate(ctx, dl)
if err != nil {
return Result{}, fmt.Errorf("delegate request: %w", err)
}
@@ -1006,7 +1036,7 @@ func (m *Manager) delegate(
m.logger.Warn("could not record external id", "error", err)
}
m.setStates(ctx, req.ID, item.ID, StateGrabbing)
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
if job != nil {
job.SetPhase("Waiting on external manager")
@@ -1104,12 +1134,12 @@ func (m *Manager) progressReporter(
}
// Candidates returns the ranked candidates for a live request.
func (m *Manager) Candidates(requestID string) []Candidate {
func (m *Manager) Candidates(downloadID string) []Candidate {
m.resMu.RLock()
defer m.resMu.RUnlock()
out := make([]Candidate, len(m.results[requestID]))
copy(out, m.results[requestID])
out := make([]Candidate, len(m.results[downloadID]))
copy(out, m.results[downloadID])
return out
}
@@ -1117,10 +1147,10 @@ func (m *Manager) Candidates(requestID string) []Candidate {
// setStates advances a request and its item together.
func (m *Manager) setStates(
ctx context.Context,
requestID, itemID string,
downloadID, itemID string,
state State,
) {
if err := m.store.SetRequestState(ctx, requestID, state, ""); err != nil {
if err := m.store.SetDownloadState(ctx, downloadID, state, ""); err != nil {
m.logger.Warn("could not set request state", "error", err)
}
@@ -1129,17 +1159,17 @@ func (m *Manager) setStates(
}
}
// failRequest records a request-level failure.
func (m *Manager) failRequest(
// failDownload records a download-level failure.
func (m *Manager) failDownload(
ctx context.Context,
job *jobs.Handle,
requestID string,
downloadID string,
err error,
) {
m.logger.Warn("download request failed", "request", requestID, "error", err)
m.logger.Warn("download failed", "download", downloadID, "error", err)
if serr := m.store.SetRequestState(
ctx, requestID, StateFailed, err.Error(),
if serr := m.store.SetDownloadState(
ctx, downloadID, StateFailed, err.Error(),
); serr != nil {
m.logger.Warn("could not record failure", "error", serr)
}
@@ -1149,12 +1179,12 @@ func (m *Manager) failRequest(
}
}
// failItem records an item-level failure and fails its request.
// failItem records an item-level failure and fails its download.
func (m *Manager) failItem(
ctx context.Context,
job *jobs.Handle,
item Item,
requestID string,
item DownloadItem,
downloadID string,
err error,
) {
if serr := m.store.SetItemState(
@@ -1163,30 +1193,30 @@ func (m *Manager) failItem(
m.logger.Warn("could not record item failure", "error", serr)
}
m.failRequest(ctx, job, requestID, err)
m.failDownload(ctx, job, downloadID, err)
}
// startJob registers the request in the background jobs panel.
func (m *Manager) startJob(req Request) *jobs.Handle {
func (m *Manager) startJob(dl Download) *jobs.Handle {
if m.jobsReg == nil {
return nil
}
title := req.Album
title := dl.Album
if title == "" {
title = req.SearchText()
title = dl.SearchText()
}
return m.jobsReg.Start(jobs.Spec{
ID: "download-" + req.ID,
ID: "download-" + dl.ID,
Kind: jobs.KindDownload,
Title: "Downloading " + title,
Subtitle: req.Artist,
Subtitle: dl.Artist,
State: jobs.StateRunning,
Caps: jobs.Caps{Cancellable: true},
Controls: jobs.Controls{
Cancel: func() {
if err := m.Cancel(context.Background(), req.ID); err != nil {
if err := m.Cancel(context.Background(), dl.ID); err != nil {
m.logger.Warn("cancel failed", "error", err)
}
},
+40 -40
View File
@@ -91,7 +91,7 @@ func TestManagerSearchRanksAcrossProviders(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, mp3)
f.manager.installProvider(Config{ID: 2, Priority: 50}, flac)
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -128,7 +128,7 @@ func TestManagerSearchToleratesProviderFailure(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, broken)
f.manager.installProvider(Config{ID: 2, Priority: 50}, working)
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -143,7 +143,7 @@ func TestManagerSearchNoProviders(t *testing.T) {
f := newManagerFixture(t)
_, err := f.manager.Search(context.Background(), fourTrackRequest())
_, err := f.manager.Search(context.Background(), fourTrackDownload())
if !errors.Is(err, ErrNoProviders) {
t.Fatalf("error = %v, want ErrNoProviders", err)
}
@@ -157,7 +157,7 @@ func TestManagerSearchNoCandidates(t *testing.T) {
empty := NewFakeProvider(1, "empty", Caps{CanSearch: true})
f.manager.installProvider(Config{ID: 1, Priority: 50}, empty)
_, err := f.manager.Search(context.Background(), fourTrackRequest())
_, err := f.manager.Search(context.Background(), fourTrackDownload())
if !errors.Is(err, ErrNoCandidates) {
t.Fatalf("error = %v, want ErrNoCandidates", err)
}
@@ -173,21 +173,21 @@ func TestManagerEndToEndAutoPick(t *testing.T) {
provider := fakeWithAlbum(1, "flac-source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
dl := fourTrackDownload()
ranked, err := f.manager.Start(context.Background(), req)
ranked, err := f.manager.Start(context.Background(), dl)
if err != nil {
t.Fatalf("Start: %v", err)
}
if !AutoPickable(req, ranked) {
if !f.manager.AutoPickable(dl, ranked) {
t.Fatalf(
"expected a clear winner to auto-pick; best match %f quality %f",
ranked[0].Match.Overall, ranked[0].Quality.Overall,
)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
waitForDownloadState(t, f.store, dl.ID, StateComplete)
if provider.GrabCalls != 1 {
t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
@@ -231,14 +231,14 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
f.manager.installProvider(Config{ID: 2, Priority: 50}, b)
req := fourTrackRequest()
dl := fourTrackDownload()
ranked, err := f.manager.Start(context.Background(), req)
ranked, err := f.manager.Start(context.Background(), dl)
if err != nil {
t.Fatalf("Start: %v", err)
}
if AutoPickable(req, ranked) {
if f.manager.AutoPickable(dl, ranked) {
t.Fatal("two equivalent candidates must not auto-pick")
}
@@ -250,23 +250,23 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
)
}
stored, err := f.store.GetRequest(context.Background(), req.ID)
stored, err := f.store.GetDownload(context.Background(), dl.ID)
if err != nil {
t.Fatalf("GetRequest: %v", err)
t.Fatalf("GetDownload: %v", err)
}
if stored.ID != req.ID {
t.Errorf("stored request id = %s, want %s", stored.ID, req.ID)
if stored.ID != dl.ID {
t.Errorf("stored request id = %s, want %s", stored.ID, dl.ID)
}
// The user picks the second one explicitly.
if err := f.manager.Pick(
context.Background(), req.ID, ranked[1].ID,
context.Background(), dl.ID, ranked[1].ID,
); err != nil {
t.Fatalf("Pick: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
waitForDownloadState(t, f.store, dl.ID, StateComplete)
}
func TestManagerPickUnknownCandidate(t *testing.T) {
@@ -277,14 +277,14 @@ func TestManagerPickUnknownCandidate(t *testing.T) {
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
req.Expected = nil // free text: never auto-picks
dl := fourTrackDownload()
dl.Expected = nil // free text: never auto-picks
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
err := f.manager.Pick(context.Background(), req.ID, "no-such-candidate")
err := f.manager.Pick(context.Background(), dl.ID, "no-such-candidate")
if !errors.Is(err, ErrCandidateGone) {
t.Fatalf("error = %v, want ErrCandidateGone", err)
}
@@ -302,13 +302,13 @@ func TestManagerFailedGrabLeavesLibraryClean(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateFailed)
waitForDownloadState(t, f.store, dl.ID, StateFailed)
entries, err := os.ReadDir(f.root)
if err != nil {
@@ -355,13 +355,13 @@ func TestManagerPairsSearcherWithTransport(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
f.manager.installProvider(Config{ID: 2, Priority: 50}, transport)
req := fourTrackRequest()
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
waitForDownloadState(t, f.store, dl.ID, StateComplete)
if transport.GrabCalls != 1 {
t.Errorf("transport grabs = %d, want 1", transport.GrabCalls)
@@ -387,22 +387,22 @@ func TestManagerNoTransportForProtocol(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
req := fourTrackRequest()
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateFailed)
waitForDownloadState(t, f.store, dl.ID, StateFailed)
}
// waitForRequestState polls until a request reaches the wanted state.
// waitForDownloadState polls until a download reaches the wanted state.
// The pipeline runs on its own goroutine, so tests observe it through
// the store rather than by reaching into the manager.
func waitForRequestState(
func waitForDownloadState(
t *testing.T,
store *Store,
requestID string,
downloadID string,
want State,
) {
t.Helper()
@@ -412,7 +412,7 @@ func waitForRequestState(
var last State
for time.Now().Before(deadline) {
state, _, err := store.GetRequestState(context.Background(), requestID)
state, _, err := store.GetDownloadState(context.Background(), downloadID)
if err == nil {
last = state
if last == want {
@@ -423,7 +423,7 @@ func waitForRequestState(
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("request state = %q after 5s, want %q", last, want)
t.Fatalf("download state = %q after 5s, want %q", last, want)
}
// A delegate's files are already in the external manager's library,
@@ -457,13 +457,13 @@ func TestManagerDelegateReconcilesInPlace(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, delegate)
req := fourTrackRequest()
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
waitForDownloadState(t, f.store, dl.ID, StateComplete)
if delegate.DelegateCalls != 1 {
t.Errorf("delegate calls = %d, want 1", delegate.DelegateCalls)
@@ -489,9 +489,9 @@ func TestManagerDelegateReconcilesInPlace(t *testing.T) {
}
// The external paths were recorded against the item.
items, err := f.store.ListItemsForRequest(context.Background(), req.ID)
items, err := f.store.ListItemsForDownload(context.Background(), dl.ID)
if err != nil {
t.Fatalf("ListItemsForRequest: %v", err)
t.Fatalf("ListItemsForDownload: %v", err)
}
if len(items) != 1 {
+11 -11
View File
@@ -50,7 +50,7 @@ type Provider interface {
// respect ctx deadlines: the pipeline searches providers concurrently
// with a per-provider timeout and takes whatever came back in time.
type Searcher interface {
Search(ctx context.Context, req Request) ([]Candidate, error)
Search(ctx context.Context, dl Download) ([]Candidate, error)
}
// Transporter moves a candidate's bytes into dst, which the pipeline
@@ -73,7 +73,7 @@ type Transporter interface {
// the manager reports terminal state.
type Delegator interface {
// Delegate submits the request and returns the manager's own ID.
Delegate(ctx context.Context, req Request) (string, error)
Delegate(ctx context.Context, dl Download) (string, error)
// Poll reports on a previously delegated request.
Poll(ctx context.Context, externalID string) (DelegateStatus, error)
@@ -92,18 +92,18 @@ type Delegator interface {
// pushes, the external system receives. Pulling happens only when the
// user explicitly imports.
type Lister interface {
// PushWant records a want in the provider's own list and returns
// the provider's identifier for it. Implementations must be
// idempotent: pushing a want the provider already has returns the
// existing identifier rather than duplicating it.
PushWant(ctx context.Context, w Want) (string, error)
// PushRequest records a request in the provider's own list and
// returns the provider's identifier for it. Implementations must
// be idempotent: pushing a request the provider already has returns
// the existing identifier rather than duplicating it.
PushRequest(ctx context.Context, r Request) (string, error)
// RemoveWant drops a previously pushed want. Best-effort.
RemoveWant(ctx context.Context, externalID string) error
// RemoveRequest drops a previously pushed request. Best-effort.
RemoveRequest(ctx context.Context, externalID string) error
// ListWants reads the provider's list back, for the deliberate
// ListRequests reads the provider's list back, for the deliberate
// import path. LibraryID is filled in by the caller.
ListWants(ctx context.Context) ([]Want, error)
ListRequests(ctx context.Context) ([]Request, error)
}
// ProviderInfo is a provider's identity as the frontend sees it.
+7 -7
View File
@@ -237,8 +237,8 @@ type lidarrTrackFile struct {
// Delegate adds the album to Lidarr, monitors it and triggers a search.
// The external ID returned is Lidarr's album ID, which is what Poll
// needs and what survives a restart.
func (l *lidarr) Delegate(ctx context.Context, req Request) (string, error) {
album, err := l.findAlbum(ctx, req)
func (l *lidarr) Delegate(ctx context.Context, dl Download) (string, error) {
album, err := l.findAlbum(ctx, dl)
if err != nil {
return "", err
}
@@ -276,12 +276,12 @@ func (l *lidarr) Delegate(ctx context.Context, req Request) (string, error) {
// not.
func (l *lidarr) findAlbum(
ctx context.Context,
req Request,
dl Download,
) (lidarrAlbum, error) {
term := req.SearchText()
term := dl.SearchText()
if req.ReleaseGroupMBID != "" {
term = "lidarr:" + req.ReleaseGroupMBID
if dl.ReleaseGroupMBID != "" {
term = "lidarr:" + dl.ReleaseGroupMBID
}
var results []struct {
@@ -300,7 +300,7 @@ func (l *lidarr) findAlbum(
}
}
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, req.SearchText())
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, dl.SearchText())
}
// addArtistForAlbum adds the album's artist so the album becomes a real
+34 -34
View File
@@ -7,10 +7,10 @@ import (
"strconv"
)
// Lidarr's Lister role: mirroring this app's wanted list into Lidarr's
// Lidarr's Lister role: mirroring this app's request list into Lidarr's
// own monitoring.
//
// Lidarr already models exactly what a want is — a monitored artist or
// Lidarr already models exactly what a request is — a monitored artist or
// a monitored album — so the mapping is direct and, more usefully, it
// means an artist subscription made here keeps working through Lidarr's
// own release-checking even while this app is closed. That is the
@@ -21,52 +21,52 @@ import (
//
// The mapping is deliberately lossy in one direction only:
//
// artist want -> Lidarr artist, monitored
// release-group want -> Lidarr album, monitored (artist added if new)
// release want -> same, at release-group granularity
// recording want -> not pushed; Lidarr has no concept of wanting
// artist request -> Lidarr artist, monitored
// release-group request -> Lidarr album, monitored (artist added if new)
// release request -> same, at release-group granularity
// recording request -> not pushed; Lidarr has no concept of wanting
// one track, and monitoring the whole album to
// get it would download far more than asked.
//
// Nothing here searches. Pushing a want expresses intent; Lidarr
// Nothing here searches. Pushing a request expresses intent; Lidarr
// decides when to act on it, which is the point of delegating.
// PushWant records a want in Lidarr's own monitoring.
// PushRequest records a request in Lidarr's own monitoring.
//
// It is idempotent because Lidarr is: adding an artist that already
// exists returns the existing record, and monitoring an already-
// monitored album is a no-op. Callers rely on that — the reconciler
// pushes on every pass until it gets an ID back.
func (l *lidarr) PushWant(ctx context.Context, w Want) (string, error) {
switch w.Entity {
func (l *lidarr) PushRequest(ctx context.Context, r Request) (string, error) {
switch r.Entity {
case EntityArtist:
return l.pushArtistWant(ctx, w)
return l.pushArtistRequest(ctx, r)
case EntityReleaseGroup, EntityRelease:
return l.pushAlbumWant(ctx, w)
return l.pushAlbumRequest(ctx, r)
case EntityRecording:
// Deliberately unsupported rather than approximated. See the
// mapping note above.
return "", nil
default:
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, w.Entity)
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, r.Entity)
}
}
// pushArtistWant makes Lidarr monitor an artist.
// pushArtistRequest makes Lidarr monitor an artist.
//
// Scope is honoured through Lidarr's own monitor option rather than by
// pushing each album separately: "future" maps to monitoring new
// releases only, "all" to monitoring everything missing. Letting
// Lidarr apply the policy means it stays applied to albums released
// after this push, which is what a subscription is for.
func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
existing, err := l.findArtistByMBID(ctx, w.MBID)
func (l *lidarr) pushArtistRequest(ctx context.Context, r Request) (string, error) {
existing, err := l.findArtistByMBID(ctx, r.MBID)
if err != nil {
return "", err
}
monitor := "future"
if w.Scope == ScopeAll {
if r.Scope == ScopeAll {
monitor = "missing"
}
@@ -84,13 +84,13 @@ func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
return "", err
}
name := w.Artist
name := r.Artist
if name == "" {
name = w.Title
name = r.Title
}
body := map[string]any{
"foreignArtistId": w.MBID,
"foreignArtistId": r.MBID,
"artistName": name,
"qualityProfileId": quality,
"metadataProfileId": metadata,
@@ -118,13 +118,13 @@ func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
return strconv.Itoa(created.ID), nil
}
// pushAlbumWant makes Lidarr monitor one album, adding its artist if
// pushAlbumRequest makes Lidarr monitor one album, adding its artist if
// Lidarr has never heard of them.
func (l *lidarr) pushAlbumWant(ctx context.Context, w Want) (string, error) {
album, err := l.findAlbum(ctx, Request{
ReleaseGroupMBID: w.MBID,
Artist: w.Artist,
Album: w.Title,
func (l *lidarr) pushAlbumRequest(ctx context.Context, r Request) (string, error) {
album, err := l.findAlbum(ctx, Download{
ReleaseGroupMBID: r.MBID,
Artist: r.Artist,
Album: r.Title,
})
if err != nil {
return "", err
@@ -146,13 +146,13 @@ func (l *lidarr) pushAlbumWant(ctx context.Context, w Want) (string, error) {
return strconv.Itoa(album.ID), nil
}
// RemoveWant stops Lidarr monitoring something.
// RemoveRequest stops Lidarr monitoring something.
//
// It unmonitors rather than deletes: the user's Lidarr may have been
// monitoring that artist long before this app existed, and removing a
// want here is not permission to tear down their setup. An unmonitored
// request here is not permission to tear down their setup. An unmonitored
// artist stays in their library with its files intact.
func (l *lidarr) RemoveWant(ctx context.Context, externalID string) error {
func (l *lidarr) RemoveRequest(ctx context.Context, externalID string) error {
id, err := strconv.Atoi(externalID)
if err != nil {
return fmt.Errorf("%w: bad lidarr id %q", ErrLidarrNoMatch, externalID)
@@ -180,14 +180,14 @@ func (l *lidarr) RemoveWant(ctx context.Context, externalID string) error {
return l.client.put(ctx, endpoint, artist, nil)
}
// ListWants reads Lidarr's monitored artists back, for the deliberate
// ListRequests reads Lidarr's monitored artists back, for the deliberate
// "import what Lidarr is already watching" action.
//
// Only artists are imported, not their individual monitored albums: an
// artist is the durable statement of intent, and importing every
// monitored album alongside it would produce a wanted list that is
// monitored album alongside it would produce a request list that is
// mostly redundant with the subscription that generated it.
func (l *lidarr) ListWants(ctx context.Context) ([]Want, error) {
func (l *lidarr) ListRequests(ctx context.Context) ([]Request, error) {
var artists []struct {
lidarrArtist
@@ -198,14 +198,14 @@ func (l *lidarr) ListWants(ctx context.Context) ([]Want, error) {
return nil, err
}
out := make([]Want, 0, len(artists))
out := make([]Request, 0, len(artists))
for _, a := range artists {
if !a.Monitored || a.ForeignArtistID == "" {
continue
}
out = append(out, Want{
out = append(out, Request{
MBID: a.ForeignArtistID,
Entity: EntityArtist,
Artist: a.ArtistName,
+25 -25
View File
@@ -9,12 +9,12 @@ import (
// scope translated into Lidarr's own monitor option — so the policy
// keeps applying to albums released after the push, which is the whole
// reason to mirror a subscription rather than a list of albums.
func TestLidarrPushArtistWantMapsScope(t *testing.T) {
func TestLidarrPushArtistRequestMapsScope(t *testing.T) {
t.Parallel()
tests := []struct {
name string
scope WantScope
scope RequestScope
wantMonitor string
}{
{name: "future", scope: ScopeFuture, wantMonitor: "future"},
@@ -25,14 +25,14 @@ func TestLidarrPushArtistWantMapsScope(t *testing.T) {
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
id, err := l.PushWant(context.Background(), Want{
id, err := l.PushRequest(context.Background(), Request{
MBID: "artist-mbid",
Entity: EntityArtist,
Artist: "Radiohead",
Scope: tt.scope,
})
if err != nil {
t.Fatalf("%s: PushWant: %v", tt.name, err)
t.Fatalf("%s: PushRequest: %v", tt.name, err)
}
if id != "42" {
@@ -70,7 +70,7 @@ func TestLidarrPushArtistWantMapsScope(t *testing.T) {
// Pushing a want Lidarr already has returns the existing ID instead of
// adding a second copy — the reconciler pushes on every pass, so this
// is load-bearing rather than tidy.
func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
func TestLidarrPushArtistRequestIsIdempotent(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
@@ -83,13 +83,13 @@ func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
l := newStubLidarr(t, stub)
id, err := l.PushWant(context.Background(), Want{
id, err := l.PushRequest(context.Background(), Request{
MBID: "artist-mbid",
Entity: EntityArtist,
Artist: "Radiohead",
})
if err != nil {
t.Fatalf("PushWant: %v", err)
t.Fatalf("PushRequest: %v", err)
}
if id != "7" {
@@ -107,19 +107,19 @@ func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
// Lidarr cannot express "I want one track", and monitoring the whole
// album to get it would download far more than was asked for.
func TestLidarrPushRecordingWantIsSkipped(t *testing.T) {
func TestLidarrPushRecordingRequestIsSkipped(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
id, err := l.PushWant(context.Background(), Want{
id, err := l.PushRequest(context.Background(), Request{
MBID: "recording-mbid",
Entity: EntityRecording,
Title: "Paranoid Android",
})
if err != nil {
t.Fatalf("PushWant: %v", err)
t.Fatalf("PushRequest: %v", err)
}
if id != "" {
@@ -141,7 +141,7 @@ func TestLidarrPushRecordingWantIsSkipped(t *testing.T) {
// Importing adopts monitored artists conservatively: a subscription
// pulled in from elsewhere must not queue a back catalogue.
func TestLidarrListWantsImportsMonitoredArtistsOnly(t *testing.T) {
func TestLidarrListRequestsImportsMonitoredArtistsOnly(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
@@ -168,40 +168,40 @@ func TestLidarrListWantsImportsMonitoredArtistsOnly(t *testing.T) {
l := newStubLidarr(t, stub)
wants, err := l.ListWants(context.Background())
requests, err := l.ListRequests(context.Background())
if err != nil {
t.Fatalf("ListWants: %v", err)
t.Fatalf("ListRequests: %v", err)
}
if len(wants) != 1 {
t.Fatalf("imported %d wants, want 1", len(wants))
if len(requests) != 1 {
t.Fatalf("imported %d requests, want 1", len(requests))
}
w := wants[0]
req := requests[0]
if w.MBID != "artist-1" {
t.Errorf("mbid = %q, want artist-1", w.MBID)
if req.MBID != "artist-1" {
t.Errorf("mbid = %q, want artist-1", req.MBID)
}
if w.Entity != EntityArtist {
t.Errorf("entity = %q, want artist", w.Entity)
if req.Entity != EntityArtist {
t.Errorf("entity = %q, want artist", req.Entity)
}
if w.Scope != ScopeFuture {
t.Errorf("scope = %q, want the conservative future", w.Scope)
if req.Scope != ScopeFuture {
t.Errorf("scope = %q, want the conservative future", req.Scope)
}
}
// Removing a want must not tear down a Lidarr setup that may predate
// this app: it unmonitors, it does not delete.
func TestLidarrRemoveWantUnmonitorsOnly(t *testing.T) {
func TestLidarrRemoveRequestUnmonitorsOnly(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
if err := l.RemoveWant(context.Background(), "55"); err != nil {
t.Fatalf("RemoveWant: %v", err)
if err := l.RemoveRequest(context.Background(), "55"); err != nil {
t.Fatalf("RemoveRequest: %v", err)
}
stub.mu.Lock()
+3 -3
View File
@@ -327,7 +327,7 @@ func TestLidarrDelegateExistingAlbum(t *testing.T) {
l := newStubLidarr(t, stub)
externalID, err := l.Delegate(context.Background(), Request{
externalID, err := l.Delegate(context.Background(), Download{
ReleaseGroupMBID: "rg-mbid",
Artist: "Radiohead",
Album: "OK Computer",
@@ -373,7 +373,7 @@ func TestLidarrDelegateNewArtistMonitorsNothingByDefault(t *testing.T) {
l := newStubLidarr(t, stub)
if _, err := l.Delegate(context.Background(), Request{
if _, err := l.Delegate(context.Background(), Download{
ReleaseGroupMBID: "rg-mbid",
Artist: "Radiohead",
Album: "OK Computer",
@@ -414,7 +414,7 @@ func TestLidarrDelegateNoMatch(t *testing.T) {
l := newStubLidarr(t, stub)
_, err := l.Delegate(context.Background(), Request{
_, err := l.Delegate(context.Background(), Download{
Artist: "Nobody",
Album: "Nothing",
})
+2 -2
View File
@@ -210,10 +210,10 @@ type prowlarrResult struct {
// Search queries every configured indexer through Prowlarr.
func (p *prowlarr) Search(
ctx context.Context,
req Request,
dl Download,
) ([]Candidate, error) {
query := url.Values{}
query.Set("query", req.SearchText())
query.Set("query", dl.SearchText())
query.Set("categories", prowlarrMusicCategory)
query.Set("type", "search")
+3 -3
View File
@@ -169,7 +169,7 @@ func TestProwlarrSearchMarksProtocols(t *testing.T) {
p := newStubProwlarr(t, stub, nil)
got, err := p.Search(context.Background(), Request{
got, err := p.Search(context.Background(), Download{
Artist: "Radiohead",
Album: "OK Computer",
})
@@ -230,7 +230,7 @@ func TestProwlarrFiltersDeadTorrents(t *testing.T) {
p := newStubProwlarr(t, stub, map[string]string{"minSeeders": "1"})
got, err := p.Search(context.Background(), Request{Query: "x"})
got, err := p.Search(context.Background(), Download{Query: "x"})
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -253,7 +253,7 @@ func TestProwlarrSearchesMusicCategory(t *testing.T) {
p := newStubProwlarr(t, stub, nil)
if _, err := p.Search(
context.Background(), Request{Query: "radiohead"},
context.Background(), Download{Query: "radiohead"},
); err != nil {
t.Fatalf("Search: %v", err)
}
+2 -2
View File
@@ -278,7 +278,7 @@ func (t slskdTransfer) done() (finished, ok bool) {
// per-folder candidates. A folder from one peer is the unit a user
// actually wants: Soulseek has no album concept, but people organise
// their shares by album directory.
func (s *slskd) Search(ctx context.Context, req Request) ([]Candidate, error) {
func (s *slskd) Search(ctx context.Context, dl Download) ([]Candidate, error) {
// slskd's search endpoint deserializes id as a .NET Guid server-side,
// so it must be a dashed UUID — the app's own newID() (a plain hex
// string, used for request/item IDs elsewhere) is rejected with an
@@ -287,7 +287,7 @@ func (s *slskd) Search(ctx context.Context, req Request) ([]Candidate, error) {
body := map[string]any{
"id": searchID,
"searchText": req.SearchText(),
"searchText": dl.SearchText(),
}
if err := s.client.post(ctx, "/api/v0/searches", body, nil); err != nil {
+2 -2
View File
@@ -288,7 +288,7 @@ func TestSlskdGroupsResultsByPeerAndFolder(t *testing.T) {
s, _ := newStubSlskd(t, stub)
got, err := s.Search(context.Background(), Request{
got, err := s.Search(context.Background(), Download{
Artist: "Radiohead",
Album: "OK Computer",
})
@@ -354,7 +354,7 @@ func TestSlskdSkipsTinyFolders(t *testing.T) {
s, _ := newStubSlskd(t, stub)
got, err := s.Search(context.Background(), Request{Query: "x"})
got, err := s.Search(context.Background(), Download{Query: "x"})
if err != nil {
t.Fatalf("Search: %v", err)
}
+11 -11
View File
@@ -201,9 +201,9 @@ func (e ytEntry) link() string {
// how yt-dlp is actually useful for albums — a single "full album"
// video is one file and cannot be imported as tracks. Without a
// tracklist it falls back to returning the top individual results.
func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
if len(req.Expected) > 0 {
c, err := y.assembleAlbum(ctx, req)
func (y *ytDlp) Search(ctx context.Context, dl Download) ([]Candidate, error) {
if len(dl.Expected) > 0 {
c, err := y.assembleAlbum(ctx, dl)
if err != nil {
return nil, err
}
@@ -213,7 +213,7 @@ func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
}
}
entries, err := y.search(ctx, req.SearchText(), ytSearchCount)
entries, err := y.search(ctx, dl.SearchText(), ytSearchCount)
if err != nil {
return nil, err
}
@@ -257,7 +257,7 @@ func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
// threshold decides whether what arrived is enough.
func (y *ytDlp) assembleAlbum(
ctx context.Context,
req Request,
dl Download,
) (Candidate, error) {
type hit struct {
index int
@@ -272,10 +272,10 @@ func (y *ytDlp) assembleAlbum(
group, gctx := errgroup.WithContext(ctx)
group.SetLimit(ytTrackConcurrency)
for i, track := range req.Expected {
for i, track := range dl.Expected {
group.Go(func() error {
query := strings.TrimSpace(
req.Artist + " " + track.Title,
dl.Artist + " " + track.Title,
)
entries, err := y.search(gctx, query, 1)
@@ -300,11 +300,11 @@ func (y *ytDlp) assembleAlbum(
}
c := Candidate{
ID: "ytdlp:album:" + req.ID,
ID: "ytdlp:album:" + dl.ID,
Kind: KindYtDlp,
Protocol: ProtocolDirect,
Title: req.Album,
Artist: req.Artist,
Title: dl.Album,
Artist: dl.Artist,
Origin: "yt-dlp (assembled per track)",
Health: 0.75,
Payload: map[string]string{},
@@ -312,7 +312,7 @@ func (y *ytDlp) assembleAlbum(
}
for _, h := range hits {
track := req.Expected[h.index]
track := dl.Expected[h.index]
link := h.entry.link()
if link == "" {
+6 -6
View File
@@ -130,7 +130,7 @@ cat <<'EOF'
EOF
`)
got, err := y.Search(context.Background(), Request{
got, err := y.Search(context.Background(), Download{
Artist: "Radiohead",
Album: "OK Computer",
})
@@ -173,7 +173,7 @@ not json at all
EOF
`)
got, err := y.Search(context.Background(), Request{Query: "radiohead"})
got, err := y.Search(context.Background(), Download{Query: "radiohead"})
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -193,8 +193,8 @@ func TestYtDlpAssemblesAlbumFromTracklist(t *testing.T) {
echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https://example.com/x","filesize_approx":4000000}'
`)
req := Request{
ID: "req-1",
dl := Download{
ID: "dl-1",
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
Album: "OK Computer",
@@ -205,7 +205,7 @@ echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https:/
},
}
got, err := y.Search(context.Background(), req)
got, err := y.Search(context.Background(), dl)
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -254,7 +254,7 @@ case "$*" in
esac
`)
got, err := y.Search(context.Background(), Request{
got, err := y.Search(context.Background(), Download{
ID: "req-1",
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
+137 -23
View File
@@ -34,12 +34,13 @@ const (
weightArtistFit = 0.12
)
// Quality sub-weights.
// Quality sub-weights. They sum to 1.0 along with weightSizeFit below.
const (
weightFormat = 0.45
weightBitrate = 0.25
weightFormat = 0.42
weightBitrate = 0.23
weightHealth = 0.20
weightPriority = 0.10
weightSizeFit = 0.05
)
// unanchoredCap bounds the match score of a free-text request. Without
@@ -47,20 +48,119 @@ const (
// looking score would be a lie — and auto-pick keys off this.
const unanchoredCap = 0.65
// AutoDownloadPrefs gates and scores what AutoPickable may choose
// without asking. Zero values are permissive: no size window and no
// format restriction.
type AutoDownloadPrefs struct {
// MinSizeMB and MaxSizeMB bound what auto-pick will grab. Zero
// means no bound on that side. A candidate outside the window is
// filtered out of auto-pick entirely, not merely scored down — a
// tiny "sampler" torrent or a boxset ten times the expected size is
// usually the wrong thing entirely, not a worse copy of the right
// thing.
MinSizeMB int `json:"minSizeMb"`
MaxSizeMB int `json:"maxSizeMb"`
// PreferredSizeMB nudges the score toward a target size within the
// min/max window (a lossless rip and a heavily-padded lossless rip
// can both pass the window). Zero disables the nudge; sizeFit then
// returns a neutral value that does not affect ranking.
PreferredSizeMB int `json:"preferredSizeMb"`
// AllowedFormats restricts auto-pick to candidates whose audio
// files are all in one of these formats. Empty means no
// restriction.
AllowedFormats []Format `json:"allowedFormats"`
}
// eligible reports whether a candidate may be auto-picked under these
// preferences: within the size window (when set) and, when a format
// list is given, every audio file in an allowed format.
func (p AutoDownloadPrefs) eligible(c Candidate) bool {
const bytesPerMB = 1 << 20
if p.MinSizeMB > 0 && c.TotalSize < int64(p.MinSizeMB)*bytesPerMB {
return false
}
if p.MaxSizeMB > 0 && c.TotalSize > int64(p.MaxSizeMB)*bytesPerMB {
return false
}
if len(p.AllowedFormats) == 0 {
return true
}
allowed := make(map[Format]bool, len(p.AllowedFormats))
for _, f := range p.AllowedFormats {
allowed[f] = true
}
for _, f := range c.AudioFiles() {
if !allowed[f.Format] {
return false
}
}
return true
}
// filter returns only the candidates these preferences allow to be
// auto-picked, in the same (already ranked) order.
func (p AutoDownloadPrefs) filter(ranked []Candidate) []Candidate {
out := make([]Candidate, 0, len(ranked))
for _, c := range ranked {
if p.eligible(c) {
out = append(out, c)
}
}
return out
}
// sizeFit scores how close totalSize is to PreferredSizeMB, 0..1,
// falling off linearly as the size doubles or halves away from it.
// Returns a neutral 0.5 when no preference is set, so the absence of a
// preference does not bias ranking.
func (p AutoDownloadPrefs) sizeFit(totalSize int64) float64 {
const (
bytesPerMB = 1 << 20
neutral = 0.5
)
if p.PreferredSizeMB <= 0 || totalSize <= 0 {
return neutral
}
preferred := float64(p.PreferredSizeMB) * bytesPerMB
ratio := float64(totalSize) / preferred
if ratio < 1 {
ratio = 1 / ratio
}
// ratio is now >= 1: 1.0 is an exact match, 2.0 is double or half
// the preferred size. Falls to 0 at 2x away and beyond.
fit := 1 - (ratio - 1)
return clamp01(fit)
}
// Score fills a candidate's Match, Quality and Score fields.
func Score(req Request, c Candidate, priority int) Candidate {
func Score(dl Download, c Candidate, priority int, prefs AutoDownloadPrefs) Candidate {
c.Files = AnnotateFiles(c.Files)
audio := c.AudioFiles()
matched, titleFit := matchFiles(audio, req.Expected)
matched, titleFit := matchFiles(audio, dl.Expected)
// Write the alignment back so the picker can show which file maps
// to which track.
c.Files = mergeMatched(c.Files, matched)
c.Match = scoreMatch(req, c, audio, titleFit)
c.Quality = scoreQuality(c, audio, priority)
c.Match = scoreMatch(dl, c, audio, titleFit)
c.Quality = scoreQuality(c, audio, priority, prefs)
c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall
@@ -69,17 +169,17 @@ func Score(req Request, c Candidate, priority int) Candidate {
// scoreMatch answers whether this candidate is the requested release.
func scoreMatch(
req Request,
dl Download,
c Candidate,
audio []CandidateFile,
titleFit float64,
) MatchScore {
m := MatchScore{
Anchored: req.Anchored(),
Anchored: dl.Anchored(),
TitleFit: titleFit,
}
m.Completeness = completeness(len(audio), len(req.Expected))
m.Completeness = completeness(len(audio), len(dl.Expected))
// The candidate's own title, and the folder its files sit in, are
// two independent guesses at the album name. Take the better one:
@@ -90,16 +190,16 @@ func scoreMatch(
}
m.AlbumFit = math.Max(
autotag.TitleSimilarity(req.Album, c.Title),
autotag.TitleSimilarity(req.Album, folder),
autotag.TitleSimilarity(dl.Album, c.Title),
autotag.TitleSimilarity(dl.Album, folder),
)
m.ArtistFit = artistFit(req.Artist, c)
m.ArtistFit = artistFit(dl.Artist, c)
// With no expected tracklist there is no title signal at all, so
// redistribute its weight onto the album/artist evidence rather
// than scoring every free-text result as half-wrong.
if len(req.Expected) == 0 {
if len(dl.Expected) == 0 {
m.Overall = 0.55*m.AlbumFit + 0.45*m.ArtistFit
} else {
m.Overall = weightTitleFit*m.TitleFit +
@@ -178,10 +278,12 @@ func scoreQuality(
c Candidate,
audio []CandidateFile,
priority int,
prefs AutoDownloadPrefs,
) QualityScore {
q := QualityScore{
Health: clamp01(c.Health),
Priority: clamp01(float64(priority) / 100.0),
SizeFit: prefs.sizeFit(c.TotalSize),
}
if len(audio) == 0 {
@@ -211,7 +313,8 @@ func scoreQuality(
q.Overall = weightFormat*q.FormatRank +
weightBitrate*q.Bitrate +
weightHealth*q.Health +
weightPriority*q.Priority
weightPriority*q.Priority +
weightSizeFit*q.SizeFit
if q.Mixed {
q.Overall *= 0.9
@@ -316,9 +419,10 @@ func lossyBitrateScore(kbps int) float64 {
// on match, then on provider priority, then on file count, so the order
// is stable across runs rather than map-iteration dependent.
func Rank(
req Request,
dl Download,
candidates []Candidate,
priority func(providerID int64) int,
prefs AutoDownloadPrefs,
) []Candidate {
out := make([]Candidate, 0, len(candidates))
@@ -328,7 +432,7 @@ func Rank(
p = priority(c.ProviderID)
}
out = append(out, Score(req, c, p))
out = append(out, Score(dl, c, p, prefs))
}
sort.SliceStable(out, func(i, j int) bool {
@@ -354,31 +458,41 @@ func Rank(
// to grab without asking. It demands an anchored request, a high match,
// decent quality, and daylight between first and second place — if two
// candidates are close, the choice is the user's.
func AutoPickable(req Request, ranked []Candidate) bool {
func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool {
const (
minMatch = 0.85
minQuality = 0.5
minLead = 0.08
)
if !req.Anchored() || len(ranked) == 0 {
if !dl.Anchored() || len(ranked) == 0 {
return false
}
// An anchor with no tracklist behind it is an anchor in name only:
// the match score then rests on album and artist text alone, which
// is exactly the evidence a wrong-album candidate also has. This
// matters most for the wanted list, where nobody is watching.
if len(req.Expected) == 0 {
// matters most for the request list, where nobody is watching.
if len(dl.Expected) == 0 {
return false
}
best := ranked[0]
// The guardrails apply before the match/quality/lead checks: a
// candidate outside the allowed size or format is not a worse
// choice, it is not a choice auto-pick may make at all, so it must
// not count as "the winner" nor as "second place" for the lead
// check below.
eligible := prefs.filter(ranked)
if len(eligible) == 0 {
return false
}
best := eligible[0]
if best.Match.Overall < minMatch || best.Quality.Overall < minQuality {
return false
}
if len(ranked) > 1 && best.Score-ranked[1].Score < minLead {
if len(eligible) > 1 && best.Score-eligible[1].Score < minLead {
return false
}
+160 -22
View File
@@ -3,8 +3,8 @@ package download
import "testing"
// okComputer is the reference request used across ranking tests.
func okComputer() Request {
return Request{
func okComputer() Download {
return Download{
ReleaseMBID: "mbid-ok-computer",
Artist: "Radiohead",
Album: "OK Computer",
@@ -53,7 +53,7 @@ func allTitles() []string {
func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
flac := candidateFor("flac", allTitles(), ".flac", 30_000_000)
mp3 := candidateFor("mp3", allTitles(), ".mp3", 3_000_000)
@@ -62,7 +62,7 @@ func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
mp3.Files[i].Bitrate = 128
}
ranked := Rank(req, []Candidate{mp3, flac}, nil)
ranked := Rank(dl, []Candidate{mp3, flac}, nil, AutoDownloadPrefs{})
if ranked[0].ID != "flac" {
t.Fatalf("winner = %s, want flac", ranked[0].ID)
@@ -83,7 +83,7 @@ func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
func TestRankMatchDominatesQuality(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
// Right album, poor bitrate.
right := candidateFor("right", allTitles(), ".mp3", 2_000_000)
@@ -103,7 +103,7 @@ func TestRankMatchDominatesQuality(t *testing.T) {
trackToken(i+1) + " - x.flac"
}
ranked := Rank(req, []Candidate{wrong, right}, nil)
ranked := Rank(dl, []Candidate{wrong, right}, nil, AutoDownloadPrefs{})
if ranked[0].ID != "right" {
t.Fatalf(
@@ -116,12 +116,12 @@ func TestRankMatchDominatesQuality(t *testing.T) {
func TestIncompleteCandidateScoresLower(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
full := candidateFor("full", allTitles(), ".flac", 30_000_000)
partial := candidateFor("partial", allTitles()[:2], ".flac", 30_000_000)
ranked := Rank(req, []Candidate{partial, full}, nil)
ranked := Rank(dl, []Candidate{partial, full}, nil, AutoDownloadPrefs{})
if ranked[0].ID != "full" {
t.Fatalf("winner = %s, want full", ranked[0].ID)
@@ -138,7 +138,7 @@ func TestIncompleteCandidateScoresLower(t *testing.T) {
func TestMixedFormatIsPenalized(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
clean := candidateFor("clean", allTitles(), ".flac", 30_000_000)
@@ -146,7 +146,7 @@ func TestMixedFormatIsPenalized(t *testing.T) {
mixed.Files[2].Path = "Radiohead - OK Computer/03 - x.mp3"
mixed.Files[2].Format = FormatUnknown
ranked := Rank(req, []Candidate{mixed, clean}, nil)
ranked := Rank(dl, []Candidate{mixed, clean}, nil, AutoDownloadPrefs{})
var mixedScore QualityScore
@@ -170,10 +170,10 @@ func TestMixedFormatIsPenalized(t *testing.T) {
func TestUnanchoredMatchIsCapped(t *testing.T) {
t.Parallel()
req := Request{Artist: "Radiohead", Album: "OK Computer"}
dl := Download{Artist: "Radiohead", Album: "OK Computer"}
c := candidateFor("c", allTitles(), ".flac", 30_000_000)
scored := Score(req, c, 50)
scored := Score(dl, c, 50, AutoDownloadPrefs{})
if scored.Match.Anchored {
t.Error("free-text request reported as anchored")
@@ -190,19 +190,20 @@ func TestUnanchoredMatchIsCapped(t *testing.T) {
func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
t.Parallel()
req := okComputer()
best := Score(req, candidateFor("a", allTitles(), ".flac", 30_000_000), 50)
dl := okComputer()
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
t.Run("clear winner is auto-pickable", func(t *testing.T) {
t.Parallel()
weak := Score(
req,
dl,
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
50,
AutoDownloadPrefs{},
)
if !AutoPickable(req, []Candidate{best, weak}) {
if !AutoPickable(dl, []Candidate{best, weak}, AutoDownloadPrefs{}) {
t.Errorf(
"want auto-pickable: match %f quality %f lead %f",
best.Match.Overall, best.Quality.Overall, best.Score-weak.Score,
@@ -216,7 +217,7 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
twin := best
twin.ID = "twin"
if AutoPickable(req, []Candidate{best, twin}) {
if AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) {
t.Error("identical candidates must not auto-pick")
}
})
@@ -224,9 +225,9 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
t.Run("free text is never auto-pickable", func(t *testing.T) {
t.Parallel()
free := Request{Artist: "Radiohead", Album: "OK Computer"}
free := Download{Artist: "Radiohead", Album: "OK Computer"}
if AutoPickable(free, []Candidate{best}) {
if AutoPickable(free, []Candidate{best}, AutoDownloadPrefs{}) {
t.Error("unanchored request must not auto-pick")
}
})
@@ -234,7 +235,7 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
t.Run("empty list is not", func(t *testing.T) {
t.Parallel()
if AutoPickable(req, nil) {
if AutoPickable(dl, nil, AutoDownloadPrefs{}) {
t.Error("empty candidate list must not auto-pick")
}
})
@@ -276,7 +277,7 @@ func TestCompleteness(t *testing.T) {
func TestProviderPriorityBreaksTies(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
a := candidateFor("a", allTitles(), ".flac", 30_000_000)
a.ProviderID = 1
@@ -292,9 +293,146 @@ func TestProviderPriorityBreaksTies(t *testing.T) {
return 10
}
ranked := Rank(req, []Candidate{a, b}, priority)
ranked := Rank(dl, []Candidate{a, b}, priority, AutoDownloadPrefs{})
if ranked[0].ID != "b" {
t.Errorf("winner = %s, want b (higher provider priority)", ranked[0].ID)
}
}
const mb = 1 << 20
func TestAutoDownloadPrefsEligible(t *testing.T) {
t.Parallel()
flacCandidate := candidateFor("c", allTitles(), ".flac", 30_000_000)
flacCandidate.Files = AnnotateFiles(flacCandidate.Files)
flacCandidate.TotalSize = 300 * mb
mp3Candidate := candidateFor("c", allTitles(), ".mp3", 3_000_000)
mp3Candidate.Files = AnnotateFiles(mp3Candidate.Files)
mp3Candidate.TotalSize = 30 * mb
tests := []struct {
name string
prefs AutoDownloadPrefs
c Candidate
want bool
}{
{"zero value is permissive", AutoDownloadPrefs{}, flacCandidate, true},
{
"within min/max window",
AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 500},
flacCandidate, true,
},
{
"below minimum",
AutoDownloadPrefs{MinSizeMB: 400},
flacCandidate, false,
},
{
"above maximum",
AutoDownloadPrefs{MaxSizeMB: 200},
flacCandidate, false,
},
{
"allowed format passes",
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
flacCandidate, true,
},
{
"disallowed format rejected",
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
mp3Candidate, false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.prefs.eligible(tt.c); got != tt.want {
t.Errorf("eligible() = %v, want %v", got, tt.want)
}
})
}
}
func TestAutoDownloadPrefsFilter(t *testing.T) {
t.Parallel()
small := candidateFor("small", allTitles(), ".flac", 10_000_000)
small.TotalSize = 50 * mb
big := candidateFor("big", allTitles(), ".flac", 30_000_000)
big.TotalSize = 500 * mb
prefs := AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 600}
filtered := prefs.filter([]Candidate{small, big})
if len(filtered) != 1 || filtered[0].ID != "big" {
t.Errorf("filter() = %v, want only the in-window candidate", filtered)
}
}
func TestAutoDownloadPrefsSizeFit(t *testing.T) {
t.Parallel()
const neutral = 0.5
tests := []struct {
name string
prefs AutoDownloadPrefs
totalSize int64
want float64
}{
{"no preference is neutral", AutoDownloadPrefs{}, 300 * mb, neutral},
{
"exact match scores 1",
AutoDownloadPrefs{PreferredSizeMB: 300},
300 * mb, 1.0,
},
{
"double the preferred size scores 0",
AutoDownloadPrefs{PreferredSizeMB: 300},
600 * mb, 0.0,
},
{
"half the preferred size scores 0",
AutoDownloadPrefs{PreferredSizeMB: 300},
150 * mb, 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.prefs.sizeFit(tt.totalSize); got != tt.want {
t.Errorf("sizeFit(%d) = %f, want %f", tt.totalSize, got, tt.want)
}
})
}
}
// An otherwise-perfect candidate must not auto-pick when it falls
// outside the configured size guard: the guardrail applies before the
// match/quality/lead checks, not as one more input averaged into them.
func TestAutoPickableRejectsCandidateOutsideSizeGuard(t *testing.T) {
t.Parallel()
dl := okComputer()
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
best.TotalSize = 500 * mb
if !AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{}) {
t.Fatal("expected this candidate to be auto-pickable with no guardrails")
}
tight := AutoDownloadPrefs{MinSizeMB: 10, MaxSizeMB: 100}
if AutoPickable(dl, []Candidate{best}, tight) {
t.Error("candidate outside the size guard must not auto-pick")
}
}
+96 -96
View File
@@ -11,10 +11,10 @@ import (
"time"
)
// The reconciler is the only thing that turns wants into downloads.
// The reconciler is the only thing that turns requests into downloads.
//
// It runs on a slow loop rather than reacting to events, because
// everything it cares about changes slowly: a release the user wants
// everything it cares about changes slowly: a release the user requests
// appears on a source days or weeks after they asked, an artist puts
// out an album once a year, and a library gains files by scan rather
// than by notification. A loop that wakes a few times a day is
@@ -24,17 +24,17 @@ import (
//
// Each pass does four things, in this order and for this reason:
//
// 1. Expand artist subscriptions into per-album wants, so step 2 sees
// 1. Expand artist subscriptions into per-album requests, so step 2 sees
// them this pass rather than next.
// 2. Retire wants the library already owns — including ones the user
// 2. Retire requests the library already owns — including ones the user
// satisfied by other means, which is why ownership is checked
// rather than assumed from our own downloads.
// 3. Push the list to clients that keep their own (Lidarr), so the
// user's intent is expressed in both places.
// 4. Attempt a bounded batch of due wants.
// 4. Attempt a bounded batch of due requests.
//
// Nothing here fails a want. A want that cannot be found gets an
// attempt recorded and a longer backoff, and stays exactly as wanted as
// Nothing here fails a request. A request that cannot be found gets an
// attempt recorded and a longer backoff, and stays exactly as requested as
// it was.
// CatalogPort is what the reconciler needs to know about the world of
@@ -51,8 +51,8 @@ type CatalogPort interface {
) ([]CatalogItem, error)
// Tracklist resolves a release group or release to the tracks it
// should contain. This is what makes a want's download safe to
// complete unattended, so a want with no tracklist is never
// should contain. This is what makes a request's download safe to
// complete unattended, so a request with no tracklist is never
// auto-grabbed.
Tracklist(
ctx context.Context,
@@ -64,8 +64,8 @@ type CatalogPort interface {
// names.
Owns(ctx context.Context, entity Entity, mbid string) (bool, error)
// Describe fills in display text for a want the user added by MBID
// alone. Best-effort: an unknown MBID returns false and the want
// Describe fills in display text for a request the user added by MBID
// alone. Best-effort: an unknown MBID returns false and the request
// is still perfectly valid.
Describe(
ctx context.Context,
@@ -87,7 +87,7 @@ type CatalogItem struct {
PrimaryType string
// SecondaryTypes carries "Compilation", "Live", "Remix" and
// friends. Their presence is what an artist want's default scope
// friends. Their presence is what an artist request's default scope
// filters out.
SecondaryTypes []string
@@ -100,25 +100,25 @@ type CatalogItem struct {
// Reconciler defaults.
const (
// defaultReconcileInterval is how often the wanted list is worked.
// defaultReconcileInterval is how often the request list is worked.
// Four times a day is far more often than new music appears and far
// less often than any provider would object to.
defaultReconcileInterval = 6 * time.Hour
// startupDelay lets the app finish starting — library scan, explore
// index, provider construction — before the first pass. A wanted
// index, provider construction — before the first pass. A requested
// list worked against an index that has not loaded yet would record
// a pile of pointless attempts.
startupDelay = 3 * time.Minute
// maxExpandPerArtist bounds how many child wants one artist
// maxExpandPerArtist bounds how many child requests one artist
// subscription creates in a single pass, so switching an artist to
// full-discography scope does not enqueue four hundred albums at
// once. The remainder is picked up next pass.
maxExpandPerArtist = 40
)
// Reconciler works the wanted list.
// Reconciler works the request list.
type Reconciler struct {
logger *slog.Logger
store *Store
@@ -143,12 +143,12 @@ type Reconciler struct {
stop chan struct{}
// runMu serializes passes: two reconcilers racing would search for
// the same want twice.
// the same request twice.
runMu sync.Mutex
}
// NewReconciler builds a reconciler. catalog may be nil, in which case
// the wanted list still stores and lists wants but never acts on them —
// the request list still stores and lists requests but never acts on them —
// which is the right behaviour when the explore index is unavailable.
func NewReconciler(
logger *slog.Logger,
@@ -176,7 +176,7 @@ func (r *Reconciler) SetInterval(d time.Duration) {
}
}
// SetBatch overrides how many wants one pass attempts.
// SetBatch overrides how many requests one pass attempts.
func (r *Reconciler) SetBatch(n int) {
if n > 0 {
r.batch = n
@@ -200,7 +200,7 @@ func (r *Reconciler) Stop() {
}
// Trigger asks for a pass as soon as possible without blocking the
// caller. Used when the user adds a want and expects something to
// caller. Used when the user adds a request and expects something to
// happen.
func (r *Reconciler) Trigger() {
select {
@@ -229,27 +229,27 @@ func (r *Reconciler) loop(ctx context.Context) {
}
if _, err := r.RunOnce(ctx); err != nil {
r.logger.Warn("wanted list reconcile failed", "error", err)
r.logger.Warn("request list reconcile failed", "error", err)
}
}
}
// Summary reports what a pass did, for logging and for the UI.
type Summary struct {
// Expanded is how many child wants artist subscriptions produced.
// Expanded is how many child requests artist subscriptions produced.
Expanded int `json:"expanded"`
// Satisfied is how many wants the library turned out to own.
// Satisfied is how many requests the library turned out to own.
Satisfied int `json:"satisfied"`
// Attempted is how many wants were searched for.
// Attempted is how many requests were searched for.
Attempted int `json:"attempted"`
// Started is how many of those found a clear enough winner to
// download unattended.
Started int `json:"started"`
// Synced is how many wants were pushed to an external list.
// Synced is how many requests were pushed to an external list.
Synced int `json:"synced"`
}
@@ -259,7 +259,7 @@ func (s Summary) changed() bool {
return s.Expanded > 0 || s.Satisfied > 0 || s.Started > 0
}
// RunOnce works the wanted list once. It is safe to call directly, and
// RunOnce works the request list once. It is safe to call directly, and
// the "search now" button does.
func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
r.runMu.Lock()
@@ -296,7 +296,7 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
summary.Started = started
r.logger.Info(
"reconciled wanted list",
"reconciled request list",
"expanded", summary.Expanded,
"satisfied", summary.Satisfied,
"attempted", summary.Attempted,
@@ -315,15 +315,15 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
// Artist expansion
// ---------------------------------------------------------------------------
// expandArtists turns artist subscriptions into per-album wants.
// expandArtists turns artist subscriptions into per-album requests.
//
// The expansion is idempotent: child wants are upserted on (mbid,
// The expansion is idempotent: child requests are upserted on (mbid,
// library), so re-running adds only what is genuinely new. That is
// what makes an artist want a standing subscription rather than a
// what makes an artist request a standing subscription rather than a
// one-time queue-filling operation — an album released next year gets
// picked up by the same code path that ran today.
func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
artists, err := r.store.ListArtistWants(ctx)
artists, err := r.store.ListActiveRequests(ctx)
if err != nil {
return 0, err
}
@@ -336,7 +336,7 @@ func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
// One artist whose discography will not resolve must not
// stop the rest of the list.
r.logger.Warn(
"could not expand artist want",
"could not expand artist request",
"artist", artist.Label(),
"mbid", artist.MBID,
"error", err,
@@ -352,7 +352,7 @@ func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
}
// expandArtist expands one subscription.
func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error) {
func (r *Reconciler) expandArtist(ctx context.Context, artist Request) (int, error) {
groups, err := r.catalog.ReleaseGroupsForArtist(ctx, artist.MBID)
if err != nil {
return 0, err
@@ -365,16 +365,16 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
break
}
if !wantsReleaseGroup(artist, rg) {
if !requestsReleaseGroup(artist, rg) {
continue
}
// Existence is checked before inserting rather than relying on
// the upsert, because "how many albums are new this pass" is
// the number the UI reports and an upsert cannot tell an insert
// from a no-op. It also means a want the user pinned by hand
// from a no-op. It also means a request the user pinned by hand
// is never quietly reparented under the artist.
if _, exists, err := r.store.FindWant(
if _, exists, err := r.store.FindRequest(
ctx, rg.MBID, artist.LibraryID,
); err != nil || exists {
continue
@@ -385,7 +385,7 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
credit = artist.Artist
}
if _, err := r.store.AddWant(ctx, Want{
if _, err := r.store.AddRequest(ctx, Request{
MBID: rg.MBID,
Entity: EntityReleaseGroup,
LibraryID: artist.LibraryID,
@@ -394,7 +394,7 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
ParentID: artist.ID,
}); err != nil {
r.logger.Warn(
"could not add derived want",
"could not add derived request",
"release_group", rg.MBID,
"error", err,
)
@@ -408,9 +408,9 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
return created, nil
}
// wantsReleaseGroup applies an artist subscription's filters to one
// requestsReleaseGroup applies an artist subscription's filters to one
// release group.
func wantsReleaseGroup(artist Want, rg CatalogItem) bool {
func requestsReleaseGroup(artist Request, rg CatalogItem) bool {
if rg.MBID == "" || rg.InLibrary {
return false
}
@@ -457,30 +457,30 @@ func releaseDateAfter(date string, since time.Time) bool {
// Retiring what the library already has
// ---------------------------------------------------------------------------
// retireOwned satisfies wants the library turns out to own.
// retireOwned satisfies requests the library turns out to own.
//
// Ownership is asked of the library rather than inferred from our own
// completed downloads on purpose: the user may have bought the album,
// ripped their CD, or copied it in from another machine, and a wanted
// ripped their CD, or copied it in from another machine, and a requested
// list that keeps hunting for music already sitting on disk is worse
// than no wanted list at all.
// than no request list at all.
func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
wants, err := r.store.ListWants(ctx)
requests, err := r.store.ListRequests(ctx)
if err != nil {
return 0, err
}
satisfied := 0
for _, w := range wants {
if w.State != WantStateWanted || w.Entity.Expands() {
for _, req := range requests {
if req.State != RequestStateWanted || req.Entity.Expands() {
continue
}
owned, err := r.catalog.Owns(ctx, w.Entity, w.MBID)
owned, err := r.catalog.Owns(ctx, req.Entity, req.MBID)
if err != nil {
r.logger.Debug(
"ownership check failed", "want", w.MBID, "error", err,
"ownership check failed", "request", req.MBID, "error", err,
)
continue
@@ -490,8 +490,8 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
continue
}
if err := r.store.SatisfyWant(ctx, w.ID); err != nil {
r.logger.Warn("could not satisfy want", "want", w.ID, "error", err)
if err := r.store.SatisfyRequest(ctx, req.ID); err != nil {
r.logger.Warn("could not satisfy request", "request", req.ID, "error", err)
continue
}
@@ -506,15 +506,15 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
// Attempting downloads
// ---------------------------------------------------------------------------
// attemptDue searches for a bounded batch of due wants and grabs the
// attemptDue searches for a bounded batch of due requests and grabs the
// ones with a clear winner.
func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, err error) {
due, err := r.store.ListDueWants(ctx, r.batch)
due, err := r.store.ListDueRequests(ctx, r.batch)
if err != nil {
return 0, 0, err
}
for _, w := range due {
for _, req := range due {
select {
case <-ctx.Done():
return attempted, started, nil
@@ -523,7 +523,7 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
attempted++
ok, reason := r.attempt(ctx, w)
ok, reason := r.attempt(ctx, req)
if ok {
started++
@@ -531,10 +531,10 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
}
if err := r.store.RecordAttempt(
ctx, w.ID, w.Attempts, reason,
ctx, req.ID, req.Attempts, reason,
); err != nil {
r.logger.Warn(
"could not record want attempt", "want", w.ID, "error", err,
"could not record request attempt", "request", req.ID, "error", err,
)
}
}
@@ -542,64 +542,64 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
return attempted, started, nil
}
// tracklistFor resolves what a want should contain, which is the
// tracklistFor resolves what a request should contain, which is the
// evidence an unattended download is checked against.
//
// A track want is its own tracklist: one entry, built from the title
// the want already carries. That single expected title is what lets
// A track request is its own tracklist: one entry, built from the title
// the request already carries. That single expected title is what lets
// filename matching score a track download at all — without it a
// request for one song would be scored as an album with no tracks and
// could never clear the auto-pick bar.
func (r *Reconciler) tracklistFor(
ctx context.Context,
w Want,
req Request,
) ([]ExpectedTrack, error) {
if w.Entity != EntityRecording {
return r.catalog.Tracklist(ctx, w.Entity, w.MBID)
if req.Entity != EntityRecording {
return r.catalog.Tracklist(ctx, req.Entity, req.MBID)
}
if w.Title == "" {
if req.Title == "" {
return nil, nil
}
return []ExpectedTrack{{
Position: 1,
Title: w.Title,
Artist: w.Artist,
Title: req.Title,
Artist: req.Artist,
}}, nil
}
// attempt tries one want. It returns false with a human-readable
// attempt tries one request. It returns false with a human-readable
// reason rather than an error, because none of the ways this does not
// work out are failures: no providers configured yet, nothing on any
// source, or nothing good enough to take without asking are all just
// "not today".
func (r *Reconciler) attempt(ctx context.Context, w Want) (bool, string) {
expected, err := r.tracklistFor(ctx, w)
func (r *Reconciler) attempt(ctx context.Context, req Request) (bool, string) {
expected, err := r.tracklistFor(ctx, req)
if err != nil || len(expected) == 0 {
// Without a tracklist an unattended grab has nothing to verify
// itself against, so this want waits rather than guessing. The
// itself against, so this request waits rather than guessing. The
// tracklist usually arrives on its own once the explore index
// fetches the release.
return false, "waiting for the tracklist to resolve"
}
req := w.ToRequest(newID())
req.Expected = expected
dl := req.ToDownload(newID())
dl.Expected = expected
if req.Artist == "" || req.Album == "" {
if item, ok := r.catalog.Describe(ctx, w.Entity, w.MBID); ok {
if req.Artist == "" {
req.Artist = item.Artist
if dl.Artist == "" || dl.Album == "" {
if item, ok := r.catalog.Describe(ctx, req.Entity, req.MBID); ok {
if dl.Artist == "" {
dl.Artist = item.Artist
}
if req.Album == "" {
req.Album = item.Title
if dl.Album == "" {
dl.Album = item.Title
}
}
}
started, reason, err := r.manager.Attempt(ctx, req)
started, reason, err := r.manager.Attempt(ctx, dl)
if err != nil {
if errors.Is(err, ErrNoProviders) {
return false, "no download clients are enabled"
@@ -619,7 +619,7 @@ func (r *Reconciler) attempt(ctx context.Context, w Want) (bool, string) {
// External list sync
// ---------------------------------------------------------------------------
// syncExternalLists pushes wants to providers that keep a persistent
// syncExternalLists pushes requests to providers that keep a persistent
// list of their own.
//
// The sync is one-directional by design. Two systems that both accept
@@ -634,21 +634,21 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
return 0
}
wants, err := r.store.ListWants(ctx)
requests, err := r.store.ListRequests(ctx)
if err != nil {
r.logger.Warn("could not list wants for sync", "error", err)
r.logger.Warn("could not list requests for sync", "error", err)
return 0
}
synced := 0
for _, w := range wants {
if w.State != WantStateWanted {
for _, req := range requests {
if req.State != RequestStateWanted {
continue
}
external := w.ExternalIDs
external := req.ExternalIDs
if external == nil {
external = map[string]string{}
}
@@ -661,11 +661,11 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
continue
}
externalID, err := l.PushWant(ctx, w)
externalID, err := l.PushRequest(ctx, req)
if err != nil {
r.logger.Debug(
"could not push want to external list",
"want", w.MBID,
"could not push request to external list",
"request", req.MBID,
"provider", id,
"error", err,
)
@@ -686,9 +686,9 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
continue
}
if err := r.store.SetWantExternalIDs(ctx, w.ID, external); err != nil {
if err := r.store.SetRequestExternalIDs(ctx, req.ID, external); err != nil {
r.logger.Warn(
"could not record external want ids", "want", w.ID, "error", err,
"could not record external request ids", "request", req.ID, "error", err,
)
}
}
@@ -696,7 +696,7 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
return synced
}
// ImportExternal pulls an external manager's own list into the wanted
// ImportExternal pulls an external manager's own list into the requested
// list. This is the one place data flows the other way, and it is a
// deliberate user action ("import my monitored Lidarr artists") rather
// than part of the loop, because silently adopting whatever another
@@ -715,19 +715,19 @@ func (r *Reconciler) ImportExternal(
)
}
external, err := l.ListWants(ctx)
external, err := l.ListRequests(ctx)
if err != nil {
return 0, fmt.Errorf("list external wants: %w", err)
return 0, fmt.Errorf("list external requests: %w", err)
}
imported := 0
for _, w := range external {
w.LibraryID = libraryID
for _, req := range external {
req.LibraryID = libraryID
if _, err := r.store.AddWant(ctx, w); err != nil {
if _, err := r.store.AddRequest(ctx, req); err != nil {
r.logger.Warn(
"could not import external want", "mbid", w.MBID, "error", err,
"could not import external request", "mbid", req.MBID, "error", err,
)
continue
+56 -56
View File
@@ -111,14 +111,14 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
{MBID: "rg-2", Title: "Second", FirstReleaseDate: "2030-06-01"},
}
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Artist: "Radiohead",
Scope: ScopeAll,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
first, err := f.reconciler.expandArtists(ctx)
@@ -127,7 +127,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
}
if first != 2 {
t.Fatalf("first pass created %d wants, want 2", first)
t.Fatalf("first pass created %d requests, want 2", first)
}
second, err := f.reconciler.expandArtists(ctx)
@@ -136,7 +136,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
}
if second != 0 {
t.Errorf("second pass created %d wants, want 0", second)
t.Errorf("second pass created %d requests, want 0", second)
}
// A new album appearing later is picked up by the same pass.
@@ -153,7 +153,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
}
if third != 1 {
t.Errorf("third pass created %d wants, want 1", third)
t.Errorf("third pass created %d requests, want 1", third)
}
}
@@ -170,32 +170,32 @@ func TestExpandArtistFutureScopeSkipsBackCatalogue(t *testing.T) {
{MBID: "rg-new", Title: "New", FirstReleaseDate: "2099-01-01"},
}
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Scope: ScopeFuture,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
if _, err := f.reconciler.expandArtists(ctx); err != nil {
t.Fatalf("expandArtists: %v", err)
}
wants, err := f.store.ListWants(ctx)
requests, err := f.store.ListRequests(ctx)
if err != nil {
t.Fatalf("ListWants: %v", err)
t.Fatalf("ListDownloads: %v", err)
}
for _, w := range wants {
if w.MBID == "rg-old" {
for _, req := range requests {
if req.MBID == "rg-old" {
t.Error("future scope queued a back-catalogue album")
}
}
if len(wants) != 2 {
t.Errorf("got %d wants (artist + new album), want 2", len(wants))
if len(requests) != 2 {
t.Errorf("got %d requests (artist + new album), want 2", len(requests))
}
}
@@ -209,12 +209,12 @@ func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
f.catalog.discographyErr = errors.New("index not ready") //nolint:err113 // test
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
created, err := f.reconciler.expandArtists(ctx)
@@ -223,24 +223,24 @@ func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
}
if created != 0 {
t.Errorf("created %d wants from a failing catalog, want 0", created)
t.Errorf("created %d requests from a failing catalog, want 0", created)
}
}
// Something the library already owns is retired, however it got there.
func TestRetireOwnedSatisfiesWants(t *testing.T) {
func TestRetireOwnedSatisfiesRequests(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
id, err := f.store.AddWant(ctx, Want{
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
f.catalog.owned["rg-1"] = true
@@ -251,16 +251,16 @@ func TestRetireOwnedSatisfiesWants(t *testing.T) {
}
if n != 1 {
t.Fatalf("retired %d wants, want 1", n)
t.Fatalf("retired %d requests, want 1", n)
}
w, err := f.store.GetWant(ctx, id)
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetWant: %v", err)
t.Fatalf("GetRequest: %v", err)
}
if w.State != WantStateSatisfied {
t.Errorf("state = %q, want satisfied", w.State)
if req.State != RequestStateSatisfied {
t.Errorf("state = %q, want satisfied", req.State)
}
}
@@ -275,7 +275,7 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddWant(ctx, Want{
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
@@ -283,10 +283,10 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
@@ -301,9 +301,9 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
}
waitFor(t, func() bool {
w, err := f.store.GetWant(ctx, id)
req, err := f.store.GetRequest(ctx, id)
return err == nil && w.State == WantStateSatisfied
return err == nil && req.State == RequestStateSatisfied
}, "want was never satisfied after its download completed")
if provider.GrabCalls != 1 {
@@ -313,7 +313,7 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
// Nothing good enough is not a failure. The want stays wanted, gains
// an attempt and a reason, and leaves no request row behind.
func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
func TestReconcileKeepsRequestingWhenNothingIsGoodEnough(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
@@ -328,7 +328,7 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddWant(ctx, Want{
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
@@ -336,10 +336,10 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
@@ -350,32 +350,32 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
t.Fatalf("started %d downloads, want 0", summary.Started)
}
w, err := f.store.GetWant(ctx, id)
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetWant: %v", err)
t.Fatalf("GetRequest: %v", err)
}
if w.State != WantStateWanted {
t.Errorf("state = %q, want it still wanted", w.State)
if req.State != RequestStateWanted {
t.Errorf("state = %q, want it still wanted", req.State)
}
if w.Attempts != 1 {
t.Errorf("attempts = %d, want 1", w.Attempts)
if req.Attempts != 1 {
t.Errorf("attempts = %d, want 1", req.Attempts)
}
if w.LastError == "" {
if req.LastError == "" {
t.Error("no reason was recorded for the user")
}
if !w.NextTryAt.After(time.Now()) {
t.Errorf("next try at %v, want it in the future", w.NextTryAt)
if !req.NextTryAt.After(time.Now()) {
t.Errorf("next try at %v, want it in the future", req.NextTryAt)
}
// The whole point of Attempt over Start: an unsuccessful pass
// leaves no request row to clutter the downloads list.
requests, err := f.store.ListRequests(ctx, 50)
requests, err := f.store.ListDownloads(ctx, 50)
if err != nil {
t.Fatalf("ListRequests: %v", err)
t.Fatalf("ListDownloads: %v", err)
}
if len(requests) != 0 {
@@ -393,14 +393,14 @@ func TestReconcileWaitsWithoutTracklist(t *testing.T) {
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
summary, err := f.reconciler.RunOnce(ctx)
@@ -421,27 +421,27 @@ func TestReconcileWaitsWithoutTracklist(t *testing.T) {
}
// Artist subscriptions are never attempted as downloads: they expand.
func TestArtistWantsAreNeverDue(t *testing.T) {
func TestArtistRequestsAreNeverDue(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
due, err := f.store.ListDueWants(ctx, 10)
due, err := f.store.ListDueRequests(ctx, 10)
if err != nil {
t.Fatalf("ListDueWants: %v", err)
t.Fatalf("ListDueRequests: %v", err)
}
if len(due) != 0 {
t.Errorf("got %d due wants, want 0 — artists expand, not download", len(due))
t.Errorf("got %d due requests, want 0 — artists expand, not download", len(due))
}
}
@@ -456,16 +456,16 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
f.reconciler.SetBatch(2)
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: mbid,
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "Album " + mbid,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists[mbid] = fourTrackRequest().Expected
f.catalog.tracklists[mbid] = fourTrackDownload().Expected
}
summary, err := f.reconciler.RunOnce(ctx)
@@ -474,7 +474,7 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
}
if summary.Attempted != 2 {
t.Errorf("attempted %d wants, want 2 (the batch size)", summary.Attempted)
t.Errorf("attempted %d requests, want 2 (the batch size)", summary.Attempted)
}
}
+240
View File
@@ -0,0 +1,240 @@
package download
import (
"math"
"math/rand/v2"
"time"
)
// A Request is a persistent "I want this", stored as a MusicBrainz ID
// and almost nothing else.
//
// The distinction from Download is the whole point of this file. A
// Download is one attempt: it searches, it grabs, it succeeds or fails,
// and then it is history. A Request outlives every attempt made on its
// behalf. Nothing being findable today is the normal case for obscure
// music, and the correct response is to try again next week, not to
// show the user a failed row they have to remember to retry.
//
// Because a Request is only an MBID, it stays true when everything
// around it changes: the explore index is rebuilt, a provider is
// swapped out, the release the user originally saw is superseded by a
// remaster. The display fields are a cache for the list view and are
// never consulted for matching.
// Entity says what a request's MBID names, and is the only type
// distinction the durable request list makes.
type Entity string
// Request entity types.
const (
// EntityArtist is a subscription rather than a thing to fetch: it
// is never satisfied, and each reconcile expands the artist's
// discography into child requests.
EntityArtist Entity = "artist"
// EntityReleaseGroup is an album in the abstract — any release of
// it satisfies the request, which is what a user means by "I want
// this album".
EntityReleaseGroup Entity = "release-group"
// EntityRelease is one specific edition, used when the user picked
// a particular pressing.
EntityRelease Entity = "release"
// EntityRecording is a single track.
EntityRecording Entity = "recording"
)
// Valid reports whether e is a known entity type.
func (e Entity) Valid() bool {
switch e {
case EntityArtist, EntityReleaseGroup, EntityRelease, EntityRecording:
return true
default:
return false
}
}
// Expands reports whether this entity produces child requests rather
// than being downloaded directly.
func (e Entity) Expands() bool {
return e == EntityArtist
}
// RequestState is where a request sits. There is deliberately no
// "failed": an attempt can fail, a request cannot. A request that has
// tried and not found anything is still wanted, with attempts and
// last_error recording why it is taking a while.
type RequestState string
// Request states.
const (
// RequestStateWanted is the active state: due for another attempt
// when its backoff elapses.
RequestStateWanted RequestState = "wanted"
// RequestStateSatisfied means the library owns it. How it got
// there — downloaded here, ripped, bought elsewhere — does not
// matter.
RequestStateSatisfied RequestState = "satisfied"
// RequestStatePaused is the user saying "keep this on the list but
// stop trying".
RequestStatePaused RequestState = "paused"
)
// RequestScope applies to artist requests only.
type RequestScope string
// Artist request scopes.
const (
// ScopeFuture takes only releases first published after the artist
// was added. Default, because subscribing to an artist should not
// silently queue their entire back catalogue.
ScopeFuture RequestScope = "future"
// ScopeAll backfills the whole discography as well.
ScopeAll RequestScope = "all"
)
// Request is one row of the durable request list.
type Request struct {
ID int64 `json:"id"`
MBID string `json:"mbid"`
Entity Entity `json:"entity"`
LibraryID int64 `json:"libraryId"`
// Artist and Title are display cache only. Matching always uses
// the MBID.
Artist string `json:"artist"`
Title string `json:"title"`
Scope RequestScope `json:"scope"`
// Secondary includes compilations, live albums and remixes in an
// artist request's expansion.
Secondary bool `json:"secondary"`
State RequestState `json:"state"`
// ParentID is set on requests the reconciler derived from an artist
// subscription. A request the user pinned directly has none, so
// removing the artist leaves it alone.
ParentID int64 `json:"parentId,omitempty"`
Attempts int `json:"attempts"`
LastError string `json:"lastError,omitempty"`
LastTriedAt time.Time `json:"lastTriedAt,omitempty"`
NextTryAt time.Time `json:"nextTryAt,omitempty"`
// ExternalIDs maps provider row ID (as a string, because JSON
// object keys are strings) to that provider's own identifier for
// this request. Only set for providers that keep a persistent
// list of their own.
ExternalIDs map[string]string `json:"externalIds,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Anchored is always true for a request: it is an MBID by construction.
// The method exists so requests and downloads read the same at call
// sites.
func (r Request) Anchored() bool { return r.MBID != "" }
// Label is the request list's one-line description of a request.
func (r Request) Label() string {
switch {
case r.Artist != "" && r.Title != "":
return r.Artist + " — " + r.Title
case r.Title != "":
return r.Title
case r.Artist != "":
return r.Artist
default:
return string(r.Entity) + " " + r.MBID
}
}
// Retry backoff. A request that cannot be found is usually one that
// will not be findable for a while — a pre-release, something only
// ever on physical media, an artist no source indexes — so the
// schedule climbs fast and then sits at a weekly poll rather than
// hammering providers with the same fruitless search.
const (
// requestRetryBase is the delay after the first unsuccessful
// attempt.
requestRetryBase = 6 * time.Hour
// requestRetryMax caps the backoff. A weekly retry on a list of a
// few hundred requests is a handful of searches a day, which every
// provider tolerates.
requestRetryMax = 7 * 24 * time.Hour
// requestRetryJitter spreads retries so a list added in one sitting
// does not come due in one burst.
requestRetryJitter = 0.2
)
// nextRetry returns when a request with the given attempt count should
// be tried again: exponential from requestRetryBase, capped at
// requestRetryMax, jittered so a batch added together does not stay in
// lockstep forever.
func nextRetry(now time.Time, attempts int) time.Time {
if attempts < 1 {
attempts = 1
}
// Cap the exponent before shifting so a long-lived request cannot
// overflow the duration into something negative.
const maxExp = 16
exp := min(attempts-1, maxExp)
delay := float64(requestRetryBase) * math.Pow(2, float64(exp))
if delay > float64(requestRetryMax) {
delay = float64(requestRetryMax)
}
jitter := delay * requestRetryJitter * (rand.Float64()*2 - 1) //nolint:gosec // spreading retries, not a secret
return now.Add(time.Duration(delay + jitter))
}
// requestSource is the download source recorded for reconciler-raised
// downloads, so the downloads list can tell them apart from the ones a
// user started by hand.
const requestSource = "wanted"
// ToDownload builds the download that would satisfy this request.
// Expected is filled by the caller from the catalog, since resolving a
// tracklist is I/O and this is not.
func (r Request) ToDownload(id string) Download {
d := Download{
ID: id,
LibraryID: r.LibraryID,
Artist: r.Artist,
Album: r.Title,
RequestID: r.ID,
Source: requestSource,
}
switch r.Entity {
case EntityRelease:
d.ReleaseMBID = r.MBID
case EntityReleaseGroup:
d.ReleaseGroupMBID = r.MBID
case EntityRecording:
// A recording has no release anchor, so ranking has only the
// title to go on and auto-pick stays off. The MBID is still
// carried in RecordingMBID so a provider that can use it does.
d.RecordingMBID = r.MBID
case EntityArtist:
// Artist requests expand into children and are never turned
// into a download directly; this case exists so the switch is
// exhaustive rather than because it can happen.
}
return d
}
@@ -17,18 +17,18 @@ func TestNextRetryClimbsAndCaps(t *testing.T) {
attempts int
nominal time.Duration
}{
{attempts: 1, nominal: wantRetryBase},
{attempts: 2, nominal: 2 * wantRetryBase},
{attempts: 3, nominal: 4 * wantRetryBase},
{attempts: 20, nominal: wantRetryMax},
{attempts: 500, nominal: wantRetryMax},
{attempts: 1, nominal: requestRetryBase},
{attempts: 2, nominal: 2 * requestRetryBase},
{attempts: 3, nominal: 4 * requestRetryBase},
{attempts: 20, nominal: requestRetryMax},
{attempts: 500, nominal: requestRetryMax},
}
for _, tt := range tests {
got := nextRetry(now, tt.attempts).Sub(now)
lo := time.Duration(float64(tt.nominal) * (1 - wantRetryJitter))
hi := time.Duration(float64(tt.nominal) * (1 + wantRetryJitter))
lo := time.Duration(float64(tt.nominal) * (1 - requestRetryJitter))
hi := time.Duration(float64(tt.nominal) * (1 + requestRetryJitter))
if got < lo || got > hi {
t.Errorf(
@@ -83,14 +83,14 @@ func TestReleaseDateAfter(t *testing.T) {
}
}
func TestWantsReleaseGroupFilters(t *testing.T) {
func TestRequestsReleaseGroupFilters(t *testing.T) {
t.Parallel()
subscribed := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
future := Want{Scope: ScopeFuture, CreatedAt: subscribed}
all := Want{Scope: ScopeAll, CreatedAt: subscribed}
allSecondary := Want{
future := Request{Scope: ScopeFuture, CreatedAt: subscribed}
all := Request{Scope: ScopeAll, CreatedAt: subscribed}
allSecondary := Request{
Scope: ScopeAll, Secondary: true, CreatedAt: subscribed,
}
@@ -107,7 +107,7 @@ func TestWantsReleaseGroupFilters(t *testing.T) {
tests := []struct {
name string
artist Want
artist Request
rg CatalogItem
want bool
}{
@@ -131,54 +131,54 @@ func TestWantsReleaseGroupFilters(t *testing.T) {
}
for _, tt := range tests {
if got := wantsReleaseGroup(tt.artist, tt.rg); got != tt.want {
if got := requestsReleaseGroup(tt.artist, tt.rg); got != tt.want {
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
}
}
}
func TestWantToRequestAnchors(t *testing.T) {
func TestRequestToDownloadAnchors(t *testing.T) {
t.Parallel()
tests := []struct {
entity Entity
check func(Request) string
check func(Download) string
}{
{
entity: EntityReleaseGroup,
check: func(r Request) string {
check: func(r Download) string {
return r.ReleaseGroupMBID
},
},
{
entity: EntityRelease,
check: func(r Request) string { return r.ReleaseMBID },
check: func(r Download) string { return r.ReleaseMBID },
},
{
entity: EntityRecording,
check: func(r Request) string { return r.RecordingMBID },
check: func(r Download) string { return r.RecordingMBID },
},
}
for _, tt := range tests {
w := Want{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
req := Request{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
req := w.ToRequest("req-1")
dl := req.ToDownload("dl-1")
if got := tt.check(req); got != "mbid-x" {
if got := tt.check(dl); got != "mbid-x" {
t.Errorf("%s: anchor = %q, want mbid-x", tt.entity, got)
}
if !req.Anchored() {
t.Errorf("%s: request is not anchored", tt.entity)
if !dl.Anchored() {
t.Errorf("%s: download is not anchored", tt.entity)
}
if req.WantID != 7 {
t.Errorf("%s: WantID = %d, want 7", tt.entity, req.WantID)
if dl.RequestID != 7 {
t.Errorf("%s: RequestID = %d, want 7", tt.entity, dl.RequestID)
}
if req.Source != wantSource {
t.Errorf("%s: Source = %q, want %q", tt.entity, req.Source, wantSource)
if dl.Source != requestSource {
t.Errorf("%s: Source = %q, want %q", tt.entity, dl.Source, requestSource)
}
}
}
@@ -189,7 +189,7 @@ func TestWantToRequestAnchors(t *testing.T) {
func TestAutoPickableRequiresTracklist(t *testing.T) {
t.Parallel()
req := Request{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
dl := Download{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
ranked := []Candidate{{
Match: MatchScore{Overall: 0.99, Anchored: true},
@@ -197,42 +197,42 @@ func TestAutoPickableRequiresTracklist(t *testing.T) {
Score: 0.95,
}}
if AutoPickable(req, ranked) {
t.Error("auto-picked a request with no expected tracklist")
if AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
t.Error("auto-picked a download with no expected tracklist")
}
req.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
dl.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
if !AutoPickable(req, ranked) {
t.Error("did not auto-pick a well-anchored, well-matched request")
if !AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
t.Error("did not auto-pick a well-anchored, well-matched download")
}
}
// The wanted list's identity is the MBID, so the same one arriving
// twice — in a different case, with whitespace — is one row.
func TestAddWantNormalizesAndDeduplicates(t *testing.T) {
func TestAddRequestNormalizesAndDeduplicates(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
ctx := context.Background()
first, err := f.store.AddWant(ctx, Want{
first, err := f.store.AddRequest(ctx, Request{
MBID: " ABC-123 ",
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
second, err := f.store.AddWant(ctx, Want{
second, err := f.store.AddRequest(ctx, Request{
MBID: "abc-123",
Entity: EntityReleaseGroup,
LibraryID: 1,
})
if err != nil {
t.Fatalf("AddWant again: %v", err)
t.Fatalf("AddRequest again: %v", err)
}
if first != second {
@@ -240,27 +240,27 @@ func TestAddWantNormalizesAndDeduplicates(t *testing.T) {
}
// Re-adding with no title must not wipe the one we have.
w, err := f.store.GetWant(ctx, first)
req, err := f.store.GetRequest(ctx, first)
if err != nil {
t.Fatalf("GetWant: %v", err)
t.Fatalf("GetRequest: %v", err)
}
if w.Title != "OK Computer" {
t.Errorf("title = %q, want it preserved", w.Title)
if req.Title != "OK Computer" {
t.Errorf("title = %q, want it preserved", req.Title)
}
if w.MBID != "abc-123" {
t.Errorf("mbid = %q, want normalized", w.MBID)
if req.MBID != "abc-123" {
t.Errorf("mbid = %q, want normalized", req.MBID)
}
}
func TestWantStoreLifecycle(t *testing.T) {
func TestRequestStoreLifecycle(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
ctx := context.Background()
id, err := f.store.AddWant(ctx, Want{
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
@@ -268,17 +268,17 @@ func TestWantStoreLifecycle(t *testing.T) {
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
// A brand new want is due immediately.
due, err := f.store.ListDueWants(ctx, 10)
due, err := f.store.ListDueRequests(ctx, 10)
if err != nil {
t.Fatalf("ListDueWants: %v", err)
t.Fatalf("ListDueRequests: %v", err)
}
if len(due) != 1 {
t.Fatalf("got %d due wants, want 1", len(due))
t.Fatalf("got %d due requests, want 1", len(due))
}
// Recording an attempt pushes it out of the due set without
@@ -287,92 +287,92 @@ func TestWantStoreLifecycle(t *testing.T) {
t.Fatalf("RecordAttempt: %v", err)
}
due, err = f.store.ListDueWants(ctx, 10)
due, err = f.store.ListDueRequests(ctx, 10)
if err != nil {
t.Fatalf("ListDueWants after attempt: %v", err)
t.Fatalf("ListDueRequests after attempt: %v", err)
}
if len(due) != 0 {
t.Errorf("got %d due wants after an attempt, want 0", len(due))
t.Errorf("got %d due requests after an attempt, want 0", len(due))
}
w, err := f.store.GetWant(ctx, id)
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetWant: %v", err)
t.Fatalf("GetRequest: %v", err)
}
if w.State != WantStateWanted {
t.Errorf("state = %q, want it still wanted", w.State)
if req.State != RequestStateWanted {
t.Errorf("state = %q, want it still wanted", req.State)
}
if w.Attempts != 1 {
t.Errorf("attempts = %d, want 1", w.Attempts)
if req.Attempts != 1 {
t.Errorf("attempts = %d, want 1", req.Attempts)
}
if w.LastError == "" {
if req.LastError == "" {
t.Error("last error was not recorded")
}
if err := f.store.SatisfyWant(ctx, id); err != nil {
t.Fatalf("SatisfyWant: %v", err)
if err := f.store.SatisfyRequest(ctx, id); err != nil {
t.Fatalf("SatisfyRequest: %v", err)
}
w, err = f.store.GetWant(ctx, id)
req, err = f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetWant after satisfy: %v", err)
t.Fatalf("GetRequest after satisfy: %v", err)
}
if w.State != WantStateSatisfied {
t.Errorf("state = %q, want satisfied", w.State)
if req.State != RequestStateSatisfied {
t.Errorf("state = %q, want satisfied", req.State)
}
}
// Removing an artist subscription takes the albums it derived with it,
// so a user who unsubscribes does not keep downloading that artist.
func TestDeleteArtistWantCascadesToChildren(t *testing.T) {
func TestDeleteArtistRequestCascadesToChildren(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
ctx := context.Background()
artist, err := f.store.AddWant(ctx, Want{
artist, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Artist: "Radiohead",
})
if err != nil {
t.Fatalf("AddWant artist: %v", err)
t.Fatalf("AddRequest artist: %v", err)
}
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
ParentID: artist,
}); err != nil {
t.Fatalf("AddWant child: %v", err)
t.Fatalf("AddRequest child: %v", err)
}
children, err := f.store.ListChildWants(ctx, artist)
children, err := f.store.ListChildRequests(ctx, artist)
if err != nil {
t.Fatalf("ListChildWants: %v", err)
t.Fatalf("ListChildRequests: %v", err)
}
if len(children) != 1 {
t.Fatalf("got %d children, want 1", len(children))
}
if err := f.store.DeleteWant(ctx, artist); err != nil {
t.Fatalf("DeleteWant: %v", err)
if err := f.store.DeleteRequest(ctx, artist); err != nil {
t.Fatalf("DeleteRequest: %v", err)
}
all, err := f.store.ListWants(ctx)
all, err := f.store.ListRequests(ctx)
if err != nil {
t.Fatalf("ListWants: %v", err)
t.Fatalf("ListRequests: %v", err)
}
if len(all) != 0 {
t.Errorf("got %d wants after deleting the artist, want 0", len(all))
t.Errorf("got %d requests after deleting the artist, want 0", len(all))
}
}
+309
View File
@@ -0,0 +1,309 @@
package download
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"yellowjacket/backend/database/sql/sqlcgen"
)
// The durable request list's persistence. Kept apart from the
// download/item storage in store.go because the two have opposite
// lifetimes: downloads and items are written constantly and swept,
// requests are written rarely and kept.
// defaultDueBatch bounds how many requests one reconcile pass picks up.
// The list can be thousands of rows after a discography backfill, and a
// pass that tried to search all of them would take a day and annoy
// every provider on the way.
const defaultDueBatch = 25
// AddRequest inserts a request, or returns the existing row's ID if the
// same MBID is already requested in this library. Asking twice is not
// two requests, and re-asking must not reset a backoff that is
// deliberately long.
func (s *Store) AddRequest(ctx context.Context, r Request) (int64, error) {
if !r.Entity.Valid() {
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, r.Entity)
}
if r.Scope == "" {
r.Scope = ScopeFuture
}
// The MBID is the identity of a request, so it is normalized here
// rather than at each call site: the same identifier arriving from
// an Explore page and from a pasted URL must be one row, or the
// uniqueness constraint that makes artist expansion idempotent
// stops holding.
r.MBID = strings.ToLower(strings.TrimSpace(r.MBID))
if r.MBID == "" {
return 0, fmt.Errorf("%w: a request needs an MBID", ErrUnsupported)
}
parent := sql.NullInt64{}
if r.ParentID != 0 {
parent = sql.NullInt64{Int64: r.ParentID, Valid: true}
}
id, err := s.db.Queries.UpsertDownloadRequest(
ctx,
sqlcgen.UpsertDownloadRequestParams{
Mbid: r.MBID,
Entity: string(r.Entity),
LibraryID: r.LibraryID,
Artist: r.Artist,
Title: r.Title,
Scope: string(r.Scope),
Secondary: boolToInt(r.Secondary),
ParentID: parent,
},
)
if err != nil {
return 0, fmt.Errorf("add download request: %w", err)
}
return id, nil
}
// GetRequest loads one request.
func (s *Store) GetRequest(ctx context.Context, id int64) (Request, error) {
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Request{}, fmt.Errorf("%w: request %d", ErrNotFound, id)
}
return Request{}, fmt.Errorf("get download request: %w", err)
}
return requestRowToRequest(row), nil
}
// FindRequest looks a request up by what it names rather than by row
// ID, which is how callers holding an MBID (the Explore pages, a
// provider sync) ask "is this already requested?".
func (s *Store) FindRequest(
ctx context.Context,
mbid string,
libraryID int64,
) (Request, bool, error) {
row, err := s.db.ReadQueries.GetDownloadRequestByMBID(
ctx,
sqlcgen.GetDownloadRequestByMBIDParams{Mbid: mbid, LibraryID: libraryID},
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Request{}, false, nil
}
return Request{}, false, fmt.Errorf("find download request: %w", err)
}
return requestRowToRequest(row), true, nil
}
// ListRequests returns the whole durable request list, active first.
func (s *Store) ListRequests(ctx context.Context) ([]Request, error) {
rows, err := s.db.ReadQueries.ListDownloadRequests(ctx)
if err != nil {
return nil, fmt.Errorf("list download requests: %w", err)
}
return requestRowsToRequests(rows), nil
}
// ListActiveRequests returns active artist subscriptions, which are
// what the reconciler expands.
func (s *Store) ListActiveRequests(ctx context.Context) ([]Request, error) {
rows, err := s.db.ReadQueries.ListDownloadRequestsByEntity(
ctx,
sqlcgen.ListDownloadRequestsByEntityParams{
Entity: string(EntityArtist),
State: string(RequestStateWanted),
},
)
if err != nil {
return nil, fmt.Errorf("list artist requests: %w", err)
}
return requestRowsToRequests(rows), nil
}
// ListDueRequests returns downloadable requests whose backoff has
// elapsed, least-attempted first so a new addition is not stuck behind
// a hundred long-shot retries.
func (s *Store) ListDueRequests(ctx context.Context, limit int) ([]Request, error) {
if limit <= 0 {
limit = defaultDueBatch
}
rows, err := s.db.ReadQueries.ListDueDownloadRequests(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("list due download requests: %w", err)
}
return requestRowsToRequests(rows), nil
}
// ListChildRequests returns the requests an artist subscription
// produced.
func (s *Store) ListChildRequests(
ctx context.Context,
parentID int64,
) ([]Request, error) {
rows, err := s.db.ReadQueries.ListChildDownloadRequests(
ctx,
sql.NullInt64{Int64: parentID, Valid: true},
)
if err != nil {
return nil, fmt.Errorf("list child download requests: %w", err)
}
return requestRowsToRequests(rows), nil
}
// SetRequestState moves a request between wanted, paused and satisfied.
func (s *Store) SetRequestState(
ctx context.Context,
id int64,
state RequestState,
errText string,
) error {
if err := s.db.Queries.SetDownloadRequestState(
ctx,
sqlcgen.SetDownloadRequestStateParams{
State: string(state),
LastError: errText,
ID: id,
},
); err != nil {
return fmt.Errorf("set download request state: %w", err)
}
return nil
}
// RecordAttempt notes an unsuccessful pass over a request and schedules
// the next one. The request stays wanted: not finding something is a
// fact about today's providers, not a verdict on the request.
func (s *Store) RecordAttempt(
ctx context.Context,
id int64,
attempts int,
reason string,
) error {
next := nextRetry(time.Now(), attempts+1)
if err := s.db.Queries.RecordDownloadRequestAttempt(
ctx,
sqlcgen.RecordDownloadRequestAttemptParams{
LastError: reason,
NextTryAt: sql.NullTime{Time: next, Valid: true},
ID: id,
},
); err != nil {
return fmt.Errorf("record download request attempt: %w", err)
}
return nil
}
// SatisfyRequest marks a request as owned.
func (s *Store) SatisfyRequest(ctx context.Context, id int64) error {
if err := s.db.Queries.SatisfyDownloadRequest(ctx, id); err != nil {
return fmt.Errorf("satisfy download request: %w", err)
}
return nil
}
// SetRequestExternalIDs records the identifiers external managers gave
// this request in their own persistent lists.
func (s *Store) SetRequestExternalIDs(
ctx context.Context,
id int64,
ids map[string]string,
) error {
encoded, err := json.Marshal(ids)
if err != nil {
return fmt.Errorf("encode request external ids: %w", err)
}
if err := s.db.Queries.SetDownloadRequestExternalIDs(
ctx,
sqlcgen.SetDownloadRequestExternalIDsParams{
ExternalIds: string(encoded),
ID: id,
},
); err != nil {
return fmt.Errorf("set request external ids: %w", err)
}
return nil
}
// DeleteRequest removes a request and, by cascade, anything an artist
// request derived.
func (s *Store) DeleteRequest(ctx context.Context, id int64) error {
if err := s.db.Queries.DeleteDownloadRequest(ctx, id); err != nil {
return fmt.Errorf("delete download request: %w", err)
}
return nil
}
// ClearSatisfiedRequests drops everything already owned.
func (s *Store) ClearSatisfiedRequests(ctx context.Context) error {
if err := s.db.Queries.DeleteSatisfiedDownloadRequests(ctx); err != nil {
return fmt.Errorf("clear satisfied download requests: %w", err)
}
return nil
}
// requestRowsToRequests decodes a slice of stored rows.
func requestRowsToRequests(rows []sqlcgen.DownloadRequest) []Request {
out := make([]Request, 0, len(rows))
for _, r := range rows {
out = append(out, requestRowToRequest(r))
}
return out
}
// requestRowToRequest decodes a stored request row. A malformed
// external-ID blob yields an empty map rather than an error: losing the
// link to a Lidarr row is recoverable on the next sync, making the
// request list unreadable is not.
func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
external := map[string]string{}
_ = json.Unmarshal([]byte(r.ExternalIds), &external)
return Request{
ID: r.ID,
MBID: r.Mbid,
Entity: Entity(r.Entity),
LibraryID: r.LibraryID,
Artist: r.Artist,
Title: r.Title,
Scope: RequestScope(r.Scope),
Secondary: r.Secondary != 0,
State: RequestState(r.State),
ParentID: r.ParentID.Int64,
Attempts: int(r.Attempts),
LastError: r.LastError,
LastTriedAt: r.LastTriedAt.Time,
NextTryAt: r.NextTryAt.Time,
ExternalIDs: external,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
+166 -96
View File
@@ -12,10 +12,10 @@ import (
"yellowjacket/backend/events"
)
// ErrNoLibrary means the request did not name a library to attach the
// download to. Letting it through would hit the download_requests /
// download_wants foreign key on library_id and surface as a raw SQLite
// error, so it is rejected here with a message the UI can show.
// ErrNoLibrary means the caller did not name a library to attach the
// download to. Letting it through would hit the download_downloads /
// download_requests foreign key on library_id and surface as a raw
// SQLite error, so it is rejected here with a message the UI can show.
var ErrNoLibrary = errors.New("no library selected")
// Service is the frontend-facing surface of the download subsystem.
@@ -258,8 +258,20 @@ func (s *Service) TestProvider(id int64) error {
return nil
}
// SetPreferences pushes the auto-download guardrails straight into the
// running Manager, without persisting them. Persistence is
// config.Config's job (GetDownloadPreferences/SetDownloadPreferences);
// this package cannot depend on config, since config already depends on
// download for UserConfig. The frontend settings save is expected to
// call the config setter and this method in the same action, the way
// UpdateProvider already achieves "live without a restart" by touching
// storage and the running Manager together.
func (s *Service) SetPreferences(prefs AutoDownloadPrefs) {
s.manager.SetPreferences(prefs)
}
// ---------------------------------------------------------------------------
// Requests
// Downloads
// ---------------------------------------------------------------------------
// SearchRequest is what the frontend submits to start a download.
@@ -275,7 +287,7 @@ type SearchRequest struct {
// StartResult is what the picker needs after a search.
type StartResult struct {
RequestID string `json:"requestId"`
DownloadID string `json:"downloadId"`
Candidates []Candidate `json:"candidates"`
// AutoPicked reports that the pipeline already chose and is
@@ -284,14 +296,21 @@ type StartResult struct {
AutoPicked bool `json:"autoPicked"`
}
// Start searches for a release and either auto-picks a clear winner or
// returns ranked candidates for the user to choose from.
func (s *Service) Start(req SearchRequest) (StartResult, error) {
// StartDownload searches for a release and either auto-picks a clear
// winner or returns ranked candidates for the user to choose from.
//
// When the search carries a MusicBrainz anchor, it also resolves or
// creates the durable Request the anchor names and attaches it, via
// ensureRequest — so a manual download that fails or finds nothing
// right now leaves a durable record behind instead of just vanishing,
// and the reconciler picks it up on its normal schedule exactly as if
// the user had explicitly added it to the request list.
func (s *Service) StartDownload(req SearchRequest) (StartResult, error) {
if req.LibraryID <= 0 {
return StartResult{}, ErrNoLibrary
}
r := Request{
dl := Download{
ID: newID(),
LibraryID: req.LibraryID,
ReleaseMBID: req.ReleaseMBID,
@@ -302,15 +321,19 @@ func (s *Service) Start(req SearchRequest) (StartResult, error) {
Expected: req.Expected,
}
candidates, err := s.manager.Start(context.Background(), r)
if id, ok := s.ensureRequest(dl); ok {
dl.RequestID = id
}
candidates, err := s.manager.Start(context.Background(), dl)
if err != nil {
return StartResult{}, err
}
result := StartResult{
RequestID: r.ID,
DownloadID: dl.ID,
Candidates: candidates,
AutoPicked: AutoPickable(r, candidates),
AutoPicked: s.manager.AutoPickable(dl, candidates),
}
s.emit(events.DownloadsChanged)
@@ -318,10 +341,51 @@ func (s *Service) Start(req SearchRequest) (StartResult, error) {
return result, nil
}
// ensureRequest resolves or creates the durable Request an anchored
// manual download should be attached to. Free-text downloads (no
// MBID) have nothing stable to attach to and are left alone.
//
// AddRequest already treats "asking twice" as one request and never
// resets backoff or un-pauses a paused request on conflict, so a
// manual download on something already paused still runs its one
// interactive attempt now without disturbing the request's state.
func (s *Service) ensureRequest(d Download) (int64, bool) {
var (
entity Entity
mbid string
)
switch {
case d.ReleaseMBID != "":
entity, mbid = EntityRelease, d.ReleaseMBID
case d.ReleaseGroupMBID != "":
entity, mbid = EntityReleaseGroup, d.ReleaseGroupMBID
case d.RecordingMBID != "":
entity, mbid = EntityRecording, d.RecordingMBID
default:
return 0, false
}
id, err := s.store.AddRequest(context.Background(), Request{
MBID: mbid,
Entity: entity,
LibraryID: d.LibraryID,
Artist: d.Artist,
Title: d.Album,
})
if err != nil {
s.logger.Warn("could not attach request to manual download", "error", err)
return 0, false
}
return id, true
}
// Pick starts the transfer for the candidate the user chose.
func (s *Service) Pick(requestID, candidateID string) error {
func (s *Service) Pick(downloadID, candidateID string) error {
if err := s.manager.Pick(
context.Background(), requestID, candidateID,
context.Background(), downloadID, candidateID,
); err != nil {
return err
}
@@ -331,9 +395,9 @@ func (s *Service) Pick(requestID, candidateID string) error {
return nil
}
// Cancel aborts a live request.
func (s *Service) Cancel(requestID string) error {
if err := s.manager.Cancel(context.Background(), requestID); err != nil {
// Cancel aborts a live download.
func (s *Service) Cancel(downloadID string) error {
if err := s.manager.Cancel(context.Background(), downloadID); err != nil {
return err
}
@@ -342,23 +406,28 @@ func (s *Service) Cancel(requestID string) error {
return nil
}
// Candidates returns the ranked candidates of a live request, so the
// Candidates returns the ranked candidates of a live download, so the
// picker can be reopened without searching again.
func (s *Service) Candidates(requestID string) []Candidate {
return s.manager.Candidates(requestID)
func (s *Service) Candidates(downloadID string) []Candidate {
return s.manager.Candidates(downloadID)
}
// RequestView is one row of the downloads list.
type RequestView struct {
Request
// DownloadView is one row of the downloads list.
//
// would stop reading as "one row of the Downloads list" the moment this
// package also has a Requests list — see RequestInput/Request nearby.
//
//nolint:revive // stutters as download.DownloadView, but a bare "View"
type DownloadView struct {
Download
State State `json:"state"`
Error string `json:"error,omitempty"`
Items []Item `json:"items"`
State State `json:"state"`
Error string `json:"error,omitempty"`
Items []DownloadItem `json:"items"`
}
// ListRequests returns recent download requests, newest first.
func (s *Service) ListRequests(limit int) ([]RequestView, error) {
// ListDownloads returns recent downloads, newest first.
func (s *Service) ListDownloads(limit int) ([]DownloadView, error) {
const defaultLimit = 50
if limit <= 0 {
@@ -367,36 +436,36 @@ func (s *Service) ListRequests(limit int) ([]RequestView, error) {
ctx := context.Background()
requests, err := s.store.ListRequests(ctx, limit)
downloads, err := s.store.ListDownloads(ctx, limit)
if err != nil {
return nil, err
}
out := make([]RequestView, 0, len(requests))
out := make([]DownloadView, 0, len(downloads))
for _, r := range requests {
state, errText, err := s.store.GetRequestState(ctx, r.ID)
for _, d := range downloads {
state, errText, err := s.store.GetDownloadState(ctx, d.ID)
if err != nil {
return nil, err
}
items, err := s.store.ListItemsForRequest(ctx, r.ID)
items, err := s.store.ListItemsForDownload(ctx, d.ID)
if err != nil {
return nil, err
}
out = append(out, RequestView{
Request: r,
State: state,
Error: errText,
Items: items,
out = append(out, DownloadView{
Download: d,
State: state,
Error: errText,
Items: items,
})
}
return out, nil
}
// ClearFinished removes terminal requests from the list.
// ClearFinished removes terminal downloads from the list.
func (s *Service) ClearFinished() error {
if err := s.store.ClearFinished(context.Background()); err != nil {
return err
@@ -408,19 +477,20 @@ func (s *Service) ClearFinished() error {
}
// ---------------------------------------------------------------------------
// Wanted list
// Request list
// ---------------------------------------------------------------------------
// SetReconciler wires the wanted-list loop. Optional: without it the
// wanted list still stores and lists wants, it just never acts on them.
// SetReconciler wires the request-list loop. Optional: without it the
// request list still stores and lists requests, it just never acts on
// them.
func (s *Service) SetReconciler(r *Reconciler) {
s.reconciler = r
}
// WantRequest is what the frontend submits to want something. It is
// one MBID and the type of thing it names, because that is genuinely
// all a want is.
type WantRequest struct {
// RequestInput is what the frontend submits to request something. It
// is one MBID and the type of thing it names, because that is
// genuinely all a durable request is.
type RequestInput struct {
MBID string `json:"mbid"`
Entity string `json:"entity"`
LibraryID int64 `json:"libraryId"`
@@ -431,15 +501,15 @@ type WantRequest struct {
Artist string `json:"artist"`
Title string `json:"title"`
// Scope and Secondary apply to artist wants.
// Scope and Secondary apply to artist requests.
Scope string `json:"scope"`
Secondary bool `json:"secondary"`
}
// AddWant puts something on the wanted list and asks for a reconcile
// pass, so the user sees something happen rather than waiting six hours
// for the next scheduled one.
func (s *Service) AddWant(req WantRequest) (int64, error) {
// AddRequest puts something on the request list and asks for a
// reconcile pass, so the user sees something happen rather than
// waiting six hours for the next scheduled one.
func (s *Service) AddRequest(req RequestInput) (int64, error) {
if req.LibraryID <= 0 {
return 0, ErrNoLibrary
}
@@ -449,12 +519,12 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, req.Entity)
}
scope := WantScope(req.Scope)
scope := RequestScope(req.Scope)
if scope != ScopeAll {
scope = ScopeFuture
}
id, err := s.store.AddWant(context.Background(), Want{
id, err := s.store.AddRequest(context.Background(), Request{
MBID: req.MBID,
Entity: entity,
LibraryID: req.LibraryID,
@@ -467,7 +537,7 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
return 0, err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
if s.reconciler != nil {
s.reconciler.Trigger()
@@ -476,73 +546,73 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
return id, nil
}
// ListWants returns the whole wanted list.
func (s *Service) ListWants() ([]Want, error) {
return s.store.ListWants(context.Background())
// ListRequests returns the whole durable request list.
func (s *Service) ListRequests() ([]Request, error) {
return s.store.ListRequests(context.Background())
}
// IsWanted answers the Explore pages' question — should this album show
// "want" or "wanted?" — without making them load the whole list.
func (s *Service) IsWanted(mbid string, libraryID int64) (bool, error) {
_, found, err := s.store.FindWant(context.Background(), mbid, libraryID)
// IsRequested answers the Explore pages' question — should this album
// show "want" or "wanted?" — without making them load the whole list.
func (s *Service) IsRequested(mbid string, libraryID int64) (bool, error) {
_, found, err := s.store.FindRequest(context.Background(), mbid, libraryID)
return found, err
}
// RemoveWant takes something off the list. Removing an artist takes
// RemoveRequest takes something off the list. Removing an artist takes
// its derived albums with it, by cascade; an album the user pinned
// themselves has no parent and survives.
func (s *Service) RemoveWant(id int64) error {
func (s *Service) RemoveRequest(id int64) error {
ctx := context.Background()
// Tell any external list first, while the row is still readable.
s.withdrawExternal(ctx, id)
if err := s.store.DeleteWant(ctx, id); err != nil {
if err := s.store.DeleteRequest(ctx, id); err != nil {
return err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return nil
}
// PauseWant stops attempts without forgetting the want.
func (s *Service) PauseWant(id int64, paused bool) error {
state := WantStateWanted
// PauseRequest stops attempts without forgetting the request.
func (s *Service) PauseRequest(id int64, paused bool) error {
state := RequestStateWanted
if paused {
state = WantStatePaused
state = RequestStatePaused
}
if err := s.store.SetWantState(
if err := s.store.SetRequestState(
context.Background(), id, state, "",
); err != nil {
return err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return nil
}
// ClearSatisfiedWants drops everything already owned.
func (s *Service) ClearSatisfiedWants() error {
if err := s.store.ClearSatisfiedWants(context.Background()); err != nil {
// ClearSatisfiedRequests drops everything already owned.
func (s *Service) ClearSatisfiedRequests() error {
if err := s.store.ClearSatisfiedRequests(context.Background()); err != nil {
return err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return nil
}
// ReconcileWanted runs a pass now and reports what it did. This backs
// the "check now" button, so it runs synchronously: the user pressed it
// and is waiting for an answer.
func (s *Service) ReconcileWanted() (Summary, error) {
// ReconcileRequests runs a pass now and reports what it did. This
// backs the "check now" button, so it runs synchronously: the user
// pressed it and is waiting for an answer.
func (s *Service) ReconcileRequests() (Summary, error) {
if s.reconciler == nil {
return Summary{}, fmt.Errorf(
"%w: the wanted list is not running", ErrUnsupported,
"%w: the request list is not running", ErrUnsupported,
)
}
@@ -551,20 +621,20 @@ func (s *Service) ReconcileWanted() (Summary, error) {
return summary, err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return summary, nil
}
// ImportExternalWants adopts a provider's own list — "import the
// ImportExternalRequests adopts a provider's own list — "import the
// artists Lidarr is already monitoring".
func (s *Service) ImportExternalWants(
func (s *Service) ImportExternalRequests(
providerID int64,
libraryID int64,
) (int, error) {
if s.reconciler == nil {
return 0, fmt.Errorf(
"%w: the wanted list is not running", ErrUnsupported,
"%w: the request list is not running", ErrUnsupported,
)
}
@@ -575,22 +645,22 @@ func (s *Service) ImportExternalWants(
return 0, err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return n, nil
}
// withdrawExternal best-effort unmonitors a want in the external lists
// it was pushed to. Failures are logged and ignored: the user asked to
// remove it from *this* list, and an unreachable Lidarr is not a reason
// to refuse.
// withdrawExternal best-effort unmonitors a request in the external
// lists it was pushed to. Failures are logged and ignored: the user
// asked to remove it from *this* list, and an unreachable Lidarr is not
// a reason to refuse.
func (s *Service) withdrawExternal(ctx context.Context, id int64) {
w, err := s.store.GetWant(ctx, id)
if err != nil || len(w.ExternalIDs) == 0 {
r, err := s.store.GetRequest(ctx, id)
if err != nil || len(r.ExternalIDs) == 0 {
return
}
for key, externalID := range w.ExternalIDs {
for key, externalID := range r.ExternalIDs {
providerID, err := strconv.ParseInt(key, 10, 64)
if err != nil {
continue
@@ -601,10 +671,10 @@ func (s *Service) withdrawExternal(ctx context.Context, id int64) {
continue
}
if err := l.RemoveWant(ctx, externalID); err != nil {
if err := l.RemoveRequest(ctx, externalID); err != nil {
s.logger.Debug(
"could not withdraw want from external list",
"want", id,
"could not withdraw request from external list",
"request", id,
"provider", providerID,
"error", err,
)
+188
View File
@@ -0,0 +1,188 @@
package download
import (
"context"
"testing"
)
// newServiceFixture wires a Service over the same manager/store a
// managerFixture uses, so a manual download can be started and watched
// through to completion with no network anywhere.
type serviceFixture struct {
managerFixture
svc *Service
}
func newServiceFixture(t *testing.T) serviceFixture {
t.Helper()
mf := newManagerFixture(t)
svc := NewService(slogDiscard(), mf.manager, mf.store, NewMemSecretStore())
return serviceFixture{managerFixture: mf, svc: svc}
}
// A manual download for something anchored by MBID must leave a
// durable Request behind, whether or not the download itself succeeds
// — that is the whole point of ensureRequest: a manual attempt that
// finds nothing right now is not just lost, the reconciler picks it up
// later on its normal schedule.
func TestStartDownloadCreatesRequestForAnchoredDownload(t *testing.T) {
t.Parallel()
f := newServiceFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
dl := fourTrackDownload()
if _, err := f.svc.StartDownload(SearchRequest{
LibraryID: 1,
ReleaseGroupMBID: "rg-1",
Artist: dl.Artist,
Album: dl.Album,
Expected: dl.Expected,
}); err != nil {
t.Fatalf("StartDownload: %v", err)
}
req, found, err := f.store.FindRequest(ctx, "rg-1", 1)
if err != nil {
t.Fatalf("FindRequest: %v", err)
}
if !found {
t.Fatal("manual anchored download did not create a durable request")
}
if req.Entity != EntityReleaseGroup {
t.Errorf("entity = %q, want release-group", req.Entity)
}
if req.State != RequestStateWanted {
t.Errorf("state = %q, want wanted", req.State)
}
}
// A free-text download (no MBID) has nothing stable to attach a
// request to, and must not create one.
func TestStartDownloadFreeTextCreatesNoRequest(t *testing.T) {
t.Parallel()
f := newServiceFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
if _, err := f.svc.StartDownload(SearchRequest{
LibraryID: 1,
Query: "some free text search",
}); err != nil {
t.Fatalf("StartDownload: %v", err)
}
all, err := f.store.ListRequests(ctx)
if err != nil {
t.Fatalf("ListRequests: %v", err)
}
if len(all) != 0 {
t.Errorf("free-text download created %d requests, want 0", len(all))
}
}
// A manual download must not un-pause a request the user deliberately
// paused: it runs its one interactive attempt regardless, but the
// request's own state is left alone.
func TestStartDownloadDoesNotUnpauseExistingRequest(t *testing.T) {
t.Parallel()
f := newServiceFixture(t)
ctx := context.Background()
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddRequest: %v", err)
}
if err := f.store.SetRequestState(
ctx, id, RequestStatePaused, "",
); err != nil {
t.Fatalf("SetRequestState: %v", err)
}
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
dl := fourTrackDownload()
if _, err := f.svc.StartDownload(SearchRequest{
LibraryID: 1,
ReleaseGroupMBID: "rg-1",
Artist: dl.Artist,
Album: dl.Album,
Expected: dl.Expected,
}); err != nil {
t.Fatalf("StartDownload: %v", err)
}
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if req.State != RequestStatePaused {
t.Errorf(
"a manual download un-paused the request: state = %q, want paused",
req.State,
)
}
}
// A manual download that clearly wins auto-pick still satisfies the
// durable request it was attached to when it completes — the same
// SatisfyRequest call the reconciler relies on.
func TestManualDownloadSatisfiesRequestOnSuccess(t *testing.T) {
t.Parallel()
f := newServiceFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
dl := fourTrackDownload()
result, err := f.svc.StartDownload(SearchRequest{
LibraryID: 1,
ReleaseGroupMBID: "rg-1",
Artist: dl.Artist,
Album: dl.Album,
Expected: dl.Expected,
})
if err != nil {
t.Fatalf("StartDownload: %v", err)
}
if !result.AutoPicked {
t.Fatal("expected a clear single-provider winner to auto-pick")
}
waitForDownloadState(t, f.store, result.DownloadID, StateComplete)
waitFor(t, func() bool {
req, found, err := f.store.FindRequest(ctx, "rg-1", 1)
return err == nil && found && req.State == RequestStateSatisfied
}, "request was never satisfied after its manual download completed")
}
+80 -73
View File
@@ -137,148 +137,148 @@ func providerRowToConfig(r sqlcgen.DownloadProvider) Config {
}
// ---------------------------------------------------------------------------
// Requests
// Downloads
// ---------------------------------------------------------------------------
// CreateRequest persists a new request.
func (s *Store) CreateRequest(ctx context.Context, req Request) error {
expected, err := json.Marshal(req.Expected)
// CreateDownload persists a new download.
func (s *Store) CreateDownload(ctx context.Context, dl Download) error {
expected, err := json.Marshal(dl.Expected)
if err != nil {
return fmt.Errorf("encode expected tracks: %w", err)
}
source := req.Source
source := dl.Source
if source == "" {
source = "manual"
}
wantID := sql.NullInt64{}
if req.WantID != 0 {
wantID = sql.NullInt64{Int64: req.WantID, Valid: true}
requestID := sql.NullInt64{}
if dl.RequestID != 0 {
requestID = sql.NullInt64{Int64: dl.RequestID, Valid: true}
}
if err := s.db.Queries.CreateDownloadRequest(
if err := s.db.Queries.CreateDownload(
ctx,
sqlcgen.CreateDownloadRequestParams{
ID: req.ID,
LibraryID: req.LibraryID,
sqlcgen.CreateDownloadParams{
ID: dl.ID,
LibraryID: dl.LibraryID,
Source: source,
WantID: wantID,
ReleaseMbid: toNullString(req.ReleaseMBID),
ReleaseGroupMbid: toNullString(req.ReleaseGroupMBID),
RecordingMbid: toNullString(req.RecordingMBID),
Artist: req.Artist,
Album: req.Album,
Query: req.Query,
RequestID: requestID,
ReleaseMbid: toNullString(dl.ReleaseMBID),
ReleaseGroupMbid: toNullString(dl.ReleaseGroupMBID),
RecordingMbid: toNullString(dl.RecordingMBID),
Artist: dl.Artist,
Album: dl.Album,
Query: dl.Query,
Expected: string(expected),
State: string(StateSearching),
},
); err != nil {
return fmt.Errorf("create download request: %w", err)
return fmt.Errorf("create download: %w", err)
}
return nil
}
// GetRequest loads a request by ID.
func (s *Store) GetRequest(ctx context.Context, id string) (Request, error) {
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
// GetDownload loads a download by ID.
func (s *Store) GetDownload(ctx context.Context, id string) (Download, error) {
row, err := s.db.ReadQueries.GetDownload(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Request{}, fmt.Errorf("%w: request %s", ErrNotFound, id)
return Download{}, fmt.Errorf("%w: download %s", ErrNotFound, id)
}
return Request{}, fmt.Errorf("get download request: %w", err)
return Download{}, fmt.Errorf("get download: %w", err)
}
return requestRowToRequest(row), nil
return downloadRowToDownload(row), nil
}
// GetRequestState returns a request's current state and error text.
// Kept separate from GetRequest because state is the one field that
// GetDownloadState returns a download's current state and error text.
// Kept separate from GetDownload because state is the one field that
// changes constantly while the rest of the row is immutable.
func (s *Store) GetRequestState(
func (s *Store) GetDownloadState(
ctx context.Context,
id string,
) (State, string, error) {
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
row, err := s.db.ReadQueries.GetDownload(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", "", fmt.Errorf("%w: request %s", ErrNotFound, id)
return "", "", fmt.Errorf("%w: download %s", ErrNotFound, id)
}
return "", "", fmt.Errorf("get download request state: %w", err)
return "", "", fmt.Errorf("get download state: %w", err)
}
return State(row.State), row.Error, nil
}
// ListRequests returns the most recent requests, newest first.
func (s *Store) ListRequests(ctx context.Context, limit int) ([]Request, error) {
rows, err := s.db.ReadQueries.ListDownloadRequests(ctx, int64(limit))
// ListDownloads returns the most recent downloads, newest first.
func (s *Store) ListDownloads(ctx context.Context, limit int) ([]Download, error) {
rows, err := s.db.ReadQueries.ListDownloads(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("list download requests: %w", err)
return nil, fmt.Errorf("list downloads: %w", err)
}
out := make([]Request, 0, len(rows))
out := make([]Download, 0, len(rows))
for _, r := range rows {
out = append(out, requestRowToRequest(r))
out = append(out, downloadRowToDownload(r))
}
return out, nil
}
// SetRequestState updates a request's state and error text.
func (s *Store) SetRequestState(
// SetDownloadState updates a download's state and error text.
func (s *Store) SetDownloadState(
ctx context.Context,
id string,
state State,
errText string,
) error {
if err := s.db.Queries.SetDownloadRequestState(
if err := s.db.Queries.SetDownloadState(
ctx,
sqlcgen.SetDownloadRequestStateParams{
sqlcgen.SetDownloadStateParams{
State: string(state),
Error: errText,
ID: id,
},
); err != nil {
return fmt.Errorf("set download request state: %w", err)
return fmt.Errorf("set download state: %w", err)
}
return nil
}
// DeleteRequest removes a request and, by cascade, its items.
func (s *Store) DeleteRequest(ctx context.Context, id string) error {
if err := s.db.Queries.DeleteDownloadRequest(ctx, id); err != nil {
return fmt.Errorf("delete download request: %w", err)
// DeleteDownload removes a download and, by cascade, its items.
func (s *Store) DeleteDownload(ctx context.Context, id string) error {
if err := s.db.Queries.DeleteDownload(ctx, id); err != nil {
return fmt.Errorf("delete download: %w", err)
}
return nil
}
// ClearFinished removes every terminal request.
// ClearFinished removes every terminal download.
func (s *Store) ClearFinished(ctx context.Context) error {
if err := s.db.Queries.DeleteFinishedDownloadRequests(ctx); err != nil {
return fmt.Errorf("clear finished download requests: %w", err)
if err := s.db.Queries.DeleteFinishedDownloads(ctx); err != nil {
return fmt.Errorf("clear finished downloads: %w", err)
}
return nil
}
// requestRowToRequest decodes a stored request row.
func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
// downloadRowToDownload decodes a stored download row.
func downloadRowToDownload(r sqlcgen.DownloadDownload) Download {
var expected []ExpectedTrack
_ = json.Unmarshal([]byte(r.Expected), &expected)
return Request{
return Download{
ID: r.ID,
LibraryID: r.LibraryID,
Source: r.Source,
WantID: r.WantID.Int64,
RequestID: r.RequestID.Int64,
ReleaseMBID: r.ReleaseMbid.String,
ReleaseGroupMBID: r.ReleaseGroupMbid.String,
RecordingMBID: r.RecordingMbid.String,
@@ -294,10 +294,16 @@ func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
// Items
// ---------------------------------------------------------------------------
// Item is one grab attempt, as stored.
type Item struct {
// DownloadItem is one grab attempt, as stored.
//
// deliberate: distinguishes it from download.Download (the attempt) and
// download.Request (the durable record) at every call site, which a bare
// "Item" would not.
//
//nolint:revive // stutters as download.DownloadItem, but the name is
type DownloadItem struct {
ID string `json:"id"`
RequestID string `json:"requestId"`
DownloadID string `json:"downloadId"`
ProviderID int64 `json:"providerId"`
Transport int64 `json:"transportId,omitempty"`
ExternalID string `json:"externalId,omitempty"`
@@ -313,7 +319,7 @@ type Item struct {
}
// CreateItem persists a grab attempt.
func (s *Store) CreateItem(ctx context.Context, item Item) error {
func (s *Store) CreateItem(ctx context.Context, item DownloadItem) error {
candidate, err := json.Marshal(item.Candidate)
if err != nil {
return fmt.Errorf("encode candidate: %w", err)
@@ -328,7 +334,7 @@ func (s *Store) CreateItem(ctx context.Context, item Item) error {
ctx,
sqlcgen.CreateDownloadItemParams{
ID: item.ID,
RequestID: item.RequestID,
DownloadID: item.DownloadID,
ProviderID: item.ProviderID,
TransportID: transport,
ExternalID: item.ExternalID,
@@ -345,30 +351,31 @@ func (s *Store) CreateItem(ctx context.Context, item Item) error {
}
// GetItem loads one item.
func (s *Store) GetItem(ctx context.Context, id string) (Item, error) {
func (s *Store) GetItem(ctx context.Context, id string) (DownloadItem, error) {
row, err := s.db.ReadQueries.GetDownloadItem(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Item{}, fmt.Errorf("%w: item %s", ErrNotFound, id)
return DownloadItem{}, fmt.Errorf("%w: item %s", ErrNotFound, id)
}
return Item{}, fmt.Errorf("get download item: %w", err)
return DownloadItem{}, fmt.Errorf("get download item: %w", err)
}
return itemRowToItem(row), nil
}
// ListItemsForRequest returns a request's grab attempts, oldest first.
func (s *Store) ListItemsForRequest(
// ListItemsForDownload returns a download's grab attempts, oldest
// first.
func (s *Store) ListItemsForDownload(
ctx context.Context,
requestID string,
) ([]Item, error) {
rows, err := s.db.ReadQueries.ListDownloadItemsForRequest(ctx, requestID)
downloadID string,
) ([]DownloadItem, error) {
rows, err := s.db.ReadQueries.ListDownloadItemsForDownload(ctx, downloadID)
if err != nil {
return nil, fmt.Errorf("list download items: %w", err)
}
out := make([]Item, 0, len(rows))
out := make([]DownloadItem, 0, len(rows))
for _, r := range rows {
out = append(out, itemRowToItem(r))
@@ -379,13 +386,13 @@ func (s *Store) ListItemsForRequest(
// ListLiveItems returns every non-terminal item. Called at startup to
// decide what to resume, reconcile or abandon.
func (s *Store) ListLiveItems(ctx context.Context) ([]Item, error) {
func (s *Store) ListLiveItems(ctx context.Context) ([]DownloadItem, error) {
rows, err := s.db.ReadQueries.ListLiveDownloadItems(ctx)
if err != nil {
return nil, fmt.Errorf("list live download items: %w", err)
}
out := make([]Item, 0, len(rows))
out := make([]DownloadItem, 0, len(rows))
for _, r := range rows {
out = append(out, itemRowToItem(r))
@@ -479,7 +486,7 @@ func (s *Store) SetItemImported(
}
// itemRowToItem decodes a stored item row.
func itemRowToItem(r sqlcgen.DownloadItem) Item {
func itemRowToItem(r sqlcgen.DownloadItem) DownloadItem {
var (
candidate Candidate
imported []string
@@ -488,9 +495,9 @@ func itemRowToItem(r sqlcgen.DownloadItem) Item {
_ = json.Unmarshal([]byte(r.Candidate), &candidate)
_ = json.Unmarshal([]byte(r.ImportedPaths), &imported)
return Item{
return DownloadItem{
ID: r.ID,
RequestID: r.RequestID,
DownloadID: r.DownloadID,
ProviderID: r.ProviderID,
Transport: r.TransportID.Int64,
ExternalID: r.ExternalID,
+33 -25
View File
@@ -82,29 +82,36 @@ func (c Caps) Handles(p Protocol) bool {
return slices.Contains(c.Transports, p)
}
// Request is what the user asked for. Requests that carry a MusicBrainz
// anchor are far more reliable than free-text ones, because the anchor
// gives the import step an expected tracklist to match against — so the
// pipeline records which it got and refuses to auto-pick without one.
type Request struct {
// Download is one search-and-grab attempt: it searches, it grabs, it
// succeeds or fails, and then it is history. A Download that carries a
// MusicBrainz anchor is far more reliable than a free-text one, because
// the anchor gives the import step an expected tracklist to match
// against — so the pipeline records which it got and refuses to
// auto-pick without one.
//
// A Download is not the same thing as a Request (request.go): a
// Request is durable and outlives every attempt made on its behalf,
// while a Download is one such attempt and is disposable.
type Download struct {
ID string `json:"id"`
// Anchors. Any may be empty; all empty means free-text.
ReleaseMBID string `json:"releaseMbid,omitempty"`
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
// RecordingMBID anchors a single-track request. Its Expected holds
// exactly that one track, which is what lets a track request be
// RecordingMBID anchors a single-track download. Its Expected holds
// exactly that one track, which is what lets a track download be
// scored — and therefore auto-picked — on the same footing as an
// album.
RecordingMBID string `json:"recordingMbid,omitempty"`
// WantID links back to the wanted-list row this request was raised
// for, or 0 for a request the user started by hand. The reconciler
// writes the outcome back through it.
WantID int64 `json:"wantId,omitempty"`
// RequestID links back to the durable Request row this download was
// raised for or attached to, or 0 for a free-text download with
// nothing stable to attach to. The reconciler and manual anchored
// downloads both write the outcome back through it.
RequestID int64 `json:"requestId,omitempty"`
// Source records where the request came from, for the downloads
// Source records where the download came from, for the downloads
// list. Empty means "manual".
Source string `json:"source,omitempty"`
@@ -116,7 +123,7 @@ type Request struct {
// Expected is the tracklist the anchor resolves to, used for
// completeness scoring and for the autotag match at import. Empty
// for free-text requests.
// for free-text downloads.
Expected []ExpectedTrack `json:"expected,omitempty"`
// LibraryID is the library imported files belong to.
@@ -125,12 +132,12 @@ type Request struct {
CreatedAt time.Time `json:"createdAt"`
}
// Anchored reports whether the request carries a MusicBrainz ID. Only
// anchored requests are eligible for auto-pick.
func (r Request) Anchored() bool {
return r.ReleaseMBID != "" ||
r.ReleaseGroupMBID != "" ||
r.RecordingMBID != ""
// Anchored reports whether the download carries a MusicBrainz ID. Only
// anchored downloads are eligible for auto-pick.
func (d Download) Anchored() bool {
return d.ReleaseMBID != "" ||
d.ReleaseGroupMBID != "" ||
d.RecordingMBID != ""
}
// SearchText returns the string to hand a provider's search endpoint.
@@ -142,16 +149,16 @@ func (r Request) Anchored() bool {
// return zero results on providers that expect every term to appear
// in a match (Soulseek in particular), so the artist is dropped when
// the album title already leads with it.
func (r Request) SearchText() string {
if r.Query != "" {
return r.Query
func (d Download) SearchText() string {
if d.Query != "" {
return d.Query
}
if r.Artist != "" && albumLeadsWithArtist(r.Artist, r.Album) {
return strings.TrimSpace(r.Album)
if d.Artist != "" && albumLeadsWithArtist(d.Artist, d.Album) {
return strings.TrimSpace(d.Album)
}
return strings.TrimSpace(r.Artist + " " + r.Album)
return strings.TrimSpace(d.Artist + " " + d.Album)
}
// albumLeadsWithArtist reports whether album starts with artist as a
@@ -295,6 +302,7 @@ type QualityScore struct {
Bitrate float64 `json:"bitrate"`
Health float64 `json:"health"` // seeders, free slots
Priority float64 `json:"priority"` // user's per-provider preference
SizeFit float64 `json:"sizeFit"` // closeness to the preferred download size
// Mixed marks a candidate whose files are not all the same format,
// which usually means a hand-assembled folder rather than a rip.
+10 -10
View File
@@ -2,52 +2,52 @@ package download
import "testing"
func TestRequest_SearchText(t *testing.T) {
func TestDownload_SearchText(t *testing.T) {
tests := []struct {
name string
req Request
dl Download
want string
}{
{
name: "query overrides everything",
req: Request{Artist: "Blank Banshee", Album: "0", Query: "raw text"},
dl: Download{Artist: "Blank Banshee", Album: "0", Query: "raw text"},
want: "raw text",
},
{
name: "ordinary album keeps artist and album",
req: Request{Artist: "Pink Floyd", Album: "The Wall"},
dl: Download{Artist: "Pink Floyd", Album: "The Wall"},
want: "Pink Floyd The Wall",
},
{
name: "album title leads with artist name",
req: Request{Artist: "Blank Banshee", Album: "Blank Banshee 0"},
dl: Download{Artist: "Blank Banshee", Album: "Blank Banshee 0"},
want: "Blank Banshee 0",
},
{
name: "self-titled album",
req: Request{Artist: "Boston", Album: "Boston"},
dl: Download{Artist: "Boston", Album: "Boston"},
want: "Boston",
},
{
name: "artist name as a substring, not a word prefix",
req: Request{Artist: "Air", Album: "Repair"},
dl: Download{Artist: "Air", Album: "Repair"},
want: "Air Repair",
},
{
name: "case-insensitive match",
req: Request{Artist: "blank banshee", Album: "BLANK BANSHEE 0"},
dl: Download{Artist: "blank banshee", Album: "BLANK BANSHEE 0"},
want: "BLANK BANSHEE 0",
},
{
name: "no artist",
req: Request{Album: "Compilation"},
dl: Download{Album: "Compilation"},
want: "Compilation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.req.SearchText(); got != tt.want {
if got := tt.dl.SearchText(); got != tt.want {
t.Errorf("SearchText() = %q, want %q", got, tt.want)
}
})
-236
View File
@@ -1,236 +0,0 @@
package download
import (
"math"
"math/rand/v2"
"time"
)
// A Want is a persistent "I want this", stored as a MusicBrainz ID and
// almost nothing else.
//
// The distinction from Request is the whole point of this file. A
// Request is one attempt: it searches, it grabs, it succeeds or fails,
// and then it is history. A Want outlives every attempt made on its
// behalf. Nothing being findable today is the normal case for obscure
// music, and the correct response is to try again next week, not to
// show the user a failed row they have to remember to retry.
//
// Because a Want is only an MBID, it stays true when everything around
// it changes: the explore index is rebuilt, a provider is swapped out,
// the release the user originally saw is superseded by a remaster. The
// display fields are a cache for the list view and are never consulted
// for matching.
// Entity says what a want's MBID names, and is the only type
// distinction the wanted list makes.
type Entity string
// Want entity types.
const (
// EntityArtist is a subscription rather than a thing to fetch: it
// is never satisfied, and each reconcile expands the artist's
// discography into child wants.
EntityArtist Entity = "artist"
// EntityReleaseGroup is an album in the abstract — any release of
// it satisfies the want, which is what a user means by "I want this
// album".
EntityReleaseGroup Entity = "release-group"
// EntityRelease is one specific edition, used when the user picked
// a particular pressing.
EntityRelease Entity = "release"
// EntityRecording is a single track.
EntityRecording Entity = "recording"
)
// Valid reports whether e is a known entity type.
func (e Entity) Valid() bool {
switch e {
case EntityArtist, EntityReleaseGroup, EntityRelease, EntityRecording:
return true
default:
return false
}
}
// Expands reports whether this entity produces child wants rather than
// being downloaded directly.
func (e Entity) Expands() bool {
return e == EntityArtist
}
// WantState is where a want sits. There is deliberately no "failed":
// an attempt can fail, a want cannot. A want that has tried and not
// found anything is still wanted, with attempts and last_error
// recording why it is taking a while.
type WantState string
// Want states.
const (
// WantStateWanted is the active state: due for another attempt when
// its backoff elapses.
WantStateWanted WantState = "wanted"
// WantStateSatisfied means the library owns it. How it got there —
// downloaded here, ripped, bought elsewhere — does not matter.
WantStateSatisfied WantState = "satisfied"
// WantStatePaused is the user saying "keep this on the list but
// stop trying".
WantStatePaused WantState = "paused"
)
// WantScope applies to artist wants only.
type WantScope string
// Artist want scopes.
const (
// ScopeFuture takes only releases first published after the artist
// was added. Default, because subscribing to an artist should not
// silently queue their entire back catalogue.
ScopeFuture WantScope = "future"
// ScopeAll backfills the whole discography as well.
ScopeAll WantScope = "all"
)
// Want is one row of the wanted list.
type Want struct {
ID int64 `json:"id"`
MBID string `json:"mbid"`
Entity Entity `json:"entity"`
LibraryID int64 `json:"libraryId"`
// Artist and Title are display cache only. Matching always uses
// the MBID.
Artist string `json:"artist"`
Title string `json:"title"`
Scope WantScope `json:"scope"`
// Secondary includes compilations, live albums and remixes in an
// artist want's expansion.
Secondary bool `json:"secondary"`
State WantState `json:"state"`
// ParentID is set on wants the reconciler derived from an artist
// subscription. A want the user pinned directly has none, so
// removing the artist leaves it alone.
ParentID int64 `json:"parentId,omitempty"`
Attempts int `json:"attempts"`
LastError string `json:"lastError,omitempty"`
LastTriedAt time.Time `json:"lastTriedAt,omitempty"`
NextTryAt time.Time `json:"nextTryAt,omitempty"`
// ExternalIDs maps provider row ID (as a string, because JSON
// object keys are strings) to that provider's own identifier for
// this want. Only set for providers that keep a persistent list of
// their own.
ExternalIDs map[string]string `json:"externalIds,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Anchored is always true for a want: it is an MBID by construction.
// The method exists so wants and requests read the same at call sites.
func (w Want) Anchored() bool { return w.MBID != "" }
// Label is the wanted list's one-line description of a want.
func (w Want) Label() string {
switch {
case w.Artist != "" && w.Title != "":
return w.Artist + " — " + w.Title
case w.Title != "":
return w.Title
case w.Artist != "":
return w.Artist
default:
return string(w.Entity) + " " + w.MBID
}
}
// Retry backoff. A want that cannot be found is usually one that will
// not be findable for a while — a pre-release, something only ever on
// physical media, an artist no source indexes — so the schedule climbs
// fast and then sits at a weekly poll rather than hammering providers
// with the same fruitless search.
const (
// wantRetryBase is the delay after the first unsuccessful attempt.
wantRetryBase = 6 * time.Hour
// wantRetryMax caps the backoff. A weekly retry on a list of a few
// hundred wants is a handful of searches a day, which every
// provider tolerates.
wantRetryMax = 7 * 24 * time.Hour
// wantRetryJitter spreads retries so a list added in one sitting
// does not come due in one burst.
wantRetryJitter = 0.2
)
// nextRetry returns when a want with the given attempt count should be
// tried again: exponential from wantRetryBase, capped at wantRetryMax,
// jittered so a batch added together does not stay in lockstep forever.
func nextRetry(now time.Time, attempts int) time.Time {
if attempts < 1 {
attempts = 1
}
// Cap the exponent before shifting so a long-lived want cannot
// overflow the duration into something negative.
const maxExp = 16
exp := min(attempts-1, maxExp)
delay := float64(wantRetryBase) * math.Pow(2, float64(exp))
if delay > float64(wantRetryMax) {
delay = float64(wantRetryMax)
}
jitter := delay * wantRetryJitter * (rand.Float64()*2 - 1) //nolint:gosec // spreading retries, not a secret
return now.Add(time.Duration(delay + jitter))
}
// wantSource is the request source recorded for reconciler-raised
// requests, so the downloads list can tell them apart from the ones a
// user started by hand.
const wantSource = "wanted"
// ToRequest builds the download request that would satisfy this want.
// Expected is filled by the caller from the catalog, since resolving a
// tracklist is I/O and this is not.
func (w Want) ToRequest(id string) Request {
req := Request{
ID: id,
LibraryID: w.LibraryID,
Artist: w.Artist,
Album: w.Title,
WantID: w.ID,
Source: wantSource,
}
switch w.Entity {
case EntityRelease:
req.ReleaseMBID = w.MBID
case EntityReleaseGroup:
req.ReleaseGroupMBID = w.MBID
case EntityRecording:
// A recording has no release anchor, so ranking has only the
// title to go on and auto-pick stays off. The MBID is still
// carried in RecordingMBID so a provider that can use it does.
req.RecordingMBID = w.MBID
case EntityArtist:
// Artist wants expand into children and are never turned into
// a request directly; this case exists so the switch is
// exhaustive rather than because it can happen.
}
return req
}
-307
View File
@@ -1,307 +0,0 @@
package download
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"yellowjacket/backend/database/sql/sqlcgen"
)
// The wanted list's persistence. Kept apart from the request/item
// storage in store.go because the two have opposite lifetimes: items
// are written constantly and swept, wants are written rarely and kept.
// defaultDueBatch bounds how many wants one reconcile pass picks up.
// The list can be thousands of rows after a discography backfill, and a
// pass that tried to search all of them would take a day and annoy
// every provider on the way.
const defaultDueBatch = 25
// AddWant inserts a want, or returns the existing row's ID if the same
// MBID is already wanted in this library. Asking twice is not two
// wants, and re-asking must not reset a backoff that is deliberately
// long.
func (s *Store) AddWant(ctx context.Context, w Want) (int64, error) {
if !w.Entity.Valid() {
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, w.Entity)
}
if w.Scope == "" {
w.Scope = ScopeFuture
}
// The MBID is the identity of a want, so it is normalized here
// rather than at each call site: the same identifier arriving from
// an Explore page and from a pasted URL must be one row, or the
// uniqueness constraint that makes artist expansion idempotent
// stops holding.
w.MBID = strings.ToLower(strings.TrimSpace(w.MBID))
if w.MBID == "" {
return 0, fmt.Errorf("%w: a want needs an MBID", ErrUnsupported)
}
parent := sql.NullInt64{}
if w.ParentID != 0 {
parent = sql.NullInt64{Int64: w.ParentID, Valid: true}
}
id, err := s.db.Queries.UpsertDownloadWant(
ctx,
sqlcgen.UpsertDownloadWantParams{
Mbid: w.MBID,
Entity: string(w.Entity),
LibraryID: w.LibraryID,
Artist: w.Artist,
Title: w.Title,
Scope: string(w.Scope),
Secondary: boolToInt(w.Secondary),
ParentID: parent,
},
)
if err != nil {
return 0, fmt.Errorf("add download want: %w", err)
}
return id, nil
}
// GetWant loads one want.
func (s *Store) GetWant(ctx context.Context, id int64) (Want, error) {
row, err := s.db.ReadQueries.GetDownloadWant(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Want{}, fmt.Errorf("%w: want %d", ErrNotFound, id)
}
return Want{}, fmt.Errorf("get download want: %w", err)
}
return wantRowToWant(row), nil
}
// FindWant looks a want up by what it names rather than by row ID,
// which is how callers holding an MBID (the Explore pages, a provider
// sync) ask "is this already wanted?".
func (s *Store) FindWant(
ctx context.Context,
mbid string,
libraryID int64,
) (Want, bool, error) {
row, err := s.db.ReadQueries.GetDownloadWantByMBID(
ctx,
sqlcgen.GetDownloadWantByMBIDParams{Mbid: mbid, LibraryID: libraryID},
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Want{}, false, nil
}
return Want{}, false, fmt.Errorf("find download want: %w", err)
}
return wantRowToWant(row), true, nil
}
// ListWants returns the whole wanted list, active first.
func (s *Store) ListWants(ctx context.Context) ([]Want, error) {
rows, err := s.db.ReadQueries.ListDownloadWants(ctx)
if err != nil {
return nil, fmt.Errorf("list download wants: %w", err)
}
return wantRowsToWants(rows), nil
}
// ListArtistWants returns active artist subscriptions, which are what
// the reconciler expands.
func (s *Store) ListArtistWants(ctx context.Context) ([]Want, error) {
rows, err := s.db.ReadQueries.ListDownloadWantsByEntity(
ctx,
sqlcgen.ListDownloadWantsByEntityParams{
Entity: string(EntityArtist),
State: string(WantStateWanted),
},
)
if err != nil {
return nil, fmt.Errorf("list artist wants: %w", err)
}
return wantRowsToWants(rows), nil
}
// ListDueWants returns downloadable wants whose backoff has elapsed,
// least-attempted first so a new addition is not stuck behind a
// hundred long-shot retries.
func (s *Store) ListDueWants(ctx context.Context, limit int) ([]Want, error) {
if limit <= 0 {
limit = defaultDueBatch
}
rows, err := s.db.ReadQueries.ListDueDownloadWants(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("list due download wants: %w", err)
}
return wantRowsToWants(rows), nil
}
// ListChildWants returns the wants an artist subscription produced.
func (s *Store) ListChildWants(
ctx context.Context,
parentID int64,
) ([]Want, error) {
rows, err := s.db.ReadQueries.ListChildDownloadWants(
ctx,
sql.NullInt64{Int64: parentID, Valid: true},
)
if err != nil {
return nil, fmt.Errorf("list child download wants: %w", err)
}
return wantRowsToWants(rows), nil
}
// SetWantState moves a want between wanted, paused and satisfied.
func (s *Store) SetWantState(
ctx context.Context,
id int64,
state WantState,
errText string,
) error {
if err := s.db.Queries.SetDownloadWantState(
ctx,
sqlcgen.SetDownloadWantStateParams{
State: string(state),
LastError: errText,
ID: id,
},
); err != nil {
return fmt.Errorf("set download want state: %w", err)
}
return nil
}
// RecordAttempt notes an unsuccessful pass over a want and schedules
// the next one. The want stays wanted: not finding something is a fact
// about today's providers, not a verdict on the request.
func (s *Store) RecordAttempt(
ctx context.Context,
id int64,
attempts int,
reason string,
) error {
next := nextRetry(time.Now(), attempts+1)
if err := s.db.Queries.RecordDownloadWantAttempt(
ctx,
sqlcgen.RecordDownloadWantAttemptParams{
LastError: reason,
NextTryAt: sql.NullTime{Time: next, Valid: true},
ID: id,
},
); err != nil {
return fmt.Errorf("record download want attempt: %w", err)
}
return nil
}
// SatisfyWant marks a want as owned.
func (s *Store) SatisfyWant(ctx context.Context, id int64) error {
if err := s.db.Queries.SatisfyDownloadWant(ctx, id); err != nil {
return fmt.Errorf("satisfy download want: %w", err)
}
return nil
}
// SetWantExternalIDs records the identifiers external managers gave
// this want in their own persistent lists.
func (s *Store) SetWantExternalIDs(
ctx context.Context,
id int64,
ids map[string]string,
) error {
encoded, err := json.Marshal(ids)
if err != nil {
return fmt.Errorf("encode want external ids: %w", err)
}
if err := s.db.Queries.SetDownloadWantExternalIDs(
ctx,
sqlcgen.SetDownloadWantExternalIDsParams{
ExternalIds: string(encoded),
ID: id,
},
); err != nil {
return fmt.Errorf("set want external ids: %w", err)
}
return nil
}
// DeleteWant removes a want and, by cascade, anything an artist want
// derived.
func (s *Store) DeleteWant(ctx context.Context, id int64) error {
if err := s.db.Queries.DeleteDownloadWant(ctx, id); err != nil {
return fmt.Errorf("delete download want: %w", err)
}
return nil
}
// ClearSatisfiedWants drops everything already owned.
func (s *Store) ClearSatisfiedWants(ctx context.Context) error {
if err := s.db.Queries.DeleteSatisfiedDownloadWants(ctx); err != nil {
return fmt.Errorf("clear satisfied download wants: %w", err)
}
return nil
}
// wantRowsToWants decodes a slice of stored rows.
func wantRowsToWants(rows []sqlcgen.DownloadWant) []Want {
out := make([]Want, 0, len(rows))
for _, r := range rows {
out = append(out, wantRowToWant(r))
}
return out
}
// wantRowToWant decodes a stored want row. A malformed external-ID
// blob yields an empty map rather than an error: losing the link to a
// Lidarr row is recoverable on the next sync, making the wanted list
// unreadable is not.
func wantRowToWant(r sqlcgen.DownloadWant) Want {
external := map[string]string{}
_ = json.Unmarshal([]byte(r.ExternalIds), &external)
return Want{
ID: r.ID,
MBID: r.Mbid,
Entity: Entity(r.Entity),
LibraryID: r.LibraryID,
Artist: r.Artist,
Title: r.Title,
Scope: WantScope(r.Scope),
Secondary: r.Secondary != 0,
State: WantState(r.State),
ParentID: r.ParentID.Int64,
Attempts: int(r.Attempts),
LastError: r.LastError,
LastTriedAt: r.LastTriedAt.Time,
NextTryAt: r.NextTryAt.Time,
ExternalIDs: external,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
+6 -6
View File
@@ -123,15 +123,15 @@ const (
// open download picker re-read the provider list.
DownloadProvidersChanged = "DownloadProvidersChanged"
// DownloadsChanged fires when the set of download requests changes
// (started, picked, cancelled, cleared). Per-transfer progress does
// not use this — it flows through the jobs registry's JobsChanged,
// which already coalesces high-frequency updates.
// DownloadsChanged fires when the set of downloads changes (started,
// picked, cancelled, cleared). Per-transfer progress does not use
// this — it flows through the jobs registry's JobsChanged, which
// already coalesces high-frequency updates.
DownloadsChanged = "DownloadsChanged"
// WantedListChanged fires when the wanted list gains, loses or
// RequestsChanged fires when the request list gains, loses or
// retires an entry — including from a background reconcile pass,
// which is why the list is event-driven rather than fetched once on
// mount.
WantedListChanged = "WantedListChanged"
RequestsChanged = "RequestsChanged"
)
+2 -2
View File
@@ -23,7 +23,7 @@ import '@components/autotag-view/autotag-view.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/jobs/job-indicator.ts';
import '@components/jobs/jobs-view.ts';
import '@components/wanted-view/wanted-view.ts';
import '@components/downloads-view/downloads-view.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
@@ -64,7 +64,7 @@ const VIEW_TAGS: Record<string, string> = {
playlists: 'playlist-view',
explore: 'explore-view',
autotag: 'autotag-view',
wanted: 'wanted-view',
downloads: 'downloads-view',
jobs: 'jobs-view',
settings: 'config-page',
};
@@ -15,8 +15,28 @@ import type {
} from '@store/download-store';
import { downloadStore } from '@store/download-store';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/Config';
import { SetPreferences } from '@go/download/Service';
import type { download } from '@go/models';
import './config-section';
/**
* Allowed audio formats for auto-download, mirrored from
* backend/download/types.go's `Format` constants. `FormatUnknown` is
* deliberately excluded — it names "no format detected", not a format a
* user could opt into.
*/
const AUTO_DOWNLOAD_FORMATS: { value: string; label: string }[] = [
{ value: 'flac', label: 'FLAC' },
{ value: 'alac', label: 'ALAC' },
{ value: 'wav', label: 'WAV' },
{ value: 'mp3', label: 'MP3' },
{ value: 'aac', label: 'AAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'opus', label: 'Opus' },
{ value: 'wma', label: 'WMA' },
];
/**
* Download client configuration.
*
@@ -59,6 +79,24 @@ export class DownloadClients extends LitElement {
@state()
private errorMessage = '';
/** Working copy of the auto-download guardrails. */
@state()
private prefs: download.AutoDownloadPrefs = {
minSizeMb: 0,
maxSizeMb: 0,
preferredSizeMb: 0,
allowedFormats: [],
} as download.AutoDownloadPrefs;
@state()
private prefsSaving = false;
@state()
private prefsError = '';
@state()
private prefsSaved = false;
private unsubscribe: (() => void) | null = null;
override connectedCallback(): void {
@@ -67,6 +105,7 @@ export class DownloadClients extends LitElement {
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
void downloadStore.init().then(() => this.syncFromStore());
void this.loadPreferences();
}
override disconnectedCallback(): void {
@@ -81,6 +120,14 @@ export class DownloadClients extends LitElement {
this.descriptors = downloadStore.descriptors;
}
private async loadPreferences(): Promise<void> {
try {
this.prefs = await GetDownloadPreferences();
} catch (err) {
console.error('Failed to load auto-download preferences:', err);
}
}
static override styles = [
designTokens,
css`
@@ -178,6 +225,21 @@ export class DownloadClients extends LitElement {
.field-row .browse-button {
flex-shrink: 0;
}
.format-options {
display: flex;
flex-wrap: wrap;
gap: 0.4em 1em;
margin-top: 0.4em;
}
.format-option {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 0.9em;
cursor: pointer;
}
`,
];
@@ -208,6 +270,104 @@ export class DownloadClients extends LitElement {
</div>
`}
</config-section>
<config-section
heading="Auto-download preferences"
description="Guardrails on what the pipeline may grab without asking — a manual pick is never restricted by these, only automatic ones."
>
${this.prefsError
? html`<wa-callout variant="danger">${this.prefsError}</wa-callout>`
: nothing}
<div class="form">
<div class="field-row">
<wa-input
label="Minimum size (MB)"
type="number"
min="0"
placeholder="No minimum"
.value=${this.prefs.minSizeMb ? String(this.prefs.minSizeMb) : ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
minSizeMb: Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
<wa-input
label="Maximum size (MB)"
type="number"
min="0"
placeholder="No maximum"
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
maxSizeMb: Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
<wa-input
label="Preferred size (MB)"
type="number"
min="0"
placeholder="No preference"
.value=${this.prefs.preferredSizeMb
? String(this.prefs.preferredSizeMb)
: ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
preferredSizeMb:
Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
</div>
<div>
<div class="requires">
Allowed formats — leave all unchecked to allow any format.
</div>
<div class="format-options">
${AUTO_DOWNLOAD_FORMATS.map(
(format) => html`
<label class="format-option">
<input
type="checkbox"
.checked=${(this.prefs.allowedFormats ?? []).includes(
format.value,
)}
@change=${(e: Event) =>
this.toggleFormat(
format.value,
(e.target as HTMLInputElement).checked,
)}
/>
${format.label}
</label>
`,
)}
</div>
</div>
<div class="form-actions">
${this.prefsSaved
? html`<span class="test-result ok">Saved.</span>`
: nothing}
<wa-button
size="small"
variant="brand"
?disabled=${this.prefsSaving}
@click=${this.savePreferences}
>
${this.prefsSaving
? html`<wa-spinner></wa-spinner>`
: 'Save preferences'}
</wa-button>
</div>
</div>
</config-section>
`;
}
@@ -554,6 +714,37 @@ export class DownloadClients extends LitElement {
this.testing = null;
}
}
private toggleFormat(format: string, checked: boolean): void {
const current = this.prefs.allowedFormats ?? [];
const allowedFormats = checked
? [...current, format]
: current.filter((f) => f !== format);
this.prefs = { ...this.prefs, allowedFormats };
}
/**
* Saves the guardrails both to disk and to the running download
* manager in one action — persistence alone would leave the setting
* inert until restart, which is exactly the bug this mirrors away
* from (see `config.Library`'s prior persist-without-apply gap).
*/
private savePreferences = async () => {
this.prefsSaving = true;
this.prefsError = '';
this.prefsSaved = false;
try {
await SetDownloadPreferences(this.prefs);
await SetPreferences(this.prefs);
this.prefsSaved = true;
} catch (err) {
this.prefsError = String(err);
} finally {
this.prefsSaving = false;
}
};
}
declare global {
@@ -57,7 +57,7 @@ export class DownloadPicker extends LitElement {
private candidates: DownloadCandidate[] = [];
@state()
private requestId = '';
private downloadId = '';
@state()
private autoPicked = false;
@@ -141,7 +141,7 @@ export class DownloadPicker extends LitElement {
expected: this.expected ?? [],
} as download.SearchRequest);
this.requestId = result.requestId;
this.downloadId = result.downloadId;
this.candidates = result.candidates ?? [];
this.autoPicked = result.autoPicked;
} catch (err) {
@@ -158,7 +158,7 @@ export class DownloadPicker extends LitElement {
this.errorMessage = '';
try {
await downloadStore.pick(this.requestId, event.detail.candidateId);
await downloadStore.pick(this.downloadId, event.detail.candidateId);
this.close();
} catch (err) {
this.errorMessage = String(err);
@@ -0,0 +1,513 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { designTokens } from '../../styles/tokens.css';
import { downloadStore, stateLabel } from '@store/download-store';
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
import { libraryStore } from '@store/library-store';
type Tab = 'requests' | 'downloads';
/**
* The downloads page: what music the user has asked for, and what has
* actually been attempted.
*
* These are two different lists on purpose. A Request is durable — "get
* this whenever available" — and stays around, retried on a backoff,
* until it is satisfied or removed. A Download is one search-and-grab
* attempt; it can fail or complete and that is the end of its story. The
* Requests tab is the list the durable, not-a-failure-just-because-it's-
* still-here content the wanted list used to be; the Downloads tab is the
* attempt history nothing rendered before this page existed.
*/
@customElement('downloads-view')
export class DownloadsView extends LitElement {
@state() private tab: Tab = 'requests';
@state() private requests: Request[] = [];
@state() private downloads: DownloadRecord[] = [];
@state() private checking = false;
@state() private lastSummary: RequestSummary | null = null;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
css`
:host {
display: block;
height: 100%;
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--yj-text-primary, #fff);
flex: 1;
}
.subtitle {
margin: 0 0 20px;
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
}
.tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
}
.tab {
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: var(--yj-text-secondary, #b3b3b3);
cursor: pointer;
border-bottom: 2px solid transparent;
user-select: none;
}
.tab:hover {
color: var(--yj-text-primary, #fff);
}
.tab.active {
color: var(--yj-text-primary, #fff);
border-bottom-color: var(--yj-accent, #ffd43b);
}
h2 {
margin: 24px 0 8px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-secondary, #b3b3b3);
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 6px;
background: var(--yj-bg-surface, #181818);
}
.row + .row {
margin-top: 6px;
}
.row-main {
flex: 1;
min-width: 0;
}
.title {
font-size: 14px;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail {
font-size: 12px;
color: var(--yj-text-tertiary, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail.error {
color: var(--wa-color-danger-fill-loud, #c65f5f);
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
color: var(--yj-text-secondary, #b3b3b3);
flex-shrink: 0;
}
.empty {
padding: 40px 20px;
text-align: center;
color: var(--yj-text-tertiary, #888);
font-size: 14px;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.summary {
font-size: 12px;
color: var(--yj-text-secondary, #b3b3b3);
margin: 8px 0 0;
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = downloadStore.subscribe(() => {
this.requests = downloadStore.requests;
this.downloads = downloadStore.downloads;
});
void downloadStore.init().then(() => {
this.requests = downloadStore.requests;
this.downloads = downloadStore.downloads;
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
}
override render() {
return html`
<header>
<h1>Downloads</h1>
${this.tab === 'requests'
? html`
<wa-button
size="small"
appearance="outlined"
?disabled=${this.checking}
@click=${() => void this.checkNow()}
>
<wa-icon slot="start" name="rotate"></wa-icon>
${this.checking ? 'Checking…' : 'Check now'}
</wa-button>
`
: nothing}
</header>
<p class="subtitle">
Music you have requested, and the download attempts that
have run for it. A request that cannot be found today stays
on the list and is looked for again later.
</p>
<div class="tabs">
<div
class="tab ${this.tab === 'requests' ? 'active' : ''}"
@click=${() => (this.tab = 'requests')}
>
Requests
</div>
<div
class="tab ${this.tab === 'downloads' ? 'active' : ''}"
@click=${() => (this.tab = 'downloads')}
>
Downloads
</div>
</div>
${this.tab === 'requests' ? this.renderRequests() : this.renderDownloads()}
`;
}
// -----------------------------------------------------------------
// Requests tab
// -----------------------------------------------------------------
private renderRequests() {
const subscriptions = this.requests.filter((r) => r.entity === 'artist');
const wanted = this.requests.filter(
(r) => r.entity !== 'artist' && r.state === 'wanted',
);
const paused = this.requests.filter((r) => r.state === 'paused');
const satisfied = this.requests.filter((r) => r.state === 'satisfied');
return html`
${this.renderSummary()}
${satisfied.length > 0
? html`
<div class="actions">
<wa-button
size="small"
appearance="plain"
@click=${() =>
void downloadStore.clearSatisfiedRequests()}
>
Clear found
</wa-button>
</div>
`
: nothing}
${this.requests.length === 0 ? this.renderEmptyRequests() : nothing}
${this.renderRequestSection(
'Following',
subscriptions,
(r) => this.renderSubscription(r),
)}
${this.renderRequestSection('Looking for', wanted, (r) => this.renderRequest(r))}
${this.renderRequestSection('Paused', paused, (r) => this.renderRequest(r))}
${this.renderRequestSection('Found', satisfied, (r) => this.renderRequest(r))}
`;
}
private renderEmptyRequests() {
return html`
<div class="empty">
Nothing requested yet. Use “Want this” on an album or artist
to add it here.
</div>
`;
}
private renderSummary() {
if (!this.lastSummary) return nothing;
const s = this.lastSummary;
const parts = [
s.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
s.started > 0 ? `${s.started} downloading` : '',
s.attempted > 0 ? `${s.attempted} searched for` : '',
].filter(Boolean);
return html`
<p class="summary">
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
</p>
`;
}
private renderRequestSection(
title: string,
items: Request[],
renderer: (request: Request) => unknown,
) {
if (items.length === 0) return nothing;
return html`
<h2>${title}</h2>
${items.map((request) => renderer(request))}
`;
}
/**
* An artist row is a subscription, not a queued download, so it
* shows what it covers rather than a retry count — the albums it
* produced appear in their own section.
*/
private renderSubscription(request: Request) {
return html`
<div class="row">
<wa-icon name="user-group"></wa-icon>
<div class="row-main">
<div class="title">
${request.artist || request.title || request.mbid}
</div>
<div class="detail">
${request.scope === 'all'
? 'Whole discography, plus new releases'
: 'New releases only'}
</div>
</div>
<span class="badge">Following</span>
<div class="actions">
<wa-button
size="small"
appearance="plain"
@click=${() => void this.toggleScope(request)}
>
${request.scope === 'all' ? 'New only' : 'Everything'}
</wa-button>
${this.renderRemove(request)}
</div>
</div>
`;
}
private renderRequest(request: Request) {
return html`
<div class="row">
<wa-icon
name=${request.entity === 'recording' ? 'music' : 'compact-disc'}
></wa-icon>
<div class="row-main">
<div class="title">
${request.artist ? `${request.artist}` : ''}${request.title ||
request.mbid}
</div>
<div class="detail">${requestDetail(request)}</div>
</div>
<div class="actions">
${request.state === 'satisfied'
? nothing
: html`
<wa-button
size="small"
appearance="plain"
@click=${() =>
void downloadStore.pauseRequest(
request.id,
request.state !== 'paused',
)}
>
${request.state === 'paused' ? 'Resume' : 'Pause'}
</wa-button>
`}
${this.renderRemove(request)}
</div>
</div>
`;
}
private renderRemove(request: Request) {
return html`
<wa-button
size="small"
appearance="plain"
@click=${() => void downloadStore.removeRequest(request.id)}
>
<wa-icon name="xmark"></wa-icon>
</wa-button>
`;
}
/** Widens or narrows what an artist subscription covers. */
private async toggleScope(request: Request): Promise<void> {
try {
const libraryId =
request.libraryId || (await libraryStore.getDefaultLibraryId());
if (!libraryId) {
console.error(
'Could not change what this subscription covers: no library available',
);
return;
}
await downloadStore.addRequest({
mbid: request.mbid,
entity: 'artist',
libraryId,
artist: request.artist,
title: request.title,
scope: request.scope === 'all' ? 'future' : 'all',
secondary: request.secondary,
} as never);
} catch (err) {
console.error('Could not change what this subscription covers:', err);
}
}
private async checkNow(): Promise<void> {
this.checking = true;
try {
this.lastSummary = await downloadStore.reconcileRequests();
} catch (err) {
console.error('Could not check the requests list:', err);
} finally {
this.checking = false;
}
}
// -----------------------------------------------------------------
// Downloads tab
// -----------------------------------------------------------------
private renderDownloads() {
if (this.downloads.length === 0) {
return html`
<div class="empty">
No downloads yet. Attempts made by "Download now" or the
background reconciler show up here.
</div>
`;
}
return this.downloads.map((view) => this.renderDownload(view));
}
private renderDownload(view: DownloadRecord) {
const title = view.artist
? `${view.artist}${view.album ? `${view.album}` : ''}`
: view.query || view.album || 'Untitled download';
return html`
<div class="row">
<wa-icon name="compact-disc"></wa-icon>
<div class="row-main">
<div class="title">${title}</div>
<div class="detail">${this.downloadDetail(view)}</div>
${view.error
? html`<div class="detail error">${view.error}</div>`
: nothing}
</div>
<span class="badge">${stateLabel(view.state)}</span>
</div>
`;
}
/** Provider/progress summary for a download's second line. */
private downloadDetail(view: DownloadRecord): string {
const providers = [
...new Set(view.items.map((item) => item.candidate?.origin).filter(Boolean)),
];
const parts: string[] = [];
if (providers.length > 0) parts.push(providers.join(', '));
if (view.source) parts.push(view.source);
return parts.length > 0 ? parts.join(' · ') : 'No provider info';
}
}
/**
* The second line of a request row: what is happening, in the user's
* terms.
*
* A request that has been tried and not found is reported as still being
* looked for rather than as an error, because that is what it is — the
* retry is already scheduled and there is nothing for the user to do.
*/
function requestDetail(request: Request): string {
if (request.state === 'satisfied') return 'In your library';
if (request.state === 'paused') return 'Paused';
if (request.attempts === 0) return 'Not looked for yet';
const reason = request.lastError ? `${request.lastError}` : '';
return `Looked for ${request.attempts} time${request.attempts === 1 ? '' : 's'}${reason}`;
}
declare global {
interface HTMLElementTagNameMap {
'downloads-view': DownloadsView;
}
}
@@ -120,11 +120,11 @@ export class ExploreAlbumDetails extends LitElement {
/** True once a download client is configured and enabled. */
@state() private canDownload = false;
/** True when this album is already on the wanted list. */
@state() private isWanted = false;
/** True when this album already has a request. */
@state() private isRequested = false;
/**
* Library to attach downloads/wants to. The library-filter UI that
* Library to attach downloads/requests to. The library-filter UI that
* would normally set libraryStore's selection isn't mounted anywhere
* currently, so that selection is always null here — falling back to
* `?? 0` would send a library id that doesn't exist and fail the
@@ -496,12 +496,12 @@ export class ExploreAlbumDetails extends LitElement {
// so this tracks the provider list rather than assuming.
this.downloadUnsub = downloadStore.subscribe(() => {
this.canDownload = downloadStore.available;
this.syncWanted();
this.syncRequested();
});
void downloadStore.init().then(() => {
this.canDownload = downloadStore.available;
this.syncWanted();
this.syncRequested();
});
void this.resolveTargetLibraryId();
@@ -1537,62 +1537,62 @@ export class ExploreAlbumDetails extends LitElement {
}
/**
* Adds the album to the wanted list, which is the answer to "look
* Adds the album to the requests list, which is the answer to "look
* for it, but not right now".
*
* Unlike the download button this shows whether or not a client is
* connected: wanting something is a durable statement about the
* connected: requesting something is a durable statement about the
* library, and it stays true — and stays queued — until a client
* exists to act on it.
*/
private renderWantAction() {
if (!this.releaseGroupMBID) return nothing;
const want = downloadStore.wantFor(this.releaseGroupMBID);
const request = downloadStore.requestFor(this.releaseGroupMBID);
return html`
<wa-button
size="small"
appearance=${this.isWanted ? 'filled' : 'outlined'}
@click=${() => void this.toggleWanted(want?.id)}
appearance=${this.isRequested ? 'filled' : 'outlined'}
@click=${() => void this.toggleRequested(request?.id)}
>
<wa-icon
slot="start"
name=${this.isWanted ? 'bookmark-check' : 'bookmark'}
name=${this.isRequested ? 'bookmark-check' : 'bookmark'}
></wa-icon>
${this.isWanted ? 'Wanted' : 'Want this'}
${this.isRequested ? 'Wanted' : 'Want this'}
</wa-button>
`;
}
/** Resolves the library to attach downloads/wants to. */
/** Resolves the library to attach downloads/requests to. */
private async resolveTargetLibraryId(): Promise<void> {
this.targetLibraryId = await libraryStore.getDefaultLibraryId();
}
/** Reflects the store's view of whether this album is wanted. */
private syncWanted(): void {
this.isWanted = this.releaseGroupMBID
? downloadStore.isWanted(this.releaseGroupMBID)
/** Reflects the store's view of whether this album is requested. */
private syncRequested(): void {
this.isRequested = this.releaseGroupMBID
? downloadStore.isRequested(this.releaseGroupMBID)
: false;
}
private async toggleWanted(wantId: number | undefined): Promise<void> {
private async toggleRequested(requestId: number | undefined): Promise<void> {
if (!this.releaseGroupMBID) return;
try {
if (wantId) {
await downloadStore.removeWant(wantId);
if (requestId) {
await downloadStore.removeRequest(requestId);
} else {
if (!this.targetLibraryId) {
await this.resolveTargetLibraryId();
}
if (!this.targetLibraryId) {
console.error('Could not update the wanted list: no library available');
console.error('Could not update the requests list: no library available');
return;
}
await downloadStore.addWant({
await downloadStore.addRequest({
mbid: this.releaseGroupMBID,
entity: 'release-group',
libraryId: this.targetLibraryId,
@@ -1600,13 +1600,13 @@ export class ExploreAlbumDetails extends LitElement {
title: this.albumName,
scope: 'future',
secondary: false,
} as download.WantRequest);
} as download.RequestInput);
}
} catch (err) {
console.error('Could not update the wanted list:', err);
console.error('Could not update the requests list:', err);
}
this.syncWanted();
this.syncRequested();
}
private async openPicker(): Promise<void> {
@@ -846,8 +846,8 @@ export class ExploreArtistDetails extends LitElement {
* background discography fetch never signals readiness. */
private discogFallbackTimer?: number;
/** Unsubscribe handle for the wanted list. */
private unsubWanted: (() => void) | null = null;
/** Unsubscribe handle for the requests list. */
private unsubRequests: (() => void) | null = null;
override connectedCallback() {
super.connectedCallback();
@@ -855,10 +855,10 @@ export class ExploreArtistDetails extends LitElement {
void this.loadAllData();
}
// Keep the follow button in step with the wanted list, which a
// Keep the follow button in step with the requests list, which a
// background reconcile pass can change without this page doing
// anything.
this.unsubWanted = downloadStore.subscribe(() => this.requestUpdate());
this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate());
void downloadStore.init().then(() => this.requestUpdate());
// A background discography fetch (top tracks / top releases for an
@@ -897,8 +897,8 @@ export class ExploreArtistDetails extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubWanted?.();
this.unsubWanted = null;
this.unsubRequests?.();
this.unsubRequests = null;
this.unsubDiscogReady?.();
this.unsubSimilarReady?.();
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
@@ -1896,50 +1896,50 @@ export class ExploreArtistDetails extends LitElement {
}
/**
* Subscribes to an artist: their new releases go on the wanted list
* as they come out.
* Subscribes to an artist: their new releases go on the requests
* list as they come out.
*
* The default is new releases only. Following an artist should not
* silently queue forty albums — someone who wants the back
* catalogue can widen it from the wanted list, and will not be
* catalogue can widen it from the requests list, and will not be
* surprised by having done so.
*/
private renderFollowAction() {
if (!this.artistMBID) return nothing;
const want = downloadStore.wantFor(this.artistMBID);
const request = downloadStore.requestFor(this.artistMBID);
return html`
<div class="artist-follow">
<wa-button
size="small"
appearance=${want ? 'filled' : 'outlined'}
@click=${() => void this.toggleFollow(want?.id)}
appearance=${request ? 'filled' : 'outlined'}
@click=${() => void this.toggleFollow(request?.id)}
>
<wa-icon
slot="start"
name=${want ? 'bookmark-check' : 'bookmark'}
name=${request ? 'bookmark-check' : 'bookmark'}
></wa-icon>
${want ? 'Following' : 'Follow for new releases'}
${request ? 'Following' : 'Follow for new releases'}
</wa-button>
</div>
`;
}
private async toggleFollow(wantId: number | undefined): Promise<void> {
private async toggleFollow(requestId: number | undefined): Promise<void> {
if (!this.artistMBID) return;
try {
if (wantId) {
await downloadStore.removeWant(wantId);
if (requestId) {
await downloadStore.removeRequest(requestId);
} else {
const libraryId = await libraryStore.getDefaultLibraryId();
if (!libraryId) {
console.error('Could not update the wanted list: no library available');
console.error('Could not update the requests list: no library available');
return;
}
await downloadStore.addWant({
await downloadStore.addRequest({
mbid: this.artistMBID,
entity: 'artist',
libraryId,
@@ -1950,7 +1950,7 @@ export class ExploreArtistDetails extends LitElement {
} as never);
}
} catch (err) {
console.error('Could not update the wanted list:', err);
console.error('Could not update the requests list:', err);
}
this.requestUpdate();
@@ -5,7 +5,7 @@ import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'wanted' | 'autotag' | 'jobs' | 'settings';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
interface NavItem {
id: View;
@@ -149,7 +149,7 @@ export class AppSidebar extends LitElement {
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'wanted', label: 'Wanted', icon: 'bookmark' },
{ id: 'downloads', label: 'Downloads', icon: 'bookmark' },
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
@@ -1,386 +0,0 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { designTokens } from '../../styles/tokens.css';
import { downloadStore } from '@store/download-store';
import type { Want, WantSummary } from '@store/download-store';
import { libraryStore } from '@store/library-store';
/**
* The wanted list: music the user has said they want but does not have.
*
* The list is the durable thing here, not the downloads it produces.
* Something unfindable today stays on the list and is retried on a
* backoff, so this view is mostly about making the waiting legible —
* what is being looked for, when it was last tried, and why it has not
* turned up. A row is not a failure just because it is still here.
*/
@customElement('wanted-view')
export class WantedView extends LitElement {
@state() private wants: Want[] = [];
@state() private checking = false;
@state() private lastSummary: WantSummary | null = null;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
css`
:host {
display: block;
height: 100%;
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--yj-text-primary, #fff);
flex: 1;
}
.subtitle {
margin: 0 0 20px;
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
}
h2 {
margin: 24px 0 8px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-secondary, #b3b3b3);
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 6px;
background: var(--yj-bg-surface, #181818);
}
.row + .row {
margin-top: 6px;
}
.row-main {
flex: 1;
min-width: 0;
}
.title {
font-size: 14px;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail {
font-size: 12px;
color: var(--yj-text-tertiary, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
color: var(--yj-text-secondary, #b3b3b3);
flex-shrink: 0;
}
.empty {
padding: 40px 20px;
text-align: center;
color: var(--yj-text-tertiary, #888);
font-size: 14px;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.summary {
font-size: 12px;
color: var(--yj-text-secondary, #b3b3b3);
margin: 8px 0 0;
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = downloadStore.subscribe(() => {
this.wants = downloadStore.wants;
});
void downloadStore.init().then(() => {
this.wants = downloadStore.wants;
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
}
override render() {
const subscriptions = this.wants.filter((w) => w.entity === 'artist');
const wanted = this.wants.filter(
(w) => w.entity !== 'artist' && w.state === 'wanted',
);
const paused = this.wants.filter((w) => w.state === 'paused');
const satisfied = this.wants.filter((w) => w.state === 'satisfied');
return html`
<header>
<h1>Wanted</h1>
<wa-button
size="small"
appearance="outlined"
?disabled=${this.checking}
@click=${() => void this.checkNow()}
>
<wa-icon slot="start" name="rotate"></wa-icon>
${this.checking ? 'Checking…' : 'Check now'}
</wa-button>
${satisfied.length > 0
? html`
<wa-button
size="small"
appearance="plain"
@click=${() =>
void downloadStore.clearSatisfiedWants()}
>
Clear found
</wa-button>
`
: nothing}
</header>
<p class="subtitle">
Music you want but do not have. Anything that cannot be found
stays here and is looked for again later.
</p>
${this.renderSummary()}
${this.wants.length === 0 ? this.renderEmpty() : nothing}
${this.renderSection(
'Following',
subscriptions,
(w) => this.renderSubscription(w),
)}
${this.renderSection('Looking for', wanted, (w) => this.renderWant(w))}
${this.renderSection('Paused', paused, (w) => this.renderWant(w))}
${this.renderSection('Found', satisfied, (w) => this.renderWant(w))}
`;
}
private renderEmpty() {
return html`
<div class="empty">
Nothing wanted yet. Use “Want this” on an album or artist to
add it here.
</div>
`;
}
private renderSummary() {
if (!this.lastSummary) return nothing;
const s = this.lastSummary;
const parts = [
s.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
s.started > 0 ? `${s.started} downloading` : '',
s.attempted > 0 ? `${s.attempted} searched for` : '',
].filter(Boolean);
return html`
<p class="summary">
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
</p>
`;
}
private renderSection(
title: string,
items: Want[],
renderer: (want: Want) => unknown,
) {
if (items.length === 0) return nothing;
return html`
<h2>${title}</h2>
${items.map((want) => renderer(want))}
`;
}
/**
* An artist row is a subscription, not a queued download, so it
* shows what it covers rather than a retry count — the albums it
* produced appear in their own section.
*/
private renderSubscription(want: Want) {
return html`
<div class="row">
<wa-icon name="user-group"></wa-icon>
<div class="row-main">
<div class="title">${want.artist || want.title || want.mbid}</div>
<div class="detail">
${want.scope === 'all'
? 'Whole discography, plus new releases'
: 'New releases only'}
</div>
</div>
<span class="badge">Following</span>
<div class="actions">
<wa-button
size="small"
appearance="plain"
@click=${() => void this.toggleScope(want)}
>
${want.scope === 'all' ? 'New only' : 'Everything'}
</wa-button>
${this.renderRemove(want)}
</div>
</div>
`;
}
private renderWant(want: Want) {
return html`
<div class="row">
<wa-icon
name=${want.entity === 'recording' ? 'music' : 'compact-disc'}
></wa-icon>
<div class="row-main">
<div class="title">
${want.artist ? `${want.artist}` : ''}${want.title ||
want.mbid}
</div>
<div class="detail">${wantDetail(want)}</div>
</div>
<div class="actions">
${want.state === 'satisfied'
? nothing
: html`
<wa-button
size="small"
appearance="plain"
@click=${() =>
void downloadStore.pauseWant(
want.id,
want.state !== 'paused',
)}
>
${want.state === 'paused' ? 'Resume' : 'Pause'}
</wa-button>
`}
${this.renderRemove(want)}
</div>
</div>
`;
}
private renderRemove(want: Want) {
return html`
<wa-button
size="small"
appearance="plain"
@click=${() => void downloadStore.removeWant(want.id)}
>
<wa-icon name="xmark"></wa-icon>
</wa-button>
`;
}
/** Widens or narrows what an artist subscription covers. */
private async toggleScope(want: Want): Promise<void> {
try {
const libraryId =
want.libraryId || (await libraryStore.getDefaultLibraryId());
if (!libraryId) {
console.error(
'Could not change what this subscription covers: no library available',
);
return;
}
await downloadStore.addWant({
mbid: want.mbid,
entity: 'artist',
libraryId,
artist: want.artist,
title: want.title,
scope: want.scope === 'all' ? 'future' : 'all',
secondary: want.secondary,
} as never);
} catch (err) {
console.error('Could not change what this subscription covers:', err);
}
}
private async checkNow(): Promise<void> {
this.checking = true;
try {
this.lastSummary = await downloadStore.reconcileWanted();
} catch (err) {
console.error('Could not check the wanted list:', err);
} finally {
this.checking = false;
}
}
}
/**
* The second line of a want row: what is happening, in the user's terms.
*
* A want that has been tried and not found is reported as still being
* looked for rather than as an error, because that is what it is — the
* retry is already scheduled and there is nothing for the user to do.
*/
function wantDetail(want: Want): string {
if (want.state === 'satisfied') return 'In your library';
if (want.state === 'paused') return 'Paused';
if (want.attempts === 0) return 'Not looked for yet';
const reason = want.lastError ? `${want.lastError}` : '';
return `Looked for ${want.attempts} time${want.attempts === 1 ? '' : 's'}${reason}`;
}
declare global {
interface HTMLElementTagNameMap {
'wanted-view': WantedView;
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ export const Events = {
AlbumReleasesReady: "AlbumReleasesReady",
DownloadProvidersChanged: "DownloadProvidersChanged",
DownloadsChanged: "DownloadsChanged",
WantedListChanged: "WantedListChanged",
RequestsChanged: "RequestsChanged",
} as const;
export type EventName = (typeof Events)[keyof typeof Events];
+95 -87
View File
@@ -1,22 +1,22 @@
import { EventsOn } from '@runtime/runtime';
import {
AddProvider,
AddWant,
AddRequest,
Cancel,
Candidates,
ClearFinished,
ClearSatisfiedWants,
ClearSatisfiedRequests,
DeleteProvider,
ImportExternalWants,
ImportExternalRequests,
ListDownloads,
ListProviders,
ListRequests,
ListWants,
PauseWant,
PauseRequest,
Pick,
ProviderKinds,
ReconcileWanted,
RemoveWant,
Start,
ReconcileRequests,
RemoveRequest,
StartDownload,
TestProvider,
UpdateProvider,
} from '@go/download/Service';
@@ -26,32 +26,32 @@ import { Events } from '../events';
export type DownloadCandidate = download.Candidate;
export type DownloadProvider = download.Config;
export type DownloadDescriptor = download.Descriptor;
export type DownloadRequest = download.RequestView;
export type DownloadView = download.DownloadView;
export type ProviderField = download.Field;
export type Want = download.Want;
export type WantSummary = download.Summary;
export type Request = download.Request;
export type RequestSummary = download.Summary;
/**
* What a want's MBID names. Mirrors backend/download.Entity — the
* wanted list makes no other type distinction, because an MBID plus
* what it names is the whole of a want.
* What a request's MBID names. Mirrors backend/download.Entity — the
* request list makes no other type distinction, because an MBID plus
* what it names is the whole of a request.
*/
export type WantEntity = 'artist' | 'release-group' | 'release' | 'recording';
export type RequestEntity = 'artist' | 'release-group' | 'release' | 'recording';
/**
* Where a want sits. There is deliberately no "failed": an attempt can
* fail, a want cannot — something unfindable today is still wanted.
* Where a request sits. There is deliberately no "failed": an attempt can
* fail, a request cannot — something unfindable today is still requested.
*/
export type WantState = 'wanted' | 'satisfied' | 'paused';
export type RequestState = 'wanted' | 'satisfied' | 'paused';
/**
* How much of an artist's output a subscription covers. 'future' is the
* default so subscribing does not silently queue a back catalogue.
*/
export type WantScope = 'future' | 'all';
export type RequestScope = 'future' | 'all';
/** Lifecycle states a request can be in. Mirrors backend/download.State. */
export type DownloadState =
/** Lifecycle states a download can be in. Mirrors backend/download.State. */
export type DownloadLifecycleState =
| 'searching'
| 'found'
| 'queued'
@@ -71,12 +71,12 @@ const TERMINAL_STATES: ReadonlySet<string> = new Set([
'failed',
]);
export function isRequestTerminal(request: DownloadRequest): boolean {
return TERMINAL_STATES.has(request.state);
export function isDownloadTerminal(view: DownloadView): boolean {
return TERMINAL_STATES.has(view.state);
}
/**
* Human-readable label for a request state. Kept here rather than in the
* Human-readable label for a download state. Kept here rather than in the
* components so the downloads list and the picker never disagree about
* what a state is called.
*/
@@ -171,7 +171,7 @@ export function formatBytes(bytes: number): string {
* Per-transfer progress deliberately does not flow through here — that
* lives in the jobs registry, which already coalesces high-frequency
* updates into one event. This store handles the coarse changes: which
* providers exist, which requests exist, and what the user is being
* providers exist, which downloads exist, and what the user is being
* asked to choose between.
*/
class DownloadStore {
@@ -179,9 +179,9 @@ class DownloadStore {
private descriptorsValue: DownloadDescriptor[] = [];
private requestsValue: DownloadRequest[] = [];
private downloadsValue: DownloadView[] = [];
private wantsValue: Want[] = [];
private requestsValue: Request[] = [];
private subscribers = new Set<Subscriber>();
@@ -195,21 +195,21 @@ class DownloadStore {
});
EventsOn(Events.DownloadsChanged, () => {
void this.refreshRequests();
void this.refreshDownloads();
});
// The wanted list changes on its own — a background reconcile
// The request list changes on its own — a background reconcile
// pass expands an artist, retires something the library gained,
// or starts a download nobody asked for just now. So it is
// event-driven rather than fetched once on mount.
EventsOn(Events.WantedListChanged, () => {
void this.refreshWants();
EventsOn(Events.RequestsChanged, () => {
void this.refreshRequests();
});
}
/**
* Loads providers and requests once. Safe to call from every
* component's connectedCallback — subsequent calls are no-ops.
* Loads providers, downloads and requests once. Safe to call from
* every component's connectedCallback — subsequent calls are no-ops.
*/
async init(): Promise<void> {
if (this.initialized) return;
@@ -219,8 +219,8 @@ class DownloadStore {
await Promise.all([
this.refreshDescriptors(),
this.refreshProviders(),
this.refreshDownloads(),
this.refreshRequests(),
this.refreshWants(),
]);
}
@@ -238,12 +238,12 @@ class DownloadStore {
return this.descriptorsValue;
}
get requests(): DownloadRequest[] {
return this.requestsValue;
get downloads(): DownloadView[] {
return this.downloadsValue;
}
get activeRequests(): DownloadRequest[] {
return this.requestsValue.filter((r) => !isRequestTerminal(r));
get activeDownloads(): DownloadView[] {
return this.downloadsValue.filter((d) => !isDownloadTerminal(d));
}
/**
@@ -294,9 +294,9 @@ class DownloadStore {
}
}
async refreshRequests(): Promise<void> {
async refreshDownloads(): Promise<void> {
try {
this.requestsValue = (await ListRequests(50)) ?? [];
this.downloadsValue = (await ListDownloads(50)) ?? [];
this.notify();
} catch (err) {
console.error('Failed to load downloads:', err);
@@ -346,7 +346,7 @@ class DownloadStore {
}
// -----------------------------------------------------------------
// Requests
// Downloads (one search+grab attempt)
// -----------------------------------------------------------------
/**
@@ -355,94 +355,102 @@ class DownloadStore {
* the picker or just show progress.
*/
async start(request: download.SearchRequest): Promise<download.StartResult> {
const result = await Start(request);
const result = await StartDownload(request);
await this.refreshRequests();
await this.refreshDownloads();
return result;
}
async pick(requestId: string, candidateId: string): Promise<void> {
await Pick(requestId, candidateId);
await this.refreshRequests();
async pick(downloadId: string, candidateId: string): Promise<void> {
await Pick(downloadId, candidateId);
await this.refreshDownloads();
}
async cancel(requestId: string): Promise<void> {
await Cancel(requestId);
await this.refreshRequests();
async cancel(downloadId: string): Promise<void> {
await Cancel(downloadId);
await this.refreshDownloads();
}
async candidates(requestId: string): Promise<DownloadCandidate[]> {
return (await Candidates(requestId)) ?? [];
async candidates(downloadId: string): Promise<DownloadCandidate[]> {
return (await Candidates(downloadId)) ?? [];
}
async clearFinished(): Promise<void> {
await ClearFinished();
await this.refreshRequests();
await this.refreshDownloads();
}
// -----------------------------------------------------------------
// Wanted list
// Requests (durable "I asked for this")
// -----------------------------------------------------------------
get wants(): Want[] {
return this.wantsValue;
get requests(): Request[] {
return this.requestsValue;
}
/** Wants still being looked for. */
get activeWants(): Want[] {
return this.wantsValue.filter((w) => w.state === 'wanted');
/** Requests still being looked for. */
get activeRequests(): Request[] {
return this.requestsValue.filter((r) => r.state === 'wanted');
}
/** Artist subscriptions, which expand rather than download. */
get subscriptions(): Want[] {
return this.wantsValue.filter((w) => w.entity === 'artist');
get subscriptions(): Request[] {
return this.requestsValue.filter((r) => r.entity === 'artist');
}
async refreshWants(): Promise<void> {
async refreshRequests(): Promise<void> {
try {
this.wantsValue = (await ListWants()) ?? [];
this.requestsValue = (await ListRequests()) ?? [];
this.notify();
} catch (err) {
console.error('Failed to load the wanted list:', err);
console.error('Failed to load the requests list:', err);
}
}
/** True when this MBID is already on the list. */
isWanted(mbid: string): boolean {
/**
* True when this MBID is already requested.
*
* Checked against the locally cached request list rather than the
* `IsRequested` RPC: the list is already kept current via
* `RequestsChanged`, and a local lookup keeps this usable
* synchronously from render — the same shape callers relied on
* before the rename.
*/
isRequested(mbid: string): boolean {
const needle = mbid.trim().toLowerCase();
return this.wantsValue.some((w) => w.mbid === needle);
return this.requestsValue.some((r) => r.mbid === needle);
}
/** The want for an MBID, if it is on the list. */
wantFor(mbid: string): Want | undefined {
/** The request for an MBID, if one exists. */
requestFor(mbid: string): Request | undefined {
const needle = mbid.trim().toLowerCase();
return this.wantsValue.find((w) => w.mbid === needle);
return this.requestsValue.find((r) => r.mbid === needle);
}
async addWant(want: download.WantRequest): Promise<number> {
const id = await AddWant(want);
async addRequest(request: download.RequestInput): Promise<number> {
const id = await AddRequest(request);
await this.refreshWants();
await this.refreshRequests();
return id;
}
async removeWant(id: number): Promise<void> {
await RemoveWant(id);
await this.refreshWants();
async removeRequest(id: number): Promise<void> {
await RemoveRequest(id);
await this.refreshRequests();
}
async pauseWant(id: number, paused: boolean): Promise<void> {
await PauseWant(id, paused);
await this.refreshWants();
async pauseRequest(id: number, paused: boolean): Promise<void> {
await PauseRequest(id, paused);
await this.refreshRequests();
}
async clearSatisfiedWants(): Promise<void> {
await ClearSatisfiedWants();
await this.refreshWants();
async clearSatisfiedRequests(): Promise<void> {
await ClearSatisfiedRequests();
await this.refreshRequests();
}
/**
@@ -450,22 +458,22 @@ class DownloadStore {
* with what the pass did so the UI can say something concrete
* rather than just stopping its spinner.
*/
async reconcileWanted(): Promise<WantSummary> {
const summary = await ReconcileWanted();
async reconcileRequests(): Promise<RequestSummary> {
const summary = await ReconcileRequests();
await Promise.all([this.refreshWants(), this.refreshRequests()]);
await Promise.all([this.refreshRequests(), this.refreshDownloads()]);
return summary;
}
/** Adopts a provider's own list, e.g. Lidarr's monitored artists. */
async importExternalWants(
async importExternalRequests(
providerId: number,
libraryId: number,
): Promise<number> {
const count = await ImportExternalWants(providerId, libraryId);
const count = await ImportExternalRequests(providerId, libraryId);
await this.refreshWants();
await this.refreshRequests();
return count;
}
+5
View File
@@ -1,8 +1,11 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {download} from '../models';
import {tracklist} from '../models';
import {context} from '../models';
export function GetDownloadPreferences():Promise<download.AutoDownloadPrefs>;
export function GetFavoritesIconStyle():Promise<string>;
export function GetFavoritesPlaylistID():Promise<number>;
@@ -29,6 +32,8 @@ export function Save():Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetDownloadPreferences(arg1:download.AutoDownloadPrefs):Promise<void>;
export function SetFavoritesIconStyle(arg1:string):Promise<void>;
export function SetFavoritesPlaylistID(arg1:number):Promise<void>;
+8
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetDownloadPreferences() {
return window['go']['config']['Config']['GetDownloadPreferences']();
}
export function GetFavoritesIconStyle() {
return window['go']['config']['Config']['GetFavoritesIconStyle']();
}
@@ -54,6 +58,10 @@ export function SetContext(arg1) {
return window['go']['config']['Config']['SetContext'](arg1);
}
export function SetDownloadPreferences(arg1) {
return window['go']['config']['Config']['SetDownloadPreferences'](arg1);
}
export function SetFavoritesIconStyle(arg1) {
return window['go']['config']['Config']['SetFavoritesIconStyle'](arg1);
}
+13 -11
View File
@@ -5,7 +5,7 @@ import {context} from '../models';
export function AddProvider(arg1:string,arg2:string,arg3:Record<string, string>):Promise<number>;
export function AddWant(arg1:download.WantRequest):Promise<number>;
export function AddRequest(arg1:download.RequestInput):Promise<number>;
export function Cancel(arg1:string):Promise<void>;
@@ -13,35 +13,37 @@ export function Candidates(arg1:string):Promise<Array<download.Candidate>>;
export function ClearFinished():Promise<void>;
export function ClearSatisfiedWants():Promise<void>;
export function ClearSatisfiedRequests():Promise<void>;
export function DeleteProvider(arg1:number):Promise<void>;
export function ImportExternalWants(arg1:number,arg2:number):Promise<number>;
export function ImportExternalRequests(arg1:number,arg2:number):Promise<number>;
export function IsWanted(arg1:string,arg2:number):Promise<boolean>;
export function IsRequested(arg1:string,arg2:number):Promise<boolean>;
export function ListDownloads(arg1:number):Promise<Array<download.DownloadView>>;
export function ListProviders():Promise<Array<download.Config>>;
export function ListRequests(arg1:number):Promise<Array<download.RequestView>>;
export function ListRequests():Promise<Array<download.Request>>;
export function ListWants():Promise<Array<download.Want>>;
export function PauseWant(arg1:number,arg2:boolean):Promise<void>;
export function PauseRequest(arg1:number,arg2:boolean):Promise<void>;
export function Pick(arg1:string,arg2:string):Promise<void>;
export function ProviderKinds():Promise<Array<download.Descriptor>>;
export function ReconcileWanted():Promise<download.Summary>;
export function ReconcileRequests():Promise<download.Summary>;
export function RemoveWant(arg1:number):Promise<void>;
export function RemoveRequest(arg1:number):Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetPreferences(arg1:download.AutoDownloadPrefs):Promise<void>;
export function SetReconciler(arg1:download.Reconciler):Promise<void>;
export function Start(arg1:download.SearchRequest):Promise<download.StartResult>;
export function StartDownload(arg1:download.SearchRequest):Promise<download.StartResult>;
export function TestProvider(arg1:number):Promise<void>;
+26 -22
View File
@@ -6,8 +6,8 @@ export function AddProvider(arg1, arg2, arg3) {
return window['go']['download']['Service']['AddProvider'](arg1, arg2, arg3);
}
export function AddWant(arg1) {
return window['go']['download']['Service']['AddWant'](arg1);
export function AddRequest(arg1) {
return window['go']['download']['Service']['AddRequest'](arg1);
}
export function Cancel(arg1) {
@@ -22,36 +22,36 @@ export function ClearFinished() {
return window['go']['download']['Service']['ClearFinished']();
}
export function ClearSatisfiedWants() {
return window['go']['download']['Service']['ClearSatisfiedWants']();
export function ClearSatisfiedRequests() {
return window['go']['download']['Service']['ClearSatisfiedRequests']();
}
export function DeleteProvider(arg1) {
return window['go']['download']['Service']['DeleteProvider'](arg1);
}
export function ImportExternalWants(arg1, arg2) {
return window['go']['download']['Service']['ImportExternalWants'](arg1, arg2);
export function ImportExternalRequests(arg1, arg2) {
return window['go']['download']['Service']['ImportExternalRequests'](arg1, arg2);
}
export function IsWanted(arg1, arg2) {
return window['go']['download']['Service']['IsWanted'](arg1, arg2);
export function IsRequested(arg1, arg2) {
return window['go']['download']['Service']['IsRequested'](arg1, arg2);
}
export function ListDownloads(arg1) {
return window['go']['download']['Service']['ListDownloads'](arg1);
}
export function ListProviders() {
return window['go']['download']['Service']['ListProviders']();
}
export function ListRequests(arg1) {
return window['go']['download']['Service']['ListRequests'](arg1);
export function ListRequests() {
return window['go']['download']['Service']['ListRequests']();
}
export function ListWants() {
return window['go']['download']['Service']['ListWants']();
}
export function PauseWant(arg1, arg2) {
return window['go']['download']['Service']['PauseWant'](arg1, arg2);
export function PauseRequest(arg1, arg2) {
return window['go']['download']['Service']['PauseRequest'](arg1, arg2);
}
export function Pick(arg1, arg2) {
@@ -62,24 +62,28 @@ export function ProviderKinds() {
return window['go']['download']['Service']['ProviderKinds']();
}
export function ReconcileWanted() {
return window['go']['download']['Service']['ReconcileWanted']();
export function ReconcileRequests() {
return window['go']['download']['Service']['ReconcileRequests']();
}
export function RemoveWant(arg1) {
return window['go']['download']['Service']['RemoveWant'](arg1);
export function RemoveRequest(arg1) {
return window['go']['download']['Service']['RemoveRequest'](arg1);
}
export function SetContext(arg1) {
return window['go']['download']['Service']['SetContext'](arg1);
}
export function SetPreferences(arg1) {
return window['go']['download']['Service']['SetPreferences'](arg1);
}
export function SetReconciler(arg1) {
return window['go']['download']['Service']['SetReconciler'](arg1);
}
export function Start(arg1) {
return window['go']['download']['Service']['Start'](arg1);
export function StartDownload(arg1) {
return window['go']['download']['Service']['StartDownload'](arg1);
}
export function TestProvider(arg1) {
+146 -125
View File
@@ -297,12 +297,31 @@ export namespace autotagservice {
export namespace download {
export class AutoDownloadPrefs {
minSizeMb: number;
maxSizeMb: number;
preferredSizeMb: number;
allowedFormats: string[];
static createFrom(source: any = {}) {
return new AutoDownloadPrefs(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.minSizeMb = source["minSizeMb"];
this.maxSizeMb = source["maxSizeMb"];
this.preferredSizeMb = source["preferredSizeMb"];
this.allowedFormats = source["allowedFormats"];
}
}
export class QualityScore {
overall: number;
formatRank: number;
bitrate: number;
health: number;
priority: number;
sizeFit: number;
mixed: boolean;
static createFrom(source: any = {}) {
@@ -316,6 +335,7 @@ export namespace download {
this.bitrate = source["bitrate"];
this.health = source["health"];
this.priority = source["priority"];
this.sizeFit = source["sizeFit"];
this.mixed = source["mixed"];
}
}
@@ -534,30 +554,9 @@ export namespace download {
return a;
}
}
export class ExpectedTrack {
position: number;
discNumber: number;
title: string;
artist: string;
lengthMillis: number;
static createFrom(source: any = {}) {
return new ExpectedTrack(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.position = source["position"];
this.discNumber = source["discNumber"];
this.title = source["title"];
this.artist = source["artist"];
this.lengthMillis = source["lengthMillis"];
}
}
export class Item {
export class DownloadItem {
id: string;
requestId: string;
downloadId: string;
providerId: number;
transportId?: number;
externalId?: string;
@@ -570,13 +569,13 @@ export namespace download {
updatedAt: time.Time;
static createFrom(source: any = {}) {
return new Item(source);
return new DownloadItem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.requestId = source["requestId"];
this.downloadId = source["downloadId"];
this.providerId = source["providerId"];
this.transportId = source["transportId"];
this.externalId = source["externalId"];
@@ -607,26 +606,32 @@ export namespace download {
return a;
}
}
export class Reconciler {
export class ExpectedTrack {
position: number;
discNumber: number;
title: string;
artist: string;
lengthMillis: number;
static createFrom(source: any = {}) {
return new Reconciler(source);
return new ExpectedTrack(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.position = source["position"];
this.discNumber = source["discNumber"];
this.title = source["title"];
this.artist = source["artist"];
this.lengthMillis = source["lengthMillis"];
}
}
export class RequestView {
export class DownloadView {
id: string;
releaseMbid?: string;
releaseGroupMbid?: string;
recordingMbid?: string;
wantId?: number;
requestId?: number;
source?: string;
artist: string;
album: string;
@@ -636,10 +641,10 @@ export namespace download {
createdAt: time.Time;
state: string;
error?: string;
items: Item[];
items: DownloadItem[];
static createFrom(source: any = {}) {
return new RequestView(source);
return new DownloadView(source);
}
constructor(source: any = {}) {
@@ -648,7 +653,7 @@ export namespace download {
this.releaseMbid = source["releaseMbid"];
this.releaseGroupMbid = source["releaseGroupMbid"];
this.recordingMbid = source["recordingMbid"];
this.wantId = source["wantId"];
this.requestId = source["requestId"];
this.source = source["source"];
this.artist = source["artist"];
this.album = source["album"];
@@ -658,7 +663,7 @@ export namespace download {
this.createdAt = this.convertValues(source["createdAt"], time.Time);
this.state = source["state"];
this.error = source["error"];
this.items = this.convertValues(source["items"], Item);
this.items = this.convertValues(source["items"], DownloadItem);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -679,6 +684,108 @@ export namespace download {
return a;
}
}
export class Reconciler {
static createFrom(source: any = {}) {
return new Reconciler(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class Request {
id: number;
mbid: string;
entity: string;
libraryId: number;
artist: string;
title: string;
scope: string;
secondary: boolean;
state: string;
parentId?: number;
attempts: number;
lastError?: string;
lastTriedAt?: time.Time;
nextTryAt?: time.Time;
externalIds?: Record<string, string>;
createdAt: time.Time;
updatedAt: time.Time;
static createFrom(source: any = {}) {
return new Request(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.mbid = source["mbid"];
this.entity = source["entity"];
this.libraryId = source["libraryId"];
this.artist = source["artist"];
this.title = source["title"];
this.scope = source["scope"];
this.secondary = source["secondary"];
this.state = source["state"];
this.parentId = source["parentId"];
this.attempts = source["attempts"];
this.lastError = source["lastError"];
this.lastTriedAt = this.convertValues(source["lastTriedAt"], time.Time);
this.nextTryAt = this.convertValues(source["nextTryAt"], time.Time);
this.externalIds = source["externalIds"];
this.createdAt = this.convertValues(source["createdAt"], time.Time);
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class RequestInput {
mbid: string;
entity: string;
libraryId: number;
artist: string;
title: string;
scope: string;
secondary: boolean;
static createFrom(source: any = {}) {
return new RequestInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.mbid = source["mbid"];
this.entity = source["entity"];
this.libraryId = source["libraryId"];
this.artist = source["artist"];
this.title = source["title"];
this.scope = source["scope"];
this.secondary = source["secondary"];
}
}
export class SearchRequest {
libraryId: number;
releaseMbid: string;
@@ -722,7 +829,7 @@ export namespace download {
}
}
export class StartResult {
requestId: string;
downloadId: string;
candidates: Candidate[];
autoPicked: boolean;
@@ -732,7 +839,7 @@ export namespace download {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.requestId = source["requestId"];
this.downloadId = source["downloadId"];
this.candidates = this.convertValues(source["candidates"], Candidate);
this.autoPicked = source["autoPicked"];
}
@@ -775,92 +882,6 @@ export namespace download {
this.synced = source["synced"];
}
}
export class Want {
id: number;
mbid: string;
entity: string;
libraryId: number;
artist: string;
title: string;
scope: string;
secondary: boolean;
state: string;
parentId?: number;
attempts: number;
lastError?: string;
lastTriedAt?: time.Time;
nextTryAt?: time.Time;
externalIds?: Record<string, string>;
createdAt: time.Time;
updatedAt: time.Time;
static createFrom(source: any = {}) {
return new Want(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.mbid = source["mbid"];
this.entity = source["entity"];
this.libraryId = source["libraryId"];
this.artist = source["artist"];
this.title = source["title"];
this.scope = source["scope"];
this.secondary = source["secondary"];
this.state = source["state"];
this.parentId = source["parentId"];
this.attempts = source["attempts"];
this.lastError = source["lastError"];
this.lastTriedAt = this.convertValues(source["lastTriedAt"], time.Time);
this.nextTryAt = this.convertValues(source["nextTryAt"], time.Time);
this.externalIds = source["externalIds"];
this.createdAt = this.convertValues(source["createdAt"], time.Time);
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class WantRequest {
mbid: string;
entity: string;
libraryId: number;
artist: string;
title: string;
scope: string;
secondary: boolean;
static createFrom(source: any = {}) {
return new WantRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.mbid = source["mbid"];
this.entity = source["entity"];
this.libraryId = source["libraryId"];
this.artist = source["artist"];
this.title = source["title"];
this.scope = source["scope"];
this.secondary = source["secondary"];
}
}
}