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

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

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

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

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