diff --git a/backend/app.go b/backend/app.go index f447726..9f03aac 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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) } diff --git a/backend/config/config.go b/backend/config/config.go index 01e079e..e340f67 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -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 { diff --git a/backend/database/database.go b/backend/database/database.go index 530872c..f51f91e 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -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 } diff --git a/backend/database/download_rename_migration.go b/backend/database/download_rename_migration.go new file mode 100644 index 0000000..1e865aa --- /dev/null +++ b/backend/database/download_rename_migration.go @@ -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 +} diff --git a/backend/database/download_rename_migration_test.go b/backend/database/download_rename_migration_test.go new file mode 100644 index 0000000..4b5d940 --- /dev/null +++ b/backend/database/download_rename_migration_test.go @@ -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) + } +} diff --git a/backend/database/sql/queries/download.sql b/backend/database/sql/queries/download.sql index c55dbb1..22df883 100644 --- a/backend/database/sql/queries/download.sql +++ b/backend/database/sql/queries/download.sql @@ -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'; diff --git a/backend/database/sql/schemas/download_downloads.sql b/backend/database/sql/schemas/download_downloads.sql new file mode 100644 index 0000000..0e19c87 --- /dev/null +++ b/backend/database/sql/schemas/download_downloads.sql @@ -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); diff --git a/backend/database/sql/schemas/download_items.sql b/backend/database/sql/schemas/download_items.sql index eb974df..9021993 100644 --- a/backend/database/sql/schemas/download_items.sql +++ b/backend/database/sql/schemas/download_items.sql @@ -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. diff --git a/backend/database/sql/schemas/download_requests.sql b/backend/database/sql/schemas/download_requests.sql index 21981d9..f6cd928 100644 --- a/backend/database/sql/schemas/download_requests.sql +++ b/backend/database/sql/schemas/download_requests.sql @@ -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). diff --git a/backend/database/sql/schemas/download_wants.sql b/backend/database/sql/schemas/download_wants.sql deleted file mode 100644 index 9d59e56..0000000 --- a/backend/database/sql/schemas/download_wants.sql +++ /dev/null @@ -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); diff --git a/backend/database/sql/sqlcgen/download.sql.go b/backend/database/sql/sqlcgen/download.sql.go index 1760217..2601074 100644 --- a/backend/database/sql/sqlcgen/download.sql.go +++ b/backend/database/sql/sqlcgen/download.sql.go @@ -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, diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index ed4dd2a..46cfb90 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -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 diff --git a/backend/datamap/datamap.go b/backend/datamap/datamap.go index fd6b646..8fa87ef 100644 --- a/backend/datamap/datamap.go +++ b/backend/datamap/datamap.go @@ -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, diff --git a/backend/datamap/datamap_test.go b/backend/datamap/datamap_test.go index 6f48357..d89f3f4 100644 --- a/backend/datamap/datamap_test.go +++ b/backend/datamap/datamap_test.go @@ -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) { diff --git a/backend/download/concurrency_test.go b/backend/download/concurrency_test.go index 50780ef..a04c229 100644 --- a/backend/download/concurrency_test.go +++ b/backend/download/concurrency_test.go @@ -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( diff --git a/backend/download/config.go b/backend/download/config.go index 4c57435..bd3e7b2 100644 --- a/backend/download/config.go +++ b/backend/download/config.go @@ -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. diff --git a/backend/download/fake.go b/backend/download/fake.go index b483df5..e04d03f 100644 --- a/backend/download/fake.go +++ b/backend/download/fake.go @@ -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() diff --git a/backend/download/importer.go b/backend/download/importer.go index ee53af7..6754be5 100644 --- a/backend/download/importer.go +++ b/backend/download/importer.go @@ -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), ) } diff --git a/backend/download/importer_test.go b/backend/download/importer_test.go index 2f041ce..fbac0e7 100644 --- a/backend/download/importer_test.go +++ b/backend/download/importer_test.go @@ -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}, ) diff --git a/backend/download/manager.go b/backend/download/manager.go index ff55142..f9eb618 100644 --- a/backend/download/manager.go +++ b/backend/download/manager.go @@ -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) } }, diff --git a/backend/download/manager_test.go b/backend/download/manager_test.go index d583989..016830f 100644 --- a/backend/download/manager_test.go +++ b/backend/download/manager_test.go @@ -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 { diff --git a/backend/download/provider.go b/backend/download/provider.go index 3532ade..f748978 100644 --- a/backend/download/provider.go +++ b/backend/download/provider.go @@ -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. diff --git a/backend/download/provider_lidarr.go b/backend/download/provider_lidarr.go index 146d9ac..30a350d 100644 --- a/backend/download/provider_lidarr.go +++ b/backend/download/provider_lidarr.go @@ -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 diff --git a/backend/download/provider_lidarr_list.go b/backend/download/provider_lidarr_list.go index 769ab84..2769dec 100644 --- a/backend/download/provider_lidarr_list.go +++ b/backend/download/provider_lidarr_list.go @@ -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, diff --git a/backend/download/provider_lidarr_list_test.go b/backend/download/provider_lidarr_list_test.go index a2147c0..f488c24 100644 --- a/backend/download/provider_lidarr_list_test.go +++ b/backend/download/provider_lidarr_list_test.go @@ -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() diff --git a/backend/download/provider_lidarr_test.go b/backend/download/provider_lidarr_test.go index 7be6077..174bcde 100644 --- a/backend/download/provider_lidarr_test.go +++ b/backend/download/provider_lidarr_test.go @@ -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", }) diff --git a/backend/download/provider_prowlarr.go b/backend/download/provider_prowlarr.go index a205645..65e9cf2 100644 --- a/backend/download/provider_prowlarr.go +++ b/backend/download/provider_prowlarr.go @@ -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") diff --git a/backend/download/provider_prowlarr_test.go b/backend/download/provider_prowlarr_test.go index 7bc94b9..6637fa8 100644 --- a/backend/download/provider_prowlarr_test.go +++ b/backend/download/provider_prowlarr_test.go @@ -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) } diff --git a/backend/download/provider_slskd.go b/backend/download/provider_slskd.go index a78191d..02d1adb 100644 --- a/backend/download/provider_slskd.go +++ b/backend/download/provider_slskd.go @@ -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 { diff --git a/backend/download/provider_slskd_test.go b/backend/download/provider_slskd_test.go index 37d8a47..992e6d5 100644 --- a/backend/download/provider_slskd_test.go +++ b/backend/download/provider_slskd_test.go @@ -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) } diff --git a/backend/download/provider_ytdlp.go b/backend/download/provider_ytdlp.go index 08cc2a6..c12a9f4 100644 --- a/backend/download/provider_ytdlp.go +++ b/backend/download/provider_ytdlp.go @@ -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 == "" { diff --git a/backend/download/provider_ytdlp_test.go b/backend/download/provider_ytdlp_test.go index 0ea4ed6..34a2720 100644 --- a/backend/download/provider_ytdlp_test.go +++ b/backend/download/provider_ytdlp_test.go @@ -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", diff --git a/backend/download/rank.go b/backend/download/rank.go index 324706d..e094372 100644 --- a/backend/download/rank.go +++ b/backend/download/rank.go @@ -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 } diff --git a/backend/download/rank_test.go b/backend/download/rank_test.go index 639ef3c..9c7915b 100644 --- a/backend/download/rank_test.go +++ b/backend/download/rank_test.go @@ -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") + } +} diff --git a/backend/download/reconcile.go b/backend/download/reconcile.go index e40a1ab..79c2b9a 100644 --- a/backend/download/reconcile.go +++ b/backend/download/reconcile.go @@ -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 diff --git a/backend/download/reconcile_test.go b/backend/download/reconcile_test.go index 0eb11e4..e87c22a 100644 --- a/backend/download/reconcile_test.go +++ b/backend/download/reconcile_test.go @@ -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) } } diff --git a/backend/download/request.go b/backend/download/request.go new file mode 100644 index 0000000..2a51a1d --- /dev/null +++ b/backend/download/request.go @@ -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 +} diff --git a/backend/download/want_test.go b/backend/download/request_test.go similarity index 61% rename from backend/download/want_test.go rename to backend/download/request_test.go index c09b297..d53e7fc 100644 --- a/backend/download/want_test.go +++ b/backend/download/request_test.go @@ -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)) } } diff --git a/backend/download/requeststore.go b/backend/download/requeststore.go new file mode 100644 index 0000000..a61ad93 --- /dev/null +++ b/backend/download/requeststore.go @@ -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, + } +} diff --git a/backend/download/service.go b/backend/download/service.go index 83d8ebd..e7b905c 100644 --- a/backend/download/service.go +++ b/backend/download/service.go @@ -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, ) diff --git a/backend/download/service_test.go b/backend/download/service_test.go new file mode 100644 index 0000000..a90fe27 --- /dev/null +++ b/backend/download/service_test.go @@ -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") +} diff --git a/backend/download/store.go b/backend/download/store.go index ac8e714..9450abb 100644 --- a/backend/download/store.go +++ b/backend/download/store.go @@ -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, diff --git a/backend/download/types.go b/backend/download/types.go index 5472de7..c4e7886 100644 --- a/backend/download/types.go +++ b/backend/download/types.go @@ -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. diff --git a/backend/download/types_test.go b/backend/download/types_test.go index fc96e93..33abbb1 100644 --- a/backend/download/types_test.go +++ b/backend/download/types_test.go @@ -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) } }) diff --git a/backend/download/want.go b/backend/download/want.go deleted file mode 100644 index cfd5b0c..0000000 --- a/backend/download/want.go +++ /dev/null @@ -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 -} diff --git a/backend/download/wantstore.go b/backend/download/wantstore.go deleted file mode 100644 index 1eeb61f..0000000 --- a/backend/download/wantstore.go +++ /dev/null @@ -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, - } -} diff --git a/backend/events/events.go b/backend/events/events.go index b223c9e..a486300 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -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" ) diff --git a/frontend/index.ts b/frontend/index.ts index 985157e..481b30f 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -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 = { playlists: 'playlist-view', explore: 'explore-view', autotag: 'autotag-view', - wanted: 'wanted-view', + downloads: 'downloads-view', jobs: 'jobs-view', settings: 'config-page', }; diff --git a/frontend/src/components/config-page/download-clients.ts b/frontend/src/components/config-page/download-clients.ts index cc44a3d..2efa1d8 100644 --- a/frontend/src/components/config-page/download-clients.ts +++ b/frontend/src/components/config-page/download-clients.ts @@ -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 { + 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 { `} + + + ${this.prefsError + ? html`${this.prefsError}` + : nothing} + +
+
+ { + this.prefs = { + ...this.prefs, + minSizeMb: Number((e.target as HTMLInputElement).value) || 0, + }; + }} + > + { + this.prefs = { + ...this.prefs, + maxSizeMb: Number((e.target as HTMLInputElement).value) || 0, + }; + }} + > + { + this.prefs = { + ...this.prefs, + preferredSizeMb: + Number((e.target as HTMLInputElement).value) || 0, + }; + }} + > +
+ +
+
+ Allowed formats — leave all unchecked to allow any format. +
+
+ ${AUTO_DOWNLOAD_FORMATS.map( + (format) => html` + + `, + )} +
+
+ +
+ ${this.prefsSaved + ? html`Saved.` + : nothing} + + ${this.prefsSaving + ? html`` + : 'Save preferences'} + +
+
+
`; } @@ -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 { diff --git a/frontend/src/components/download-picker/download-picker.ts b/frontend/src/components/download-picker/download-picker.ts index 8b7c1b6..226ac63 100644 --- a/frontend/src/components/download-picker/download-picker.ts +++ b/frontend/src/components/download-picker/download-picker.ts @@ -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); diff --git a/frontend/src/components/downloads-view/downloads-view.ts b/frontend/src/components/downloads-view/downloads-view.ts new file mode 100644 index 0000000..971392d --- /dev/null +++ b/frontend/src/components/downloads-view/downloads-view.ts @@ -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` +
+

Downloads

+ ${this.tab === 'requests' + ? html` + void this.checkNow()} + > + + ${this.checking ? 'Checking…' : 'Check now'} + + ` + : nothing} +
+ +

+ 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. +

+ +
+
(this.tab = 'requests')} + > + Requests +
+
(this.tab = 'downloads')} + > + Downloads +
+
+ + ${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` +
+ + void downloadStore.clearSatisfiedRequests()} + > + Clear found + +
+ ` + : 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` +
+ Nothing requested yet. Use “Want this” on an album or artist + to add it here. +
+ `; + } + + 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` +

+ ${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'} +

+ `; + } + + private renderRequestSection( + title: string, + items: Request[], + renderer: (request: Request) => unknown, + ) { + if (items.length === 0) return nothing; + + return html` +

${title}

+ ${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` +
+ +
+
+ ${request.artist || request.title || request.mbid} +
+
+ ${request.scope === 'all' + ? 'Whole discography, plus new releases' + : 'New releases only'} +
+
+ Following +
+ void this.toggleScope(request)} + > + ${request.scope === 'all' ? 'New only' : 'Everything'} + + ${this.renderRemove(request)} +
+
+ `; + } + + private renderRequest(request: Request) { + return html` +
+ +
+
+ ${request.artist ? `${request.artist} — ` : ''}${request.title || + request.mbid} +
+
${requestDetail(request)}
+
+
+ ${request.state === 'satisfied' + ? nothing + : html` + + void downloadStore.pauseRequest( + request.id, + request.state !== 'paused', + )} + > + ${request.state === 'paused' ? 'Resume' : 'Pause'} + + `} + ${this.renderRemove(request)} +
+
+ `; + } + + private renderRemove(request: Request) { + return html` + void downloadStore.removeRequest(request.id)} + > + + + `; + } + + /** Widens or narrows what an artist subscription covers. */ + private async toggleScope(request: Request): Promise { + 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 { + 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` +
+ No downloads yet. Attempts made by "Download now" or the + background reconciler show up here. +
+ `; + } + + 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` +
+ +
+
${title}
+
${this.downloadDetail(view)}
+ ${view.error + ? html`
${view.error}
` + : nothing} +
+ ${stateLabel(view.state)} +
+ `; + } + + /** 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; + } +} diff --git a/frontend/src/components/explore-album-details/explore-album-details.ts b/frontend/src/components/explore-album-details/explore-album-details.ts index 4de21ff..13dfd37 100644 --- a/frontend/src/components/explore-album-details/explore-album-details.ts +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -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` void this.toggleWanted(want?.id)} + appearance=${this.isRequested ? 'filled' : 'outlined'} + @click=${() => void this.toggleRequested(request?.id)} > - ${this.isWanted ? 'Wanted' : 'Want this'} + ${this.isRequested ? 'Wanted' : 'Want this'} `; } - /** Resolves the library to attach downloads/wants to. */ + /** Resolves the library to attach downloads/requests to. */ private async resolveTargetLibraryId(): Promise { 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 { + private async toggleRequested(requestId: number | undefined): Promise { 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 { diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts index 63bbc44..c7a4f3f 100644 --- a/frontend/src/components/explore-artist-details/explore-artist-details.ts +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -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`
void this.toggleFollow(want?.id)} + appearance=${request ? 'filled' : 'outlined'} + @click=${() => void this.toggleFollow(request?.id)} > - ${want ? 'Following' : 'Follow for new releases'} + ${request ? 'Following' : 'Follow for new releases'}
`; } - private async toggleFollow(wantId: number | undefined): Promise { + private async toggleFollow(requestId: number | undefined): Promise { 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(); diff --git a/frontend/src/components/sidebar/app-sidebar.ts b/frontend/src/components/sidebar/app-sidebar.ts index 3ecfe70..4a9dd27 100644 --- a/frontend/src/components/sidebar/app-sidebar.ts +++ b/frontend/src/components/sidebar/app-sidebar.ts @@ -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' }, diff --git a/frontend/src/components/wanted-view/wanted-view.ts b/frontend/src/components/wanted-view/wanted-view.ts deleted file mode 100644 index ac237aa..0000000 --- a/frontend/src/components/wanted-view/wanted-view.ts +++ /dev/null @@ -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` -
-

Wanted

- void this.checkNow()} - > - - ${this.checking ? 'Checking…' : 'Check now'} - - ${satisfied.length > 0 - ? html` - - void downloadStore.clearSatisfiedWants()} - > - Clear found - - ` - : nothing} -
- -

- Music you want but do not have. Anything that cannot be found - stays here and is looked for again later. -

- - ${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` -
- Nothing wanted yet. Use “Want this” on an album or artist to - add it here. -
- `; - } - - 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` -

- ${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'} -

- `; - } - - private renderSection( - title: string, - items: Want[], - renderer: (want: Want) => unknown, - ) { - if (items.length === 0) return nothing; - - return html` -

${title}

- ${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` -
- -
-
${want.artist || want.title || want.mbid}
-
- ${want.scope === 'all' - ? 'Whole discography, plus new releases' - : 'New releases only'} -
-
- Following -
- void this.toggleScope(want)} - > - ${want.scope === 'all' ? 'New only' : 'Everything'} - - ${this.renderRemove(want)} -
-
- `; - } - - private renderWant(want: Want) { - return html` -
- -
-
- ${want.artist ? `${want.artist} — ` : ''}${want.title || - want.mbid} -
-
${wantDetail(want)}
-
-
- ${want.state === 'satisfied' - ? nothing - : html` - - void downloadStore.pauseWant( - want.id, - want.state !== 'paused', - )} - > - ${want.state === 'paused' ? 'Resume' : 'Pause'} - - `} - ${this.renderRemove(want)} -
-
- `; - } - - private renderRemove(want: Want) { - return html` - void downloadStore.removeWant(want.id)} - > - - - `; - } - - /** Widens or narrows what an artist subscription covers. */ - private async toggleScope(want: Want): Promise { - 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 { - 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; - } -} diff --git a/frontend/src/events.ts b/frontend/src/events.ts index 7e3f97e..ecc6c8a 100644 --- a/frontend/src/events.ts +++ b/frontend/src/events.ts @@ -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]; diff --git a/frontend/src/store/download-store.ts b/frontend/src/store/download-store.ts index 611c7bc..ef8806d 100644 --- a/frontend/src/store/download-store.ts +++ b/frontend/src/store/download-store.ts @@ -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 = 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(); @@ -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 { 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 { + async refreshDownloads(): Promise { 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 { - 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 { - await Pick(requestId, candidateId); - await this.refreshRequests(); + async pick(downloadId: string, candidateId: string): Promise { + await Pick(downloadId, candidateId); + await this.refreshDownloads(); } - async cancel(requestId: string): Promise { - await Cancel(requestId); - await this.refreshRequests(); + async cancel(downloadId: string): Promise { + await Cancel(downloadId); + await this.refreshDownloads(); } - async candidates(requestId: string): Promise { - return (await Candidates(requestId)) ?? []; + async candidates(downloadId: string): Promise { + return (await Candidates(downloadId)) ?? []; } async clearFinished(): Promise { 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 { + async refreshRequests(): Promise { 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 { - const id = await AddWant(want); + async addRequest(request: download.RequestInput): Promise { + const id = await AddRequest(request); - await this.refreshWants(); + await this.refreshRequests(); return id; } - async removeWant(id: number): Promise { - await RemoveWant(id); - await this.refreshWants(); + async removeRequest(id: number): Promise { + await RemoveRequest(id); + await this.refreshRequests(); } - async pauseWant(id: number, paused: boolean): Promise { - await PauseWant(id, paused); - await this.refreshWants(); + async pauseRequest(id: number, paused: boolean): Promise { + await PauseRequest(id, paused); + await this.refreshRequests(); } - async clearSatisfiedWants(): Promise { - await ClearSatisfiedWants(); - await this.refreshWants(); + async clearSatisfiedRequests(): Promise { + 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 { - const summary = await ReconcileWanted(); + async reconcileRequests(): Promise { + 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 { - const count = await ImportExternalWants(providerId, libraryId); + const count = await ImportExternalRequests(providerId, libraryId); - await this.refreshWants(); + await this.refreshRequests(); return count; } diff --git a/frontend/wailsjs/go/config/Config.d.ts b/frontend/wailsjs/go/config/Config.d.ts index 753213b..c763e0d 100755 --- a/frontend/wailsjs/go/config/Config.d.ts +++ b/frontend/wailsjs/go/config/Config.d.ts @@ -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; + export function GetFavoritesIconStyle():Promise; export function GetFavoritesPlaylistID():Promise; @@ -29,6 +32,8 @@ export function Save():Promise; export function SetContext(arg1:context.Context):Promise; +export function SetDownloadPreferences(arg1:download.AutoDownloadPrefs):Promise; + export function SetFavoritesIconStyle(arg1:string):Promise; export function SetFavoritesPlaylistID(arg1:number):Promise; diff --git a/frontend/wailsjs/go/config/Config.js b/frontend/wailsjs/go/config/Config.js index ae647ca..5c69205 100755 --- a/frontend/wailsjs/go/config/Config.js +++ b/frontend/wailsjs/go/config/Config.js @@ -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); } diff --git a/frontend/wailsjs/go/download/Service.d.ts b/frontend/wailsjs/go/download/Service.d.ts index 9979801..c3875ab 100755 --- a/frontend/wailsjs/go/download/Service.d.ts +++ b/frontend/wailsjs/go/download/Service.d.ts @@ -5,7 +5,7 @@ import {context} from '../models'; export function AddProvider(arg1:string,arg2:string,arg3:Record):Promise; -export function AddWant(arg1:download.WantRequest):Promise; +export function AddRequest(arg1:download.RequestInput):Promise; export function Cancel(arg1:string):Promise; @@ -13,35 +13,37 @@ export function Candidates(arg1:string):Promise>; export function ClearFinished():Promise; -export function ClearSatisfiedWants():Promise; +export function ClearSatisfiedRequests():Promise; export function DeleteProvider(arg1:number):Promise; -export function ImportExternalWants(arg1:number,arg2:number):Promise; +export function ImportExternalRequests(arg1:number,arg2:number):Promise; -export function IsWanted(arg1:string,arg2:number):Promise; +export function IsRequested(arg1:string,arg2:number):Promise; + +export function ListDownloads(arg1:number):Promise>; export function ListProviders():Promise>; -export function ListRequests(arg1:number):Promise>; +export function ListRequests():Promise>; -export function ListWants():Promise>; - -export function PauseWant(arg1:number,arg2:boolean):Promise; +export function PauseRequest(arg1:number,arg2:boolean):Promise; export function Pick(arg1:string,arg2:string):Promise; export function ProviderKinds():Promise>; -export function ReconcileWanted():Promise; +export function ReconcileRequests():Promise; -export function RemoveWant(arg1:number):Promise; +export function RemoveRequest(arg1:number):Promise; export function SetContext(arg1:context.Context):Promise; +export function SetPreferences(arg1:download.AutoDownloadPrefs):Promise; + export function SetReconciler(arg1:download.Reconciler):Promise; -export function Start(arg1:download.SearchRequest):Promise; +export function StartDownload(arg1:download.SearchRequest):Promise; export function TestProvider(arg1:number):Promise; diff --git a/frontend/wailsjs/go/download/Service.js b/frontend/wailsjs/go/download/Service.js index b11ea1d..4e41294 100755 --- a/frontend/wailsjs/go/download/Service.js +++ b/frontend/wailsjs/go/download/Service.js @@ -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) { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 80113f3..9d35b18 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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; + 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; - 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"]; - } - } }