refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
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:
+2
-1
@@ -273,6 +273,7 @@ func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
|
||||
PathTemplate: cfg.PathTemplate,
|
||||
})
|
||||
yj.downloads.SetMaxConcurrent(cfg.MaxConcurrent)
|
||||
yj.downloads.SetPreferences(cfg.AutoDownloadPrefs())
|
||||
|
||||
go func() {
|
||||
if err := yj.downloads.Reload(ctx); err != nil {
|
||||
@@ -289,7 +290,7 @@ func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
|
||||
yj.wanted.SetInterval(cfg.WantedInterval())
|
||||
yj.wanted.SetBatch(cfg.WantedBatch)
|
||||
yj.wanted.SetOnChange(func() {
|
||||
wailsruntime.EventsEmit(ctx, events.WantedListChanged)
|
||||
wailsruntime.EventsEmit(ctx, events.RequestsChanged)
|
||||
})
|
||||
yj.wanted.Start(ctx)
|
||||
}
|
||||
|
||||
@@ -364,6 +364,50 @@ func (c *Config) SetScanConcurrency(mode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDownloadPreferences returns the configured auto-download
|
||||
// guardrails.
|
||||
func (c *Config) GetDownloadPreferences() download.AutoDownloadPrefs {
|
||||
if c.Downloads == nil {
|
||||
return download.AutoDownloadPrefs{}
|
||||
}
|
||||
|
||||
return c.Downloads.AutoDownloadPrefs()
|
||||
}
|
||||
|
||||
// SetDownloadPreferences saves new auto-download guardrails. This only
|
||||
// persists them; the download package cannot depend on config (config
|
||||
// already depends on download for UserConfig), so making the change
|
||||
// live without a restart is the caller's job — the frontend settings
|
||||
// save calls this and download.Service.SetPreferences in the same
|
||||
// action, and app.go's initDownloadRuntime applies the saved value to
|
||||
// the running Manager at startup.
|
||||
func (c *Config) SetDownloadPreferences(prefs download.AutoDownloadPrefs) error {
|
||||
if c.Downloads == nil {
|
||||
c.Downloads = &download.UserConfig{}
|
||||
c.Downloads.ApplyDefaults()
|
||||
}
|
||||
|
||||
formats := make([]string, 0, len(prefs.AllowedFormats))
|
||||
for _, f := range prefs.AllowedFormats {
|
||||
formats = append(formats, string(f))
|
||||
}
|
||||
|
||||
c.Downloads.MinFileSizeMB = prefs.MinSizeMB
|
||||
c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB
|
||||
c.Downloads.PreferredFileSizeMB = prefs.PreferredSizeMB
|
||||
c.Downloads.AllowedFormats = formats
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
c.logger.Info("download auto-pick preferences updated")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetThemeAccentColor returns the configured accent colour.
|
||||
func (c *Config) GetThemeAccentColor() string {
|
||||
if c.Theme == nil {
|
||||
|
||||
@@ -306,6 +306,14 @@ func applySchema(ctx context.Context, db *sql.DB) error {
|
||||
return fmt.Errorf("could not apply migrations: %w", err)
|
||||
}
|
||||
|
||||
// The download subsystem's Want/Request rename reuses table names
|
||||
// (download_requests names a different table before and after), so
|
||||
// it cannot be a plain sql/migrations file the way an ADD COLUMN
|
||||
// migration can; see download_rename_migration.go for why.
|
||||
if err := migrateDownloadRename(ctx, db); err != nil {
|
||||
return fmt.Errorf("could not migrate download rename: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// migrateDownloadRename performs the download subsystem's table rename
|
||||
// for existing databases that still carry the old table names: the
|
||||
// durable "I asked for this" record moved from download_wants to
|
||||
// download_requests, and the one-shot search-and-grab attempt moved
|
||||
// from download_requests to download_downloads (see CLAUDE.md and
|
||||
// .planning/NOTES.md for the full Want->Request / Request->Download
|
||||
// rename).
|
||||
//
|
||||
// This cannot be a plain sql/migrations file the way an ADD COLUMN
|
||||
// migration is. That pattern's tolerance for "duplicate column name"
|
||||
// works because a fresh database's sql/schemas pass already produces
|
||||
// the identical target shape under the identical table name, so
|
||||
// replaying the ALTER TABLE against it is a safe no-op. Here the name
|
||||
// "download_requests" is reused for a different table before and after
|
||||
// the rename, so a fresh database's schema pass creates a real, empty,
|
||||
// correctly-shaped download_downloads AND a real, empty,
|
||||
// correctly-shaped (new) download_requests before this ever runs.
|
||||
// Blindly replaying "ALTER TABLE download_requests RENAME TO
|
||||
// download_downloads" against that fresh database would rename the new,
|
||||
// empty Request table into Download's place, destroying the fresh
|
||||
// install rather than no-opping. Gating on whether the OLD
|
||||
// download_wants table still exists — a name nothing creates or
|
||||
// references once this has run — is what tells an old database and a
|
||||
// fresh (or already migrated) one apart without executing anything
|
||||
// destructive on the fresh path.
|
||||
func migrateDownloadRename(ctx context.Context, db *sql.DB) error {
|
||||
var name string
|
||||
|
||||
err := db.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
||||
).Scan(&name)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// Nothing to migrate: either a fresh install (sql/schemas
|
||||
// already produced the target shape) or a database this has
|
||||
// already run against.
|
||||
case err != nil:
|
||||
return fmt.Errorf("check for download_wants table: %w", err)
|
||||
default:
|
||||
if err := runDownloadRename(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return ensureDownloadIndexes(ctx, db)
|
||||
}
|
||||
|
||||
// runDownloadRename performs the actual rename dance against a
|
||||
// database confirmed to still have the old download_wants table.
|
||||
func runDownloadRename(ctx context.Context, db *sql.DB) error {
|
||||
stmts := []string{
|
||||
// The schema pass already created an empty, correctly-shaped
|
||||
// download_downloads placeholder under this name (it never
|
||||
// existed under the old naming), which would otherwise collide
|
||||
// with the rename below.
|
||||
`DROP TABLE IF EXISTS download_downloads`,
|
||||
|
||||
// 1. Free the "download_requests" name: the old one-shot
|
||||
// attempt table becomes download_downloads.
|
||||
`ALTER TABLE download_requests RENAME TO download_downloads`,
|
||||
`ALTER TABLE download_downloads RENAME COLUMN want_id TO request_id`,
|
||||
|
||||
// 2. Claim the now-free "download_requests" name for the
|
||||
// durable-intent table.
|
||||
`ALTER TABLE download_wants RENAME TO download_requests`,
|
||||
|
||||
// 3. The transfer table's FK now points at download_downloads.
|
||||
`ALTER TABLE download_items RENAME COLUMN request_id TO download_id`,
|
||||
|
||||
// Named indexes survive a table/column rename attached to their
|
||||
// old name, so drop them here; ensureDownloadIndexes recreates
|
||||
// them under the names sql/schemas' comments describe.
|
||||
`DROP INDEX IF EXISTS idx_download_requests_created`,
|
||||
`DROP INDEX IF EXISTS idx_download_requests_state`,
|
||||
`DROP INDEX IF EXISTS idx_download_wants_due`,
|
||||
`DROP INDEX IF EXISTS idx_download_wants_entity`,
|
||||
`DROP INDEX IF EXISTS idx_download_wants_parent`,
|
||||
`DROP INDEX IF EXISTS idx_download_items_request`,
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin download rename migration: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("download rename migration %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit download rename migration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDownloadIndexes creates the indexes sql/schemas deliberately
|
||||
// omits inline for the renamed table/columns (see
|
||||
// migrateDownloadRename), under their final names. Safe to call
|
||||
// unconditionally: IF NOT EXISTS makes it a no-op once created, and by
|
||||
// the time this runs every column/table involved is guaranteed to be
|
||||
// in its final shape on both a fresh and a migrated database.
|
||||
func ensureDownloadIndexes(ctx context.Context, db *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_downloads_created
|
||||
ON download_downloads(created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_downloads_state
|
||||
ON download_downloads(state)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_due
|
||||
ON download_requests(next_try_at) WHERE state = 'wanted'`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_entity
|
||||
ON download_requests(entity, state)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_parent
|
||||
ON download_requests(parent_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_items_download
|
||||
ON download_items(download_id)`,
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("ensure download index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// oldDownloadRequestsDDL, oldDownloadWantsDDL and oldDownloadItemsDDL
|
||||
// are frozen snapshots of the download subsystem's tables exactly as
|
||||
// they read before the Want/Request rename (see
|
||||
// download_rename_migration.go) — i.e. what a real user's existing
|
||||
// database looks like today, before upgrading to a build that includes
|
||||
// this migration.
|
||||
const oldDownloadRequestsDDL = `
|
||||
CREATE TABLE IF NOT EXISTS download_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
library_id INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
|
||||
release_mbid TEXT,
|
||||
release_group_mbid TEXT,
|
||||
recording_mbid TEXT,
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
album TEXT NOT NULL DEFAULT '',
|
||||
query TEXT NOT NULL DEFAULT '',
|
||||
expected TEXT NOT NULL DEFAULT '[]',
|
||||
state TEXT NOT NULL DEFAULT 'searching'
|
||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
||||
'verifying', 'tagging', 'importing',
|
||||
'complete', 'cancelled', 'failed')),
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_created
|
||||
ON download_requests(created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_state
|
||||
ON download_requests(state);
|
||||
`
|
||||
|
||||
const oldDownloadWantsDDL = `
|
||||
CREATE TABLE IF NOT EXISTS download_wants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mbid TEXT NOT NULL,
|
||||
entity TEXT NOT NULL
|
||||
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
|
||||
library_id INTEGER NOT NULL,
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'future'
|
||||
CHECK(scope IN ('future', 'all')),
|
||||
secondary INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'wanted'
|
||||
CHECK(state IN ('wanted', 'satisfied', 'paused')),
|
||||
parent_id INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
last_tried_at DATETIME,
|
||||
next_try_at DATETIME,
|
||||
external_ids TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mbid, library_id),
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_wants_due
|
||||
ON download_wants(next_try_at)
|
||||
WHERE state = 'wanted';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
|
||||
ON download_wants(entity, state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
|
||||
ON download_wants(parent_id);
|
||||
`
|
||||
|
||||
const oldDownloadItemsDDL = `
|
||||
CREATE TABLE IF NOT EXISTS download_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
request_id TEXT NOT NULL,
|
||||
provider_id INTEGER NOT NULL,
|
||||
transport_id INTEGER,
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
candidate TEXT NOT NULL DEFAULT '{}',
|
||||
state TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
||||
'verifying', 'tagging', 'importing',
|
||||
'complete', 'cancelled', 'failed')),
|
||||
staging_dir TEXT NOT NULL DEFAULT '',
|
||||
bytes_done INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_total INTEGER NOT NULL DEFAULT 0,
|
||||
imported_paths TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_live
|
||||
ON download_items(state)
|
||||
WHERE state NOT IN ('complete', 'cancelled', 'failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_request
|
||||
ON download_items(request_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
||||
ON download_items(state);
|
||||
`
|
||||
|
||||
// seedOldDownloadSchema builds the pre-rename download tables and
|
||||
// inserts one row of real data into each, standing in for a real
|
||||
// user's database at the moment it upgrades.
|
||||
func seedOldDownloadSchema(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
for _, ddl := range []string{
|
||||
oldDownloadWantsDDL, oldDownloadRequestsDDL, oldDownloadItemsDDL,
|
||||
} {
|
||||
if _, err := db.ExecContext(t.Context(), ddl); err != nil {
|
||||
t.Fatalf("create old download schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
t.Context(),
|
||||
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed library: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
t.Context(),
|
||||
`INSERT INTO download_wants
|
||||
(id, mbid, entity, library_id, artist, title, state)
|
||||
VALUES (1, 'artist-mbid', 'artist', 1, 'Radiohead', 'Radiohead', 'wanted')`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed download_wants: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
t.Context(),
|
||||
`INSERT INTO download_requests
|
||||
(id, library_id, source, want_id, release_group_mbid, artist, album, state)
|
||||
VALUES ('dl-1', 1, 'wanted', 1, 'rg-mbid', 'Radiohead', 'OK Computer', 'complete')`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed download_requests: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
t.Context(),
|
||||
`INSERT INTO download_items
|
||||
(id, request_id, provider_id, state)
|
||||
VALUES ('item-1', 'dl-1', 1, 'complete')`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed download_items: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRename_FreshInstallUntouched confirms applySchema on a
|
||||
// brand-new database produces the target shape directly and that
|
||||
// migrateDownloadRename's gate (checking for the old download_wants
|
||||
// table) is a no-op there — the destructive path this test guards
|
||||
// against is exactly the one described in download_rename_migration.go:
|
||||
// blindly replaying the rename against a fresh database's already-
|
||||
// correct, empty download_requests/download_downloads tables.
|
||||
func TestDownloadRename_FreshInstallUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := openMemDB(t)
|
||||
|
||||
if err := applySchema(t.Context(), db); err != nil {
|
||||
t.Fatalf("apply schema (fresh): %v", err)
|
||||
}
|
||||
|
||||
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
||||
var name string
|
||||
|
||||
err := db.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
||||
table,
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("expected table %q to exist on a fresh install: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
var stray string
|
||||
|
||||
err := db.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
||||
).Scan(&stray)
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("old download_wants table should not exist on a fresh install, err=%v", err)
|
||||
}
|
||||
|
||||
// Both auto-download guardrail indexes sql/schemas deliberately
|
||||
// omits (see ensureDownloadIndexes) must still exist.
|
||||
for _, idx := range []string{
|
||||
"idx_download_requests_due",
|
||||
"idx_download_requests_entity",
|
||||
"idx_download_requests_parent",
|
||||
"idx_download_items_download",
|
||||
} {
|
||||
var name string
|
||||
|
||||
err := db.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`,
|
||||
idx,
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("expected index %q to exist on a fresh install: %v", idx, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRename_UpgradesExistingDatabase is the regression test
|
||||
// for the rename itself: an old-shaped database (download_wants +
|
||||
// old-style download_requests, both with real rows) must end up with
|
||||
// the same table names, column names, and data a fresh install would
|
||||
// have — nothing dropped, nothing silently emptied.
|
||||
func TestDownloadRename_UpgradesExistingDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fresh := openMemDB(t)
|
||||
if err := applySchema(t.Context(), fresh); err != nil {
|
||||
t.Fatalf("apply schema (fresh): %v", err)
|
||||
}
|
||||
|
||||
upgraded := openMemDB(t)
|
||||
|
||||
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read libraries schema: %v", err)
|
||||
}
|
||||
|
||||
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
|
||||
t.Fatalf("create libraries table: %v", err)
|
||||
}
|
||||
|
||||
seedOldDownloadSchema(t, upgraded)
|
||||
|
||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
||||
t.Fatalf("apply schema (upgrade path): %v", err)
|
||||
}
|
||||
|
||||
// Column order must match a fresh install's, for the same reason
|
||||
// TestMigrations_ColumnOrderMatchesFreshInstall checks tagging_items:
|
||||
// sqlc's `SELECT *` binds positionally.
|
||||
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
||||
freshCols := tableColumns(t, fresh, table)
|
||||
upgradedCols := tableColumns(t, upgraded, table)
|
||||
|
||||
if len(freshCols) != len(upgradedCols) {
|
||||
t.Fatalf(
|
||||
"%s: column count mismatch: fresh has %d (%v), upgraded has %d (%v)",
|
||||
table, len(freshCols), freshCols, len(upgradedCols), upgradedCols,
|
||||
)
|
||||
}
|
||||
|
||||
for i := range freshCols {
|
||||
if freshCols[i] != upgradedCols[i] {
|
||||
t.Errorf(
|
||||
"%s: column order mismatch at %d: fresh %q, upgraded %q\nfresh: %v\nupgraded: %v",
|
||||
table,
|
||||
i,
|
||||
freshCols[i],
|
||||
upgradedCols[i],
|
||||
freshCols,
|
||||
upgradedCols,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The seeded rows survived the rename under their new names.
|
||||
var (
|
||||
requestMBID string
|
||||
requestEntity string
|
||||
)
|
||||
|
||||
err = upgraded.QueryRowContext(
|
||||
t.Context(), `SELECT mbid, entity FROM download_requests WHERE id = 1`,
|
||||
).Scan(&requestMBID, &requestEntity)
|
||||
if err != nil {
|
||||
t.Fatalf("seeded request row missing after rename: %v", err)
|
||||
}
|
||||
|
||||
if requestMBID != "artist-mbid" || requestEntity != "artist" {
|
||||
t.Errorf("request row corrupted: mbid=%q entity=%q", requestMBID, requestEntity)
|
||||
}
|
||||
|
||||
var (
|
||||
downloadRequestID sql.NullInt64
|
||||
downloadAlbum string
|
||||
)
|
||||
|
||||
err = upgraded.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT request_id, album FROM download_downloads WHERE id = 'dl-1'`,
|
||||
).Scan(&downloadRequestID, &downloadAlbum)
|
||||
if err != nil {
|
||||
t.Fatalf("seeded download row missing after rename: %v", err)
|
||||
}
|
||||
|
||||
if !downloadRequestID.Valid || downloadRequestID.Int64 != 1 {
|
||||
t.Errorf("download.request_id = %v, want 1 (renamed from want_id)", downloadRequestID)
|
||||
}
|
||||
|
||||
if downloadAlbum != "OK Computer" {
|
||||
t.Errorf("download.album = %q, want OK Computer", downloadAlbum)
|
||||
}
|
||||
|
||||
var itemDownloadID string
|
||||
|
||||
err = upgraded.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT download_id FROM download_items WHERE id = 'item-1'`,
|
||||
).Scan(&itemDownloadID)
|
||||
if err != nil {
|
||||
t.Fatalf("seeded item row missing after rename: %v", err)
|
||||
}
|
||||
|
||||
if itemDownloadID != "dl-1" {
|
||||
t.Errorf("item.download_id = %q, want dl-1 (renamed from request_id)", itemDownloadID)
|
||||
}
|
||||
|
||||
// The old table is gone, not just emptied.
|
||||
var stray string
|
||||
|
||||
err = upgraded.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
||||
).Scan(&stray)
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("old download_wants table should be gone after migration, err=%v", err)
|
||||
}
|
||||
|
||||
// Running the whole thing again (as a second app startup would) is
|
||||
// a no-op: the gate sees no download_wants table and does nothing
|
||||
// further, so this must not error or duplicate anything.
|
||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
||||
t.Fatalf("apply schema a second time: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
|
||||
if err := upgraded.QueryRowContext(
|
||||
t.Context(), `SELECT COUNT(*) FROM download_requests`,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count download_requests: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("download_requests has %d rows after a second migration pass, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -22,69 +22,81 @@ WHERE id = ?;
|
||||
DELETE FROM download_providers
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: CreateDownloadRequest :exec
|
||||
INSERT INTO download_requests (
|
||||
id, library_id, source, want_id, release_mbid, release_group_mbid,
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Downloads (one-shot search+grab attempts)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- name: CreateDownload :exec
|
||||
INSERT INTO download_downloads (
|
||||
id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetDownloadRequest :one
|
||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
||||
-- name: GetDownload :one
|
||||
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state, error,
|
||||
created_at, updated_at
|
||||
FROM download_requests
|
||||
FROM download_downloads
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListDownloadRequests :many
|
||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
||||
-- name: ListDownloads :many
|
||||
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state, error,
|
||||
created_at, updated_at
|
||||
FROM download_requests
|
||||
FROM download_downloads
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?;
|
||||
|
||||
-- name: ListLiveDownloadRequests :many
|
||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
||||
-- name: ListLiveDownloads :many
|
||||
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state, error,
|
||||
created_at, updated_at
|
||||
FROM download_requests
|
||||
FROM download_downloads
|
||||
WHERE state NOT IN ('complete', 'cancelled', 'failed')
|
||||
ORDER BY created_at;
|
||||
|
||||
-- name: SetDownloadRequestState :exec
|
||||
UPDATE download_requests
|
||||
-- name: SetDownloadState :exec
|
||||
UPDATE download_downloads
|
||||
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteDownloadRequest :exec
|
||||
DELETE FROM download_requests
|
||||
-- name: DeleteDownload :exec
|
||||
DELETE FROM download_downloads
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteFinishedDownloads :exec
|
||||
DELETE FROM download_downloads
|
||||
WHERE state IN ('complete', 'cancelled', 'failed');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Items (transfer records within a download)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- name: CreateDownloadItem :exec
|
||||
INSERT INTO download_items (
|
||||
id, request_id, provider_id, transport_id, external_id,
|
||||
id, download_id, provider_id, transport_id, external_id,
|
||||
candidate, state, staging_dir, bytes_total
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetDownloadItem :one
|
||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
||||
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||
error, created_at, updated_at
|
||||
FROM download_items
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListDownloadItemsForRequest :many
|
||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
||||
-- name: ListDownloadItemsForDownload :many
|
||||
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||
error, created_at, updated_at
|
||||
FROM download_items
|
||||
WHERE request_id = ?
|
||||
WHERE download_id = ?
|
||||
ORDER BY created_at;
|
||||
|
||||
-- name: ListLiveDownloadItems :many
|
||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
||||
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||
error, created_at, updated_at
|
||||
FROM download_items
|
||||
@@ -112,72 +124,68 @@ SET imported_paths = ?, state = 'complete', error = '',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteFinishedDownloadRequests :exec
|
||||
DELETE FROM download_requests
|
||||
WHERE state IN ('complete', 'cancelled', 'failed');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Wants
|
||||
-- Requests (durable "I asked for this" records)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- name: UpsertDownloadWant :one
|
||||
-- Adding something already wanted is not an error and must not reset
|
||||
-- the retry clock, so the conflict path only refreshes display text and
|
||||
-- un-pauses nothing. scope and secondary are updated because asking
|
||||
-- again with a wider scope is a real change of intent.
|
||||
INSERT INTO download_wants (
|
||||
-- name: UpsertDownloadRequest :one
|
||||
-- Adding something already requested is not an error and must not
|
||||
-- reset the retry clock, so the conflict path only refreshes display
|
||||
-- text and un-pauses nothing. scope and secondary are updated because
|
||||
-- asking again with a wider scope is a real change of intent.
|
||||
INSERT INTO download_requests (
|
||||
mbid, entity, library_id, artist, title, scope, secondary,
|
||||
parent_id, next_try_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(mbid, library_id) DO UPDATE SET
|
||||
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
|
||||
ELSE download_wants.artist END,
|
||||
ELSE download_requests.artist END,
|
||||
title = CASE WHEN excluded.title <> '' THEN excluded.title
|
||||
ELSE download_wants.title END,
|
||||
ELSE download_requests.title END,
|
||||
scope = excluded.scope,
|
||||
secondary = excluded.secondary,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING id;
|
||||
|
||||
-- name: GetDownloadWant :one
|
||||
SELECT * FROM download_wants WHERE id = ?;
|
||||
-- name: GetDownloadRequest :one
|
||||
SELECT * FROM download_requests WHERE id = ?;
|
||||
|
||||
-- name: GetDownloadWantByMBID :one
|
||||
SELECT * FROM download_wants WHERE mbid = ? AND library_id = ?;
|
||||
-- name: GetDownloadRequestByMBID :one
|
||||
SELECT * FROM download_requests WHERE mbid = ? AND library_id = ?;
|
||||
|
||||
-- name: ListDownloadWants :many
|
||||
SELECT * FROM download_wants
|
||||
-- name: ListDownloadRequests :many
|
||||
SELECT * FROM download_requests
|
||||
ORDER BY
|
||||
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
|
||||
artist, title;
|
||||
|
||||
-- name: ListDownloadWantsByEntity :many
|
||||
SELECT * FROM download_wants
|
||||
-- name: ListDownloadRequestsByEntity :many
|
||||
SELECT * FROM download_requests
|
||||
WHERE entity = ? AND state = ?
|
||||
ORDER BY id;
|
||||
|
||||
-- name: ListDueDownloadWants :many
|
||||
-- name: ListDueDownloadRequests :many
|
||||
-- Everything the reconciler should act on this pass: wanted, not an
|
||||
-- artist subscription (those expand rather than download), and either
|
||||
-- never tried or past its backoff.
|
||||
SELECT * FROM download_wants
|
||||
SELECT * FROM download_requests
|
||||
WHERE state = 'wanted'
|
||||
AND entity <> 'artist'
|
||||
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
|
||||
ORDER BY attempts, created_at
|
||||
LIMIT ?;
|
||||
|
||||
-- name: ListChildDownloadWants :many
|
||||
SELECT * FROM download_wants WHERE parent_id = ? ORDER BY id;
|
||||
-- name: ListChildDownloadRequests :many
|
||||
SELECT * FROM download_requests WHERE parent_id = ? ORDER BY id;
|
||||
|
||||
-- name: SetDownloadWantState :exec
|
||||
UPDATE download_wants
|
||||
-- name: SetDownloadRequestState :exec
|
||||
UPDATE download_requests
|
||||
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: RecordDownloadWantAttempt :exec
|
||||
UPDATE download_wants
|
||||
-- name: RecordDownloadRequestAttempt :exec
|
||||
UPDATE download_requests
|
||||
SET attempts = attempts + 1,
|
||||
last_error = ?,
|
||||
last_tried_at = CURRENT_TIMESTAMP,
|
||||
@@ -185,19 +193,19 @@ SET attempts = attempts + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: SatisfyDownloadWant :exec
|
||||
UPDATE download_wants
|
||||
-- name: SatisfyDownloadRequest :exec
|
||||
UPDATE download_requests
|
||||
SET state = 'satisfied', last_error = '', next_try_at = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: SetDownloadWantExternalIDs :exec
|
||||
UPDATE download_wants
|
||||
-- name: SetDownloadRequestExternalIDs :exec
|
||||
UPDATE download_requests
|
||||
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteDownloadWant :exec
|
||||
DELETE FROM download_wants WHERE id = ?;
|
||||
-- name: DeleteDownloadRequest :exec
|
||||
DELETE FROM download_requests WHERE id = ?;
|
||||
|
||||
-- name: DeleteSatisfiedDownloadWants :exec
|
||||
DELETE FROM download_wants WHERE state = 'satisfied';
|
||||
-- name: DeleteSatisfiedDownloadRequests :exec
|
||||
DELETE FROM download_requests WHERE state = 'satisfied';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- One row per "go find me this", from the moment a search is fired
|
||||
-- until the files are in the library or the attempt is abandoned. A
|
||||
-- Download is one attempt: it searches, it grabs, it succeeds or
|
||||
-- fails, and then it is history. See download_requests.sql for the
|
||||
-- durable record a Download may be attached to.
|
||||
--
|
||||
-- release_mbid / release_group_mbid are the anchor: a download that
|
||||
-- carries one can be matched against a known tracklist at import time,
|
||||
-- which is what makes unattended completion safe. Free-text downloads
|
||||
-- (both NULL) are always presented to the user for confirmation.
|
||||
--
|
||||
-- `expected` caches the anchor's tracklist as JSON so ranking and
|
||||
-- import do not have to re-resolve it, and so a download survives the
|
||||
-- explore index being rebuilt underneath it.
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_downloads (
|
||||
id TEXT PRIMARY KEY,
|
||||
library_id INTEGER NOT NULL,
|
||||
-- source records where the download came from: 'explore-album',
|
||||
-- 'explore-artist', 'missing-album', 'wanted', 'manual'.
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
-- request_id is set when this download is attached to a durable
|
||||
-- Request (see download_requests.sql), whether raised by the
|
||||
-- reconciler or attached to a manual anchored download. NULL for
|
||||
-- a free-text download with nothing stable to attach to.
|
||||
-- Requests are durable and downloads are disposable, so the delete
|
||||
-- is a SET NULL rather than a cascade in either direction.
|
||||
request_id INTEGER REFERENCES download_requests(id) ON DELETE SET NULL,
|
||||
release_mbid TEXT,
|
||||
release_group_mbid TEXT,
|
||||
-- recording_mbid anchors a single-track download raised from a
|
||||
-- track-level request.
|
||||
recording_mbid TEXT,
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
album TEXT NOT NULL DEFAULT '',
|
||||
query TEXT NOT NULL DEFAULT '',
|
||||
expected TEXT NOT NULL DEFAULT '[]',
|
||||
state TEXT NOT NULL DEFAULT 'searching'
|
||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
||||
'verifying', 'tagging', 'importing',
|
||||
'complete', 'cancelled', 'failed')),
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_downloads_created
|
||||
ON download_downloads(created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_downloads_state
|
||||
ON download_downloads(state);
|
||||
@@ -1,4 +1,4 @@
|
||||
-- One row per grab attempt against one candidate. A request can have
|
||||
-- One row per grab attempt against one candidate. A download can have
|
||||
-- several: the first pick stalls, the user picks another, or a
|
||||
-- search-only provider's candidate is fetched by a separate transport
|
||||
-- (in which case provider_id is the searcher and transport_id is the
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
request_id TEXT NOT NULL,
|
||||
download_id TEXT NOT NULL,
|
||||
provider_id INTEGER NOT NULL,
|
||||
transport_id INTEGER,
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
@@ -35,15 +35,18 @@ CREATE TABLE IF NOT EXISTS download_items (
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
||||
FOREIGN KEY(download_id) REFERENCES download_downloads(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_live
|
||||
ON download_items(state)
|
||||
WHERE state NOT IN ('complete', 'cancelled', 'failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_request
|
||||
ON download_items(request_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
||||
ON download_items(state);
|
||||
|
||||
-- idx_download_items_download is deliberately NOT declared here: on an
|
||||
-- existing database this table already exists at schema-pass time with
|
||||
-- its old column still named request_id, so an inline CREATE INDEX on
|
||||
-- download_id would fail outright. See ensureDownloadIndexes in
|
||||
-- backend/database/download_rename_migration.go.
|
||||
|
||||
@@ -1,49 +1,79 @@
|
||||
-- One row per "go find me this", from the moment the user asks until
|
||||
-- the files are in the library or the attempt is abandoned.
|
||||
-- A Request is a persistent "I want this", stored as a MusicBrainz ID
|
||||
-- and almost nothing else. It outlives every download attempt made on
|
||||
-- its behalf: nothing being findable today is the normal case for
|
||||
-- obscure music, and the correct response is to try again next week,
|
||||
-- not to show the user a failed row they have to remember to retry.
|
||||
--
|
||||
-- release_mbid / release_group_mbid are the anchor: a request that
|
||||
-- carries one can be matched against a known tracklist at import time,
|
||||
-- which is what makes unattended completion safe. Free-text requests
|
||||
-- (both NULL) are always presented to the user for confirmation.
|
||||
--
|
||||
-- `expected` caches the anchor's tracklist as JSON so ranking and
|
||||
-- import do not have to re-resolve it, and so a request survives the
|
||||
-- explore index being rebuilt underneath it.
|
||||
-- Because a Request is only an MBID, it stays true when everything
|
||||
-- around it changes: the explore index is rebuilt, a provider is
|
||||
-- swapped out, the release the user originally saw is superseded by a
|
||||
-- remaster. The display fields are a cache for the list view and are
|
||||
-- never consulted for matching.
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
library_id INTEGER NOT NULL,
|
||||
-- source records where the request came from: 'explore-album',
|
||||
-- 'explore-artist', 'missing-album', 'wanted', 'manual'.
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
-- want_id is set when the reconciler raised this request from the
|
||||
-- wanted list, so the outcome can be written back to the want.
|
||||
-- NULL for one-off requests the user started by hand. Requests are
|
||||
-- disposable and wants are not, so the delete is a SET NULL rather
|
||||
-- than a cascade in either direction.
|
||||
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
|
||||
release_mbid TEXT,
|
||||
release_group_mbid TEXT,
|
||||
-- recording_mbid anchors a single-track request raised from a
|
||||
-- track-level want.
|
||||
recording_mbid TEXT,
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
album TEXT NOT NULL DEFAULT '',
|
||||
query TEXT NOT NULL DEFAULT '',
|
||||
expected TEXT NOT NULL DEFAULT '[]',
|
||||
state TEXT NOT NULL DEFAULT 'searching'
|
||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
||||
'verifying', 'tagging', 'importing',
|
||||
'complete', 'cancelled', 'failed')),
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mbid TEXT NOT NULL,
|
||||
entity TEXT NOT NULL
|
||||
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
|
||||
library_id INTEGER NOT NULL,
|
||||
|
||||
-- Display text, cached so the list renders without touching the
|
||||
-- explore index. Neither is authoritative; the MBID is.
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
|
||||
scope TEXT NOT NULL DEFAULT 'future'
|
||||
CHECK(scope IN ('future', 'all')),
|
||||
|
||||
-- secondary controls whether an artist request's expansion includes
|
||||
-- compilations, live albums and remixes. Off by default: someone
|
||||
-- subscribing to an artist wants the albums, not six versions of
|
||||
-- the same greatest-hits package.
|
||||
secondary INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
state TEXT NOT NULL DEFAULT 'wanted'
|
||||
CHECK(state IN ('wanted', 'satisfied', 'paused')),
|
||||
|
||||
-- parent_id links a request the reconciler derived from an artist
|
||||
-- request. Deleting the artist takes its derived children with
|
||||
-- it, but children the user pinned themselves have no parent and
|
||||
-- stay.
|
||||
parent_id INTEGER,
|
||||
|
||||
-- Retry bookkeeping. attempts drives the backoff; last_error is
|
||||
-- the most recent reason it did not work out, which for a request
|
||||
-- is information rather than a failure.
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
last_tried_at DATETIME,
|
||||
next_try_at DATETIME,
|
||||
|
||||
-- external_ids maps provider row ID to that provider's own
|
||||
-- identifier for this request, for clients that keep their own
|
||||
-- persistent list (a Lidarr artist ID). JSON object.
|
||||
external_ids TEXT NOT NULL DEFAULT '{}',
|
||||
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- One request per thing per library. Asking twice is not two
|
||||
-- requests, and this is what lets an artist expansion re-run every
|
||||
-- reconcile without accumulating duplicates.
|
||||
UNIQUE(mbid, library_id),
|
||||
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(parent_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_created
|
||||
ON download_requests(created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_state
|
||||
ON download_requests(state);
|
||||
-- idx_download_requests_{due,entity,parent} are deliberately NOT
|
||||
-- declared here. This table name is reused from the old one-shot
|
||||
-- attempt table (also called download_requests before the Want/Request
|
||||
-- rename), so on an existing database this CREATE TABLE is a no-op
|
||||
-- against a table that, at schema-pass time, is still shaped like the
|
||||
-- OLD attempts table and lacks these columns entirely — an inline
|
||||
-- CREATE INDEX here would fail outright rather than just no-op. See
|
||||
-- migrateDownloadRename/ensureDownloadIndexes in
|
||||
-- backend/database/download_rename_migration.go, which create these
|
||||
-- once the rename has actually happened (or immediately, on a fresh
|
||||
-- database where the columns exist from the start).
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
-- One row per "go find me this", from the moment the user asks until
|
||||
-- the files are in the library or the attempt is abandoned.
|
||||
--
|
||||
-- release_mbid / release_group_mbid are the anchor: a request that
|
||||
-- carries one can be matched against a known tracklist at import time,
|
||||
-- which is what makes unattended completion safe. Free-text requests
|
||||
-- (both NULL) are always presented to the user for confirmation.
|
||||
--
|
||||
-- `expected` caches the anchor's tracklist as JSON so ranking and
|
||||
-- import do not have to re-resolve it, and so a request survives the
|
||||
-- explore index being rebuilt underneath it.
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_wants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mbid TEXT NOT NULL,
|
||||
entity TEXT NOT NULL
|
||||
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
|
||||
library_id INTEGER NOT NULL,
|
||||
|
||||
-- Display text, cached so the wanted list renders without touching
|
||||
-- the explore index. Neither is authoritative; the MBID is.
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
|
||||
scope TEXT NOT NULL DEFAULT 'future'
|
||||
CHECK(scope IN ('future', 'all')),
|
||||
|
||||
-- secondary controls whether an artist want's expansion includes
|
||||
-- compilations, live albums and remixes. Off by default: someone
|
||||
-- subscribing to an artist wants the albums, not six versions of
|
||||
-- the same greatest-hits package.
|
||||
secondary INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
state TEXT NOT NULL DEFAULT 'wanted'
|
||||
CHECK(state IN ('wanted', 'satisfied', 'paused')),
|
||||
|
||||
-- parent_id links a want the reconciler derived from an artist
|
||||
-- want. Deleting the artist takes its derived children with it,
|
||||
-- but children the user pinned themselves have no parent and stay.
|
||||
parent_id INTEGER,
|
||||
|
||||
-- Retry bookkeeping. attempts drives the backoff; last_error is
|
||||
-- the most recent reason it did not work out, which for a wanted
|
||||
-- item is information rather than a failure.
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
last_tried_at DATETIME,
|
||||
next_try_at DATETIME,
|
||||
|
||||
-- external_ids maps provider row ID to that provider's own
|
||||
-- identifier for this want, for clients that keep their own
|
||||
-- persistent list (a Lidarr artist ID). JSON object.
|
||||
external_ids TEXT NOT NULL DEFAULT '{}',
|
||||
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- One want per thing per library. Asking twice is not two wants,
|
||||
-- and this is what lets an artist expansion re-run every reconcile
|
||||
-- without accumulating duplicates.
|
||||
UNIQUE(mbid, library_id),
|
||||
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_wants_due
|
||||
ON download_wants(next_try_at)
|
||||
WHERE state = 'wanted';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
|
||||
ON download_wants(entity, state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
|
||||
ON download_wants(parent_id);
|
||||
@@ -10,9 +10,55 @@ import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createDownload = `-- name: CreateDownload :exec
|
||||
|
||||
INSERT INTO download_downloads (
|
||||
id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateDownloadParams struct {
|
||||
ID string
|
||||
LibraryID int64
|
||||
Source string
|
||||
RequestID sql.NullInt64
|
||||
ReleaseMbid sql.NullString
|
||||
ReleaseGroupMbid sql.NullString
|
||||
RecordingMbid sql.NullString
|
||||
Artist string
|
||||
Album string
|
||||
Query string
|
||||
Expected string
|
||||
State string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Downloads (one-shot search+grab attempts)
|
||||
// ---------------------------------------------------------------------
|
||||
func (q *Queries) CreateDownload(ctx context.Context, arg CreateDownloadParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createDownload,
|
||||
arg.ID,
|
||||
arg.LibraryID,
|
||||
arg.Source,
|
||||
arg.RequestID,
|
||||
arg.ReleaseMbid,
|
||||
arg.ReleaseGroupMbid,
|
||||
arg.RecordingMbid,
|
||||
arg.Artist,
|
||||
arg.Album,
|
||||
arg.Query,
|
||||
arg.Expected,
|
||||
arg.State,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createDownloadItem = `-- name: CreateDownloadItem :exec
|
||||
|
||||
INSERT INTO download_items (
|
||||
id, request_id, provider_id, transport_id, external_id,
|
||||
id, download_id, provider_id, transport_id, external_id,
|
||||
candidate, state, staging_dir, bytes_total
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -20,7 +66,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
|
||||
type CreateDownloadItemParams struct {
|
||||
ID string
|
||||
RequestID string
|
||||
DownloadID string
|
||||
ProviderID int64
|
||||
TransportID sql.NullInt64
|
||||
ExternalID string
|
||||
@@ -30,10 +76,13 @@ type CreateDownloadItemParams struct {
|
||||
BytesTotal int64
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Items (transfer records within a download)
|
||||
// ---------------------------------------------------------------------
|
||||
func (q *Queries) CreateDownloadItem(ctx context.Context, arg CreateDownloadItemParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createDownloadItem,
|
||||
arg.ID,
|
||||
arg.RequestID,
|
||||
arg.DownloadID,
|
||||
arg.ProviderID,
|
||||
arg.TransportID,
|
||||
arg.ExternalID,
|
||||
@@ -72,44 +121,13 @@ func (q *Queries) CreateDownloadProvider(ctx context.Context, arg CreateDownload
|
||||
return id, err
|
||||
}
|
||||
|
||||
const createDownloadRequest = `-- name: CreateDownloadRequest :exec
|
||||
INSERT INTO download_requests (
|
||||
id, library_id, source, want_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
const deleteDownload = `-- name: DeleteDownload :exec
|
||||
DELETE FROM download_downloads
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type CreateDownloadRequestParams struct {
|
||||
ID string
|
||||
LibraryID int64
|
||||
Source string
|
||||
WantID sql.NullInt64
|
||||
ReleaseMbid sql.NullString
|
||||
ReleaseGroupMbid sql.NullString
|
||||
RecordingMbid sql.NullString
|
||||
Artist string
|
||||
Album string
|
||||
Query string
|
||||
Expected string
|
||||
State string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateDownloadRequest(ctx context.Context, arg CreateDownloadRequestParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createDownloadRequest,
|
||||
arg.ID,
|
||||
arg.LibraryID,
|
||||
arg.Source,
|
||||
arg.WantID,
|
||||
arg.ReleaseMbid,
|
||||
arg.ReleaseGroupMbid,
|
||||
arg.RecordingMbid,
|
||||
arg.Artist,
|
||||
arg.Album,
|
||||
arg.Query,
|
||||
arg.Expected,
|
||||
arg.State,
|
||||
)
|
||||
func (q *Queries) DeleteDownload(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteDownload, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -124,45 +142,66 @@ func (q *Queries) DeleteDownloadProvider(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
const deleteDownloadRequest = `-- name: DeleteDownloadRequest :exec
|
||||
DELETE FROM download_requests
|
||||
WHERE id = ?
|
||||
DELETE FROM download_requests WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteDownloadRequest(ctx context.Context, id string) error {
|
||||
func (q *Queries) DeleteDownloadRequest(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteDownloadRequest, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteDownloadWant = `-- name: DeleteDownloadWant :exec
|
||||
DELETE FROM download_wants WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteDownloadWant(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteDownloadWant, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteFinishedDownloadRequests = `-- name: DeleteFinishedDownloadRequests :exec
|
||||
DELETE FROM download_requests
|
||||
const deleteFinishedDownloads = `-- name: DeleteFinishedDownloads :exec
|
||||
DELETE FROM download_downloads
|
||||
WHERE state IN ('complete', 'cancelled', 'failed')
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteFinishedDownloadRequests(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteFinishedDownloadRequests)
|
||||
func (q *Queries) DeleteFinishedDownloads(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteFinishedDownloads)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSatisfiedDownloadWants = `-- name: DeleteSatisfiedDownloadWants :exec
|
||||
DELETE FROM download_wants WHERE state = 'satisfied'
|
||||
const deleteSatisfiedDownloadRequests = `-- name: DeleteSatisfiedDownloadRequests :exec
|
||||
DELETE FROM download_requests WHERE state = 'satisfied'
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSatisfiedDownloadWants(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteSatisfiedDownloadWants)
|
||||
func (q *Queries) DeleteSatisfiedDownloadRequests(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteSatisfiedDownloadRequests)
|
||||
return err
|
||||
}
|
||||
|
||||
const getDownload = `-- name: GetDownload :one
|
||||
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state, error,
|
||||
created_at, updated_at
|
||||
FROM download_downloads
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetDownload(ctx context.Context, id string) (DownloadDownload, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDownload, id)
|
||||
var i DownloadDownload
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.LibraryID,
|
||||
&i.Source,
|
||||
&i.RequestID,
|
||||
&i.ReleaseMbid,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.RecordingMbid,
|
||||
&i.Artist,
|
||||
&i.Album,
|
||||
&i.Query,
|
||||
&i.Expected,
|
||||
&i.State,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDownloadItem = `-- name: GetDownloadItem :one
|
||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
||||
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||
error, created_at, updated_at
|
||||
FROM download_items
|
||||
@@ -174,7 +213,7 @@ func (q *Queries) GetDownloadItem(ctx context.Context, id string) (DownloadItem,
|
||||
var i DownloadItem
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RequestID,
|
||||
&i.DownloadID,
|
||||
&i.ProviderID,
|
||||
&i.TransportID,
|
||||
&i.ExternalID,
|
||||
@@ -213,43 +252,12 @@ func (q *Queries) GetDownloadProvider(ctx context.Context, id int64) (DownloadPr
|
||||
}
|
||||
|
||||
const getDownloadRequest = `-- name: GetDownloadRequest :one
|
||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state, error,
|
||||
created_at, updated_at
|
||||
FROM download_requests
|
||||
WHERE id = ?
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetDownloadRequest(ctx context.Context, id string) (DownloadRequest, error) {
|
||||
func (q *Queries) GetDownloadRequest(ctx context.Context, id int64) (DownloadRequest, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDownloadRequest, id)
|
||||
var i DownloadRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.LibraryID,
|
||||
&i.Source,
|
||||
&i.WantID,
|
||||
&i.ReleaseMbid,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.RecordingMbid,
|
||||
&i.Artist,
|
||||
&i.Album,
|
||||
&i.Query,
|
||||
&i.Expected,
|
||||
&i.State,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDownloadWant = `-- name: GetDownloadWant :one
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetDownloadWant(ctx context.Context, id int64) (DownloadWant, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDownloadWant, id)
|
||||
var i DownloadWant
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Mbid,
|
||||
@@ -272,18 +280,18 @@ func (q *Queries) GetDownloadWant(ctx context.Context, id int64) (DownloadWant,
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDownloadWantByMBID = `-- name: GetDownloadWantByMBID :one
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE mbid = ? AND library_id = ?
|
||||
const getDownloadRequestByMBID = `-- name: GetDownloadRequestByMBID :one
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests WHERE mbid = ? AND library_id = ?
|
||||
`
|
||||
|
||||
type GetDownloadWantByMBIDParams struct {
|
||||
type GetDownloadRequestByMBIDParams struct {
|
||||
Mbid string
|
||||
LibraryID int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetDownloadWantByMBID(ctx context.Context, arg GetDownloadWantByMBIDParams) (DownloadWant, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDownloadWantByMBID, arg.Mbid, arg.LibraryID)
|
||||
var i DownloadWant
|
||||
func (q *Queries) GetDownloadRequestByMBID(ctx context.Context, arg GetDownloadRequestByMBIDParams) (DownloadRequest, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDownloadRequestByMBID, arg.Mbid, arg.LibraryID)
|
||||
var i DownloadRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Mbid,
|
||||
@@ -306,19 +314,19 @@ func (q *Queries) GetDownloadWantByMBID(ctx context.Context, arg GetDownloadWant
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listChildDownloadWants = `-- name: ListChildDownloadWants :many
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE parent_id = ? ORDER BY id
|
||||
const listChildDownloadRequests = `-- name: ListChildDownloadRequests :many
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests WHERE parent_id = ? ORDER BY id
|
||||
`
|
||||
|
||||
func (q *Queries) ListChildDownloadWants(ctx context.Context, parentID sql.NullInt64) ([]DownloadWant, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listChildDownloadWants, parentID)
|
||||
func (q *Queries) ListChildDownloadRequests(ctx context.Context, parentID sql.NullInt64) ([]DownloadRequest, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listChildDownloadRequests, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []DownloadWant
|
||||
var items []DownloadRequest
|
||||
for rows.Next() {
|
||||
var i DownloadWant
|
||||
var i DownloadRequest
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Mbid,
|
||||
@@ -351,17 +359,17 @@ func (q *Queries) ListChildDownloadWants(ctx context.Context, parentID sql.NullI
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDownloadItemsForRequest = `-- name: ListDownloadItemsForRequest :many
|
||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
||||
const listDownloadItemsForDownload = `-- name: ListDownloadItemsForDownload :many
|
||||
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||
error, created_at, updated_at
|
||||
FROM download_items
|
||||
WHERE request_id = ?
|
||||
WHERE download_id = ?
|
||||
ORDER BY created_at
|
||||
`
|
||||
|
||||
func (q *Queries) ListDownloadItemsForRequest(ctx context.Context, requestID string) ([]DownloadItem, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDownloadItemsForRequest, requestID)
|
||||
func (q *Queries) ListDownloadItemsForDownload(ctx context.Context, downloadID string) ([]DownloadItem, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDownloadItemsForDownload, downloadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -371,7 +379,7 @@ func (q *Queries) ListDownloadItemsForRequest(ctx context.Context, requestID str
|
||||
var i DownloadItem
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.RequestID,
|
||||
&i.DownloadID,
|
||||
&i.ProviderID,
|
||||
&i.TransportID,
|
||||
&i.ExternalID,
|
||||
@@ -436,16 +444,14 @@ func (q *Queries) ListDownloadProviders(ctx context.Context) ([]DownloadProvider
|
||||
}
|
||||
|
||||
const listDownloadRequests = `-- name: ListDownloadRequests :many
|
||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state, error,
|
||||
created_at, updated_at
|
||||
FROM download_requests
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
|
||||
ORDER BY
|
||||
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
|
||||
artist, title
|
||||
`
|
||||
|
||||
func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]DownloadRequest, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDownloadRequests, limit)
|
||||
func (q *Queries) ListDownloadRequests(ctx context.Context) ([]DownloadRequest, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDownloadRequests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -453,11 +459,113 @@ func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]Down
|
||||
var items []DownloadRequest
|
||||
for rows.Next() {
|
||||
var i DownloadRequest
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Mbid,
|
||||
&i.Entity,
|
||||
&i.LibraryID,
|
||||
&i.Artist,
|
||||
&i.Title,
|
||||
&i.Scope,
|
||||
&i.Secondary,
|
||||
&i.State,
|
||||
&i.ParentID,
|
||||
&i.Attempts,
|
||||
&i.LastError,
|
||||
&i.LastTriedAt,
|
||||
&i.NextTryAt,
|
||||
&i.ExternalIds,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDownloadRequestsByEntity = `-- name: ListDownloadRequestsByEntity :many
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
|
||||
WHERE entity = ? AND state = ?
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
type ListDownloadRequestsByEntityParams struct {
|
||||
Entity string
|
||||
State string
|
||||
}
|
||||
|
||||
func (q *Queries) ListDownloadRequestsByEntity(ctx context.Context, arg ListDownloadRequestsByEntityParams) ([]DownloadRequest, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDownloadRequestsByEntity, arg.Entity, arg.State)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []DownloadRequest
|
||||
for rows.Next() {
|
||||
var i DownloadRequest
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Mbid,
|
||||
&i.Entity,
|
||||
&i.LibraryID,
|
||||
&i.Artist,
|
||||
&i.Title,
|
||||
&i.Scope,
|
||||
&i.Secondary,
|
||||
&i.State,
|
||||
&i.ParentID,
|
||||
&i.Attempts,
|
||||
&i.LastError,
|
||||
&i.LastTriedAt,
|
||||
&i.NextTryAt,
|
||||
&i.ExternalIds,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDownloads = `-- name: ListDownloads :many
|
||||
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state, error,
|
||||
created_at, updated_at
|
||||
FROM download_downloads
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
func (q *Queries) ListDownloads(ctx context.Context, limit int64) ([]DownloadDownload, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDownloads, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []DownloadDownload
|
||||
for rows.Next() {
|
||||
var i DownloadDownload
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.LibraryID,
|
||||
&i.Source,
|
||||
&i.WantID,
|
||||
&i.RequestID,
|
||||
&i.ReleaseMbid,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.RecordingMbid,
|
||||
@@ -483,108 +591,8 @@ func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]Down
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDownloadWants = `-- name: ListDownloadWants :many
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
|
||||
ORDER BY
|
||||
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
|
||||
artist, title
|
||||
`
|
||||
|
||||
func (q *Queries) ListDownloadWants(ctx context.Context) ([]DownloadWant, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDownloadWants)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []DownloadWant
|
||||
for rows.Next() {
|
||||
var i DownloadWant
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Mbid,
|
||||
&i.Entity,
|
||||
&i.LibraryID,
|
||||
&i.Artist,
|
||||
&i.Title,
|
||||
&i.Scope,
|
||||
&i.Secondary,
|
||||
&i.State,
|
||||
&i.ParentID,
|
||||
&i.Attempts,
|
||||
&i.LastError,
|
||||
&i.LastTriedAt,
|
||||
&i.NextTryAt,
|
||||
&i.ExternalIds,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDownloadWantsByEntity = `-- name: ListDownloadWantsByEntity :many
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
|
||||
WHERE entity = ? AND state = ?
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
type ListDownloadWantsByEntityParams struct {
|
||||
Entity string
|
||||
State string
|
||||
}
|
||||
|
||||
func (q *Queries) ListDownloadWantsByEntity(ctx context.Context, arg ListDownloadWantsByEntityParams) ([]DownloadWant, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDownloadWantsByEntity, arg.Entity, arg.State)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []DownloadWant
|
||||
for rows.Next() {
|
||||
var i DownloadWant
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Mbid,
|
||||
&i.Entity,
|
||||
&i.LibraryID,
|
||||
&i.Artist,
|
||||
&i.Title,
|
||||
&i.Scope,
|
||||
&i.Secondary,
|
||||
&i.State,
|
||||
&i.ParentID,
|
||||
&i.Attempts,
|
||||
&i.LastError,
|
||||
&i.LastTriedAt,
|
||||
&i.NextTryAt,
|
||||
&i.ExternalIds,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDueDownloadWants = `-- name: ListDueDownloadWants :many
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
|
||||
const listDueDownloadRequests = `-- name: ListDueDownloadRequests :many
|
||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
|
||||
WHERE state = 'wanted'
|
||||
AND entity <> 'artist'
|
||||
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
|
||||
@@ -595,15 +603,15 @@ LIMIT ?
|
||||
// Everything the reconciler should act on this pass: wanted, not an
|
||||
// artist subscription (those expand rather than download), and either
|
||||
// never tried or past its backoff.
|
||||
func (q *Queries) ListDueDownloadWants(ctx context.Context, limit int64) ([]DownloadWant, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDueDownloadWants, limit)
|
||||
func (q *Queries) ListDueDownloadRequests(ctx context.Context, limit int64) ([]DownloadRequest, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDueDownloadRequests, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []DownloadWant
|
||||
var items []DownloadRequest
|
||||
for rows.Next() {
|
||||
var i DownloadWant
|
||||
var i DownloadRequest
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Mbid,
|
||||
@@ -637,7 +645,7 @@ func (q *Queries) ListDueDownloadWants(ctx context.Context, limit int64) ([]Down
|
||||
}
|
||||
|
||||
const listLiveDownloadItems = `-- name: ListLiveDownloadItems :many
|
||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
||||
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||
error, created_at, updated_at
|
||||
FROM download_items
|
||||
@@ -656,7 +664,7 @@ func (q *Queries) ListLiveDownloadItems(ctx context.Context) ([]DownloadItem, er
|
||||
var i DownloadItem
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.RequestID,
|
||||
&i.DownloadID,
|
||||
&i.ProviderID,
|
||||
&i.TransportID,
|
||||
&i.ExternalID,
|
||||
@@ -683,29 +691,29 @@ func (q *Queries) ListLiveDownloadItems(ctx context.Context) ([]DownloadItem, er
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLiveDownloadRequests = `-- name: ListLiveDownloadRequests :many
|
||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
||||
const listLiveDownloads = `-- name: ListLiveDownloads :many
|
||||
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||
recording_mbid, artist, album, query, expected, state, error,
|
||||
created_at, updated_at
|
||||
FROM download_requests
|
||||
FROM download_downloads
|
||||
WHERE state NOT IN ('complete', 'cancelled', 'failed')
|
||||
ORDER BY created_at
|
||||
`
|
||||
|
||||
func (q *Queries) ListLiveDownloadRequests(ctx context.Context) ([]DownloadRequest, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listLiveDownloadRequests)
|
||||
func (q *Queries) ListLiveDownloads(ctx context.Context) ([]DownloadDownload, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listLiveDownloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []DownloadRequest
|
||||
var items []DownloadDownload
|
||||
for rows.Next() {
|
||||
var i DownloadRequest
|
||||
var i DownloadDownload
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.LibraryID,
|
||||
&i.Source,
|
||||
&i.WantID,
|
||||
&i.RequestID,
|
||||
&i.ReleaseMbid,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.RecordingMbid,
|
||||
@@ -731,8 +739,8 @@ func (q *Queries) ListLiveDownloadRequests(ctx context.Context) ([]DownloadReque
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const recordDownloadWantAttempt = `-- name: RecordDownloadWantAttempt :exec
|
||||
UPDATE download_wants
|
||||
const recordDownloadRequestAttempt = `-- name: RecordDownloadRequestAttempt :exec
|
||||
UPDATE download_requests
|
||||
SET attempts = attempts + 1,
|
||||
last_error = ?,
|
||||
last_tried_at = CURRENT_TIMESTAMP,
|
||||
@@ -741,26 +749,26 @@ SET attempts = attempts + 1,
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type RecordDownloadWantAttemptParams struct {
|
||||
type RecordDownloadRequestAttemptParams struct {
|
||||
LastError string
|
||||
NextTryAt sql.NullTime
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) RecordDownloadWantAttempt(ctx context.Context, arg RecordDownloadWantAttemptParams) error {
|
||||
_, err := q.db.ExecContext(ctx, recordDownloadWantAttempt, arg.LastError, arg.NextTryAt, arg.ID)
|
||||
func (q *Queries) RecordDownloadRequestAttempt(ctx context.Context, arg RecordDownloadRequestAttemptParams) error {
|
||||
_, err := q.db.ExecContext(ctx, recordDownloadRequestAttempt, arg.LastError, arg.NextTryAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const satisfyDownloadWant = `-- name: SatisfyDownloadWant :exec
|
||||
UPDATE download_wants
|
||||
const satisfyDownloadRequest = `-- name: SatisfyDownloadRequest :exec
|
||||
UPDATE download_requests
|
||||
SET state = 'satisfied', last_error = '', next_try_at = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) SatisfyDownloadWant(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, satisfyDownloadWant, id)
|
||||
func (q *Queries) SatisfyDownloadRequest(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, satisfyDownloadRequest, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -831,53 +839,53 @@ func (q *Queries) SetDownloadItemState(ctx context.Context, arg SetDownloadItemS
|
||||
return err
|
||||
}
|
||||
|
||||
const setDownloadRequestState = `-- name: SetDownloadRequestState :exec
|
||||
const setDownloadRequestExternalIDs = `-- name: SetDownloadRequestExternalIDs :exec
|
||||
UPDATE download_requests
|
||||
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type SetDownloadRequestStateParams struct {
|
||||
State string
|
||||
Error string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) SetDownloadRequestState(ctx context.Context, arg SetDownloadRequestStateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDownloadRequestState, arg.State, arg.Error, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setDownloadWantExternalIDs = `-- name: SetDownloadWantExternalIDs :exec
|
||||
UPDATE download_wants
|
||||
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type SetDownloadWantExternalIDsParams struct {
|
||||
type SetDownloadRequestExternalIDsParams struct {
|
||||
ExternalIds string
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) SetDownloadWantExternalIDs(ctx context.Context, arg SetDownloadWantExternalIDsParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDownloadWantExternalIDs, arg.ExternalIds, arg.ID)
|
||||
func (q *Queries) SetDownloadRequestExternalIDs(ctx context.Context, arg SetDownloadRequestExternalIDsParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDownloadRequestExternalIDs, arg.ExternalIds, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setDownloadWantState = `-- name: SetDownloadWantState :exec
|
||||
UPDATE download_wants
|
||||
const setDownloadRequestState = `-- name: SetDownloadRequestState :exec
|
||||
UPDATE download_requests
|
||||
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type SetDownloadWantStateParams struct {
|
||||
type SetDownloadRequestStateParams struct {
|
||||
State string
|
||||
LastError string
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) SetDownloadWantState(ctx context.Context, arg SetDownloadWantStateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDownloadWantState, arg.State, arg.LastError, arg.ID)
|
||||
func (q *Queries) SetDownloadRequestState(ctx context.Context, arg SetDownloadRequestStateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDownloadRequestState, arg.State, arg.LastError, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setDownloadState = `-- name: SetDownloadState :exec
|
||||
UPDATE download_downloads
|
||||
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type SetDownloadStateParams struct {
|
||||
State string
|
||||
Error string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) SetDownloadState(ctx context.Context, arg SetDownloadStateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDownloadState, arg.State, arg.Error, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -906,25 +914,25 @@ func (q *Queries) UpdateDownloadProvider(ctx context.Context, arg UpdateDownload
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertDownloadWant = `-- name: UpsertDownloadWant :one
|
||||
const upsertDownloadRequest = `-- name: UpsertDownloadRequest :one
|
||||
|
||||
INSERT INTO download_wants (
|
||||
INSERT INTO download_requests (
|
||||
mbid, entity, library_id, artist, title, scope, secondary,
|
||||
parent_id, next_try_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(mbid, library_id) DO UPDATE SET
|
||||
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
|
||||
ELSE download_wants.artist END,
|
||||
ELSE download_requests.artist END,
|
||||
title = CASE WHEN excluded.title <> '' THEN excluded.title
|
||||
ELSE download_wants.title END,
|
||||
ELSE download_requests.title END,
|
||||
scope = excluded.scope,
|
||||
secondary = excluded.secondary,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type UpsertDownloadWantParams struct {
|
||||
type UpsertDownloadRequestParams struct {
|
||||
Mbid string
|
||||
Entity string
|
||||
LibraryID int64
|
||||
@@ -936,14 +944,14 @@ type UpsertDownloadWantParams struct {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Wants
|
||||
// Requests (durable "I asked for this" records)
|
||||
// ---------------------------------------------------------------------
|
||||
// Adding something already wanted is not an error and must not reset
|
||||
// the retry clock, so the conflict path only refreshes display text and
|
||||
// un-pauses nothing. scope and secondary are updated because asking
|
||||
// again with a wider scope is a real change of intent.
|
||||
func (q *Queries) UpsertDownloadWant(ctx context.Context, arg UpsertDownloadWantParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertDownloadWant,
|
||||
// Adding something already requested is not an error and must not
|
||||
// reset the retry clock, so the conflict path only refreshes display
|
||||
// text and un-pauses nothing. scope and secondary are updated because
|
||||
// asking again with a wider scope is a real change of intent.
|
||||
func (q *Queries) UpsertDownloadRequest(ctx context.Context, arg UpsertDownloadRequestParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertDownloadRequest,
|
||||
arg.Mbid,
|
||||
arg.Entity,
|
||||
arg.LibraryID,
|
||||
|
||||
@@ -74,9 +74,27 @@ type CoverArt struct {
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type DownloadDownload struct {
|
||||
ID string
|
||||
LibraryID int64
|
||||
Source string
|
||||
RequestID sql.NullInt64
|
||||
ReleaseMbid sql.NullString
|
||||
ReleaseGroupMbid sql.NullString
|
||||
RecordingMbid sql.NullString
|
||||
Artist string
|
||||
Album string
|
||||
Query string
|
||||
Expected string
|
||||
State string
|
||||
Error string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type DownloadItem struct {
|
||||
ID string
|
||||
RequestID string
|
||||
DownloadID string
|
||||
ProviderID int64
|
||||
TransportID sql.NullInt64
|
||||
ExternalID string
|
||||
@@ -102,24 +120,6 @@ type DownloadProvider struct {
|
||||
}
|
||||
|
||||
type DownloadRequest struct {
|
||||
ID string
|
||||
LibraryID int64
|
||||
Source string
|
||||
WantID sql.NullInt64
|
||||
ReleaseMbid sql.NullString
|
||||
ReleaseGroupMbid sql.NullString
|
||||
RecordingMbid sql.NullString
|
||||
Artist string
|
||||
Album string
|
||||
Query string
|
||||
Expected string
|
||||
State string
|
||||
Error string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type DownloadWant struct {
|
||||
ID int64
|
||||
Mbid string
|
||||
Entity string
|
||||
|
||||
+15
-13
@@ -142,10 +142,17 @@ var tables = []Table{
|
||||
"only; the sized variants beside it are derived filenames and " +
|
||||
"must be expanded when deleting (see library.coverArtFileSet).",
|
||||
},
|
||||
{
|
||||
Name: "download_downloads", Kind: Authored, Lifetime: Cascade,
|
||||
Note: "One row per 'go find me this', i.e. one search-and-grab " +
|
||||
"attempt. Cascades from libraries, and cascades onward to " +
|
||||
"download_items. Terminal rows are history the user clears " +
|
||||
"explicitly.",
|
||||
},
|
||||
{
|
||||
Name: "download_items", Kind: Authored, Lifetime: Cascade,
|
||||
Note: "One grab attempt per row, with the ranked candidate stored " +
|
||||
"as JSON. Cascades from download_requests. The candidate blob " +
|
||||
"as JSON. Cascades from download_downloads. The candidate blob " +
|
||||
"is kept rather than re-derived because a provider's result " +
|
||||
"set is ephemeral — the peer that had the files may be gone, " +
|
||||
"and the row still has to explain why it was chosen.",
|
||||
@@ -159,18 +166,13 @@ var tables = []Table{
|
||||
},
|
||||
{
|
||||
Name: "download_requests", Kind: Authored, Lifetime: Cascade,
|
||||
Note: "One row per 'go find me this'. Cascades from libraries, " +
|
||||
"and cascades onward to download_items. Terminal rows are " +
|
||||
"history the user clears explicitly.",
|
||||
},
|
||||
{
|
||||
Name: "download_wants", Kind: Authored, Lifetime: Cascade,
|
||||
Note: "The wanted list: one MBID per row, plus retry bookkeeping. " +
|
||||
"Cascades from libraries, and from a parent artist want to " +
|
||||
"the album wants it derived. Unlike download_requests these " +
|
||||
"are not history — a want outlives every attempt made on it " +
|
||||
"and is only removed by the user or by the library coming " +
|
||||
"to own what it names.",
|
||||
Note: "The durable request list: one MBID per row, plus retry " +
|
||||
"bookkeeping. Cascades from libraries, and from a parent " +
|
||||
"artist request to the album requests it derived. Unlike " +
|
||||
"download_downloads these are not history — a request " +
|
||||
"outlives every download attempt made on it and is only " +
|
||||
"removed by the user or by the library coming to own what it " +
|
||||
"names.",
|
||||
},
|
||||
{
|
||||
Name: "explore_champion_fts", Kind: Cache, Lifetime: Retained, FTS: true,
|
||||
|
||||
@@ -223,22 +223,23 @@ func TestAuthoredCascadesAreDeliberate(t *testing.T) {
|
||||
|
||||
// Download history is scoped to the library it imported into.
|
||||
// When that library is removed the files it acquired go with
|
||||
// it, so a request describing "fetch this into library 3" has
|
||||
// it, so a download describing "fetch this into library 3" has
|
||||
// nothing left to mean. Keeping the rows would leave history
|
||||
// pointing at a library the user deleted.
|
||||
"download_requests": true,
|
||||
"download_downloads": true,
|
||||
|
||||
// Items belong to their request and have no independent
|
||||
// Items belong to their download and have no independent
|
||||
// meaning; they cascade with it.
|
||||
"download_items": true,
|
||||
|
||||
// A want says "put this in library 3". Delete that library and
|
||||
// there is no longer anywhere for it to go, so the want has
|
||||
// nothing left to mean — the same reasoning as its requests.
|
||||
// The second cascade, artist want to derived album wants, is
|
||||
// the point of the subscription: unsubscribing from an artist
|
||||
// must stop the albums it queued on the user's behalf.
|
||||
"download_wants": true,
|
||||
// A request says "put this in library 3". Delete that library
|
||||
// and there is no longer anywhere for it to go, so the request
|
||||
// has nothing left to mean — the same reasoning as its
|
||||
// downloads. The second cascade, artist request to derived
|
||||
// album requests, is the point of the subscription:
|
||||
// unsubscribing from an artist must stop the albums it queued
|
||||
// on the user's behalf.
|
||||
"download_requests": true,
|
||||
}
|
||||
|
||||
for _, entry := range datamap.ByKind(datamap.Authored) {
|
||||
|
||||
@@ -84,17 +84,17 @@ func TestPerProviderCapSerializesTransfers(t *testing.T) {
|
||||
|
||||
// Three requests against the same one-at-a-time provider.
|
||||
for i := range 3 {
|
||||
req := fourTrackRequest()
|
||||
req.ID = "req-" + string(rune('a'+i))
|
||||
dl := fourTrackDownload()
|
||||
dl.ID = "dl-" + string(rune('a'+i))
|
||||
|
||||
if err := f.store.CreateRequest(ctx, req); err != nil {
|
||||
t.Fatalf("CreateRequest: %v", err)
|
||||
if err := f.store.CreateDownload(ctx, dl); err != nil {
|
||||
t.Fatalf("CreateDownload: %v", err)
|
||||
}
|
||||
|
||||
candidate := slow.Candidates[0]
|
||||
candidate.ProviderID = 1
|
||||
|
||||
go f.manager.grab(ctx, req, candidate, nil)
|
||||
go f.manager.grab(ctx, dl, candidate, nil)
|
||||
}
|
||||
|
||||
// Give all three a chance to reach the transport, then check how
|
||||
@@ -139,17 +139,17 @@ func TestPerProviderCapAllowsParallelWhereSafe(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
for i := range 3 {
|
||||
req := fourTrackRequest()
|
||||
req.ID = "req-" + string(rune('a'+i))
|
||||
dl := fourTrackDownload()
|
||||
dl.ID = "dl-" + string(rune('a'+i))
|
||||
|
||||
if err := f.store.CreateRequest(ctx, req); err != nil {
|
||||
t.Fatalf("CreateRequest: %v", err)
|
||||
if err := f.store.CreateDownload(ctx, dl); err != nil {
|
||||
t.Fatalf("CreateDownload: %v", err)
|
||||
}
|
||||
|
||||
candidate := fast.Candidates[0]
|
||||
candidate.ProviderID = 1
|
||||
|
||||
go f.manager.grab(ctx, req, candidate, nil)
|
||||
go f.manager.grab(ctx, dl, candidate, nil)
|
||||
}
|
||||
|
||||
waitFor(
|
||||
|
||||
@@ -33,6 +33,34 @@ type UserConfig struct {
|
||||
// for. A large list should be worked through steadily rather than
|
||||
// in one burst that every provider sees as a flood.
|
||||
WantedBatch int `toml:"WantedBatch"`
|
||||
|
||||
// MinFileSizeMB, MaxFileSizeMB and PreferredFileSizeMB bound and
|
||||
// nudge what auto-pick (interactive or via the request list) may
|
||||
// grab without asking. Zero on any of them is permissive: see
|
||||
// AutoDownloadPrefs.
|
||||
MinFileSizeMB int `toml:"MinFileSizeMB"`
|
||||
MaxFileSizeMB int `toml:"MaxFileSizeMB"`
|
||||
PreferredFileSizeMB int `toml:"PreferredFileSizeMB"`
|
||||
|
||||
// AllowedFormats restricts auto-pick to these formats. Empty means
|
||||
// no restriction. Values are Format strings ("flac", "mp3", ...).
|
||||
AllowedFormats []string `toml:"AllowedFormats"`
|
||||
}
|
||||
|
||||
// AutoDownloadPrefs converts the persisted guardrail fields to the
|
||||
// runtime type Manager and the ranker consume.
|
||||
func (c *UserConfig) AutoDownloadPrefs() AutoDownloadPrefs {
|
||||
formats := make([]Format, 0, len(c.AllowedFormats))
|
||||
for _, f := range c.AllowedFormats {
|
||||
formats = append(formats, Format(f))
|
||||
}
|
||||
|
||||
return AutoDownloadPrefs{
|
||||
MinSizeMB: c.MinFileSizeMB,
|
||||
MaxSizeMB: c.MaxFileSizeMB,
|
||||
PreferredSizeMB: c.PreferredFileSizeMB,
|
||||
AllowedFormats: formats,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyDefaults fills unset fields.
|
||||
|
||||
@@ -92,7 +92,7 @@ func (f *FakeProvider) Close() error {
|
||||
// Search returns the configured candidates.
|
||||
func (f *FakeProvider) Search(
|
||||
ctx context.Context,
|
||||
_ Request,
|
||||
_ Download,
|
||||
) ([]Candidate, error) {
|
||||
f.mu.Lock()
|
||||
f.SearchCalls++
|
||||
@@ -208,7 +208,7 @@ var errNoDelegateStatus = errors.New("fake: no delegate status configured")
|
||||
// Delegate records the call and returns a fixed external ID.
|
||||
func (f *FakeProvider) Delegate(
|
||||
_ context.Context,
|
||||
_ Request,
|
||||
_ Download,
|
||||
) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -125,7 +125,7 @@ type ImportResult struct {
|
||||
// retry or inspect it; only a fully successful import releases staging.
|
||||
func (i *Importer) Import(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
dl Download,
|
||||
result Result,
|
||||
opts ImportOptions,
|
||||
) (ImportResult, error) {
|
||||
@@ -139,13 +139,13 @@ func (i *Importer) Import(
|
||||
return ImportResult{}, ErrNoAudio
|
||||
}
|
||||
|
||||
if err := checkCompleteness(len(audio), req); err != nil {
|
||||
if err := checkCompleteness(len(audio), dl); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
|
||||
// Align staged files to the expected tracklist so tags and
|
||||
// filenames reflect the release, not the uploader's naming.
|
||||
plan := i.planFiles(audio, req)
|
||||
plan := i.planFiles(audio, dl)
|
||||
|
||||
out := ImportResult{
|
||||
Paths: make([]string, 0, len(plan)),
|
||||
@@ -158,7 +158,7 @@ func (i *Importer) Import(
|
||||
}
|
||||
|
||||
if opts.WriteTags {
|
||||
if err := i.tagFile(p, req); err != nil {
|
||||
if err := i.tagFile(p, dl); err != nil {
|
||||
// A file that cannot be tagged is still worth importing
|
||||
// — the scanner will read whatever tags it has, and the
|
||||
// autotag queue can pick it up later. Losing the whole
|
||||
@@ -173,7 +173,7 @@ func (i *Importer) Import(
|
||||
}
|
||||
}
|
||||
|
||||
dest, err := i.destinationFor(p, req, opts)
|
||||
dest, err := i.destinationFor(p, dl, opts)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -199,7 +199,7 @@ type plannedFile struct {
|
||||
}
|
||||
|
||||
// planFiles aligns staged files to the expected tracklist.
|
||||
func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
|
||||
func (i *Importer) planFiles(audio []string, dl Download) []plannedFile {
|
||||
files := make([]CandidateFile, 0, len(audio))
|
||||
|
||||
for _, a := range audio {
|
||||
@@ -211,10 +211,10 @@ func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
|
||||
})
|
||||
}
|
||||
|
||||
matched, _ := matchFiles(files, req.Expected)
|
||||
matched, _ := matchFiles(files, dl.Expected)
|
||||
|
||||
byPosition := make(map[int]ExpectedTrack, len(req.Expected))
|
||||
for _, e := range req.Expected {
|
||||
byPosition := make(map[int]ExpectedTrack, len(dl.Expected))
|
||||
for _, e := range dl.Expected {
|
||||
byPosition[e.Position] = e
|
||||
}
|
||||
|
||||
@@ -253,14 +253,14 @@ func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
|
||||
}
|
||||
|
||||
// tagFile writes the release's metadata onto a staged file.
|
||||
func (i *Importer) tagFile(p plannedFile, req Request) error {
|
||||
func (i *Importer) tagFile(p plannedFile, dl Download) error {
|
||||
if i.tags == nil || !p.Matched {
|
||||
return nil
|
||||
}
|
||||
|
||||
changes := tagwriter.TagChanges{
|
||||
tagwriter.FieldAlbum: req.Album,
|
||||
tagwriter.FieldAlbumArtist: req.Artist,
|
||||
tagwriter.FieldAlbum: dl.Album,
|
||||
tagwriter.FieldAlbumArtist: dl.Artist,
|
||||
tagwriter.FieldTitle: p.Track.Title,
|
||||
tagwriter.FieldTrackNumber: p.Track.Position,
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func (i *Importer) tagFile(p plannedFile, req Request) error {
|
||||
if p.Track.Artist != "" {
|
||||
changes[tagwriter.FieldArtist] = p.Track.Artist
|
||||
} else {
|
||||
changes[tagwriter.FieldArtist] = req.Artist
|
||||
changes[tagwriter.FieldArtist] = dl.Artist
|
||||
}
|
||||
|
||||
if p.Track.DiscNumber > 0 {
|
||||
@@ -285,7 +285,7 @@ func (i *Importer) tagFile(p plannedFile, req Request) error {
|
||||
// destinationFor computes a file's library path from the template.
|
||||
func (i *Importer) destinationFor(
|
||||
p plannedFile,
|
||||
req Request,
|
||||
dl Download,
|
||||
opts ImportOptions,
|
||||
) (string, error) {
|
||||
if opts.LibraryRoot == "" {
|
||||
@@ -311,13 +311,13 @@ func (i *Importer) destinationFor(
|
||||
|
||||
artist := p.Track.Artist
|
||||
if artist == "" {
|
||||
artist = req.Artist
|
||||
artist = dl.Artist
|
||||
}
|
||||
|
||||
repl := strings.NewReplacer(
|
||||
"{albumartist}", sanitizePathPart(fallback(req.Artist, "Unknown Artist")),
|
||||
"{albumartist}", sanitizePathPart(fallback(dl.Artist, "Unknown Artist")),
|
||||
"{artist}", sanitizePathPart(fallback(artist, "Unknown Artist")),
|
||||
"{album}", sanitizePathPart(fallback(req.Album, "Unknown Album")),
|
||||
"{album}", sanitizePathPart(fallback(dl.Album, "Unknown Album")),
|
||||
"{title}", sanitizePathPart(title),
|
||||
"{track}", trackToken(p.Track.Position),
|
||||
"{disc}", strconv.Itoa(p.Track.DiscNumber),
|
||||
@@ -443,16 +443,16 @@ func copyFile(src, dest string) error {
|
||||
|
||||
// checkCompleteness rejects an anchored download that is missing too
|
||||
// much of its tracklist.
|
||||
func checkCompleteness(got int, req Request) error {
|
||||
if len(req.Expected) == 0 {
|
||||
func checkCompleteness(got int, dl Download) error {
|
||||
if len(dl.Expected) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ratio := float64(got) / float64(len(req.Expected))
|
||||
ratio := float64(got) / float64(len(dl.Expected))
|
||||
if ratio < minCompleteness {
|
||||
return fmt.Errorf(
|
||||
"%w: got %d of %d tracks",
|
||||
ErrTooIncomplete, got, len(req.Expected),
|
||||
ErrTooIncomplete, got, len(dl.Expected),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -117,9 +117,9 @@ func newImportFixture(t *testing.T, names ...string) importFixture {
|
||||
}
|
||||
}
|
||||
|
||||
func fourTrackRequest() Request {
|
||||
return Request{
|
||||
ID: "req-1",
|
||||
func fourTrackDownload() Download {
|
||||
return Download{
|
||||
ID: "dl-1",
|
||||
LibraryID: 1,
|
||||
ReleaseMBID: "mbid-1",
|
||||
Artist: "Radiohead",
|
||||
@@ -145,7 +145,7 @@ func TestImportPlacesAndTagsFiles(t *testing.T) {
|
||||
|
||||
got, err := f.importer.Import(
|
||||
context.Background(),
|
||||
fourTrackRequest(),
|
||||
fourTrackDownload(),
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||
)
|
||||
@@ -184,12 +184,12 @@ func TestImportTagsBeforeMoving(t *testing.T) {
|
||||
|
||||
f := newImportFixture(t, "01 - Airbag.flac")
|
||||
|
||||
req := fourTrackRequest()
|
||||
req.Expected = req.Expected[:1]
|
||||
dl := fourTrackDownload()
|
||||
dl.Expected = dl.Expected[:1]
|
||||
|
||||
if _, err := f.importer.Import(
|
||||
context.Background(),
|
||||
req,
|
||||
dl,
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||
); err != nil {
|
||||
@@ -227,7 +227,7 @@ func TestImportContinuesWhenTaggingFails(t *testing.T) {
|
||||
|
||||
got, err := f.importer.Import(
|
||||
context.Background(),
|
||||
fourTrackRequest(),
|
||||
fourTrackDownload(),
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||
)
|
||||
@@ -251,7 +251,7 @@ func TestImportRejectsTooIncomplete(t *testing.T) {
|
||||
|
||||
_, err := f.importer.Import(
|
||||
context.Background(),
|
||||
fourTrackRequest(),
|
||||
fourTrackDownload(),
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||
)
|
||||
@@ -273,7 +273,7 @@ func TestImportRejectsNoAudio(t *testing.T) {
|
||||
|
||||
_, err := f.importer.Import(
|
||||
context.Background(),
|
||||
fourTrackRequest(),
|
||||
fourTrackDownload(),
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||
)
|
||||
@@ -297,7 +297,7 @@ func TestImportSkipsNonAudioFiles(t *testing.T) {
|
||||
|
||||
got, err := f.importer.Import(
|
||||
context.Background(),
|
||||
fourTrackRequest(),
|
||||
fourTrackDownload(),
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||
)
|
||||
@@ -322,8 +322,8 @@ func TestImportNeverOverwrites(t *testing.T) {
|
||||
|
||||
f := newImportFixture(t, "01 - Airbag.flac")
|
||||
|
||||
req := fourTrackRequest()
|
||||
req.Expected = req.Expected[:1]
|
||||
dl := fourTrackDownload()
|
||||
dl.Expected = dl.Expected[:1]
|
||||
|
||||
existing := filepath.Join(
|
||||
f.root, "Radiohead", "OK Computer", "01 Airbag.flac",
|
||||
@@ -339,7 +339,7 @@ func TestImportNeverOverwrites(t *testing.T) {
|
||||
|
||||
got, err := f.importer.Import(
|
||||
context.Background(),
|
||||
req,
|
||||
dl,
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||
)
|
||||
@@ -366,12 +366,12 @@ func TestImportCustomPathTemplate(t *testing.T) {
|
||||
|
||||
f := newImportFixture(t, "01 - Airbag.flac")
|
||||
|
||||
req := fourTrackRequest()
|
||||
req.Expected = req.Expected[:1]
|
||||
dl := fourTrackDownload()
|
||||
dl.Expected = dl.Expected[:1]
|
||||
|
||||
got, err := f.importer.Import(
|
||||
context.Background(),
|
||||
req,
|
||||
dl,
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{
|
||||
LibraryRoot: f.root,
|
||||
@@ -423,12 +423,12 @@ func TestImportRequiresLibraryRoot(t *testing.T) {
|
||||
|
||||
f := newImportFixture(t, "01 - Airbag.flac")
|
||||
|
||||
req := fourTrackRequest()
|
||||
req.Expected = req.Expected[:1]
|
||||
dl := fourTrackDownload()
|
||||
dl.Expected = dl.Expected[:1]
|
||||
|
||||
_, err := f.importer.Import(
|
||||
context.Background(),
|
||||
req,
|
||||
dl,
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{WriteTags: true},
|
||||
)
|
||||
|
||||
+132
-102
@@ -124,6 +124,10 @@ type Manager struct {
|
||||
optsMu sync.RWMutex
|
||||
opts ImportOptions
|
||||
|
||||
// prefs gates and scores what auto-pick may grab without asking.
|
||||
prefsMu sync.RWMutex
|
||||
prefs AutoDownloadPrefs
|
||||
|
||||
// providers caches built provider instances by config ID. Rebuilt
|
||||
// whenever config changes, so a settings edit takes effect without
|
||||
// a restart.
|
||||
@@ -206,6 +210,32 @@ func (m *Manager) importOptions() ImportOptions {
|
||||
return m.opts
|
||||
}
|
||||
|
||||
// SetPreferences configures the auto-download guardrails: the size
|
||||
// window and format list AutoPickable is allowed to grab without
|
||||
// asking. Live-settable so a settings change takes effect immediately,
|
||||
// the same way SetImportOptions does.
|
||||
func (m *Manager) SetPreferences(prefs AutoDownloadPrefs) {
|
||||
m.prefsMu.Lock()
|
||||
defer m.prefsMu.Unlock()
|
||||
|
||||
m.prefs = prefs
|
||||
}
|
||||
|
||||
// preferences returns the current auto-download guardrails.
|
||||
func (m *Manager) preferences() AutoDownloadPrefs {
|
||||
m.prefsMu.RLock()
|
||||
defer m.prefsMu.RUnlock()
|
||||
|
||||
return m.prefs
|
||||
}
|
||||
|
||||
// AutoPickable wraps the package function with this manager's current
|
||||
// preferences, so callers do not need direct field access to apply the
|
||||
// live guardrails.
|
||||
func (m *Manager) AutoPickable(dl Download, ranked []Candidate) bool {
|
||||
return AutoPickable(dl, ranked, m.preferences())
|
||||
}
|
||||
|
||||
// Reload rebuilds every provider from stored config. Called at startup
|
||||
// and after any provider settings change.
|
||||
//
|
||||
@@ -293,12 +323,12 @@ func (m *Manager) Sweep(ctx context.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
if err := m.store.SetRequestState(
|
||||
ctx, item.RequestID, StateFailed, "interrupted by restart",
|
||||
if err := m.store.SetDownloadState(
|
||||
ctx, item.DownloadID, StateFailed, "interrupted by restart",
|
||||
); err != nil {
|
||||
m.logger.Warn(
|
||||
"could not fail interrupted download request",
|
||||
"request", item.RequestID, "error", err,
|
||||
"request", item.DownloadID, "error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -399,7 +429,7 @@ func (m *Manager) syncSemaphores(configs map[int64]Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// listers returns every enabled provider that keeps a persistent wanted
|
||||
// listers returns every enabled provider that keeps a persistent
|
||||
// list of its own, keyed by provider ID.
|
||||
func (m *Manager) listers() map[int64]Lister {
|
||||
m.provMu.RLock()
|
||||
@@ -434,7 +464,7 @@ func (m *Manager) priorityFor(id int64) int {
|
||||
// and skipped, because partial results beat no results.
|
||||
func (m *Manager) Search(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
dl Download,
|
||||
) ([]Candidate, error) {
|
||||
providers := m.enabledProviders()
|
||||
if len(providers) == 0 {
|
||||
@@ -462,7 +492,7 @@ func (m *Manager) Search(
|
||||
sctx, cancel := context.WithTimeout(ctx, searchTimeout)
|
||||
defer cancel()
|
||||
|
||||
c, err := s.Search(sctx, req)
|
||||
c, err := s.Search(sctx, dl)
|
||||
results <- found{candidates: c, err: err, id: id}
|
||||
}(id, s)
|
||||
}
|
||||
@@ -497,7 +527,7 @@ func (m *Manager) Search(
|
||||
return nil, ErrNoCandidates
|
||||
}
|
||||
|
||||
return Rank(req, all, m.priorityFor), nil
|
||||
return Rank(dl, all, m.priorityFor, m.preferences()), nil
|
||||
}
|
||||
|
||||
// Start creates a request, searches for it, and either grabs the clear
|
||||
@@ -506,31 +536,31 @@ func (m *Manager) Search(
|
||||
// in the background under a job.
|
||||
func (m *Manager) Start(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
dl Download,
|
||||
) ([]Candidate, error) {
|
||||
if req.ID == "" {
|
||||
req.ID = newID()
|
||||
if dl.ID == "" {
|
||||
dl.ID = newID()
|
||||
}
|
||||
|
||||
if err := m.store.CreateRequest(ctx, req); err != nil {
|
||||
if err := m.store.CreateDownload(ctx, dl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
job := m.startJob(req)
|
||||
job := m.startJob(dl)
|
||||
|
||||
ranked, err := m.Search(ctx, req)
|
||||
ranked, err := m.Search(ctx, dl)
|
||||
if err != nil {
|
||||
m.failRequest(ctx, job, req.ID, err)
|
||||
m.failDownload(ctx, job, dl.ID, err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.resMu.Lock()
|
||||
m.results[req.ID] = ranked
|
||||
m.results[dl.ID] = ranked
|
||||
m.resMu.Unlock()
|
||||
|
||||
if err := m.store.SetRequestState(
|
||||
ctx, req.ID, StateFound, "",
|
||||
if err := m.store.SetDownloadState(
|
||||
ctx, dl.ID, StateFound, "",
|
||||
); err != nil {
|
||||
m.logger.Warn("could not record found state", "error", err)
|
||||
}
|
||||
@@ -541,12 +571,12 @@ func (m *Manager) Start(
|
||||
))
|
||||
}
|
||||
|
||||
if AutoPickable(req, ranked) {
|
||||
if m.AutoPickable(dl, ranked) {
|
||||
if job != nil {
|
||||
job.Logf(jobs.LevelInfo, "Auto-selected best candidate")
|
||||
}
|
||||
|
||||
go m.grab(context.WithoutCancel(ctx), req, ranked[0], job)
|
||||
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
|
||||
|
||||
return ranked, nil
|
||||
}
|
||||
@@ -559,30 +589,30 @@ func (m *Manager) Start(
|
||||
return ranked, nil
|
||||
}
|
||||
|
||||
// Attempt searches on behalf of the wanted list and starts a download
|
||||
// Attempt searches on behalf of the request list and starts a download
|
||||
// only if there is a clear winner. It returns whether it started and,
|
||||
// when it did not, a sentence the wanted list can show the user.
|
||||
// when it did not, a sentence the request list can show the user.
|
||||
//
|
||||
// Unlike Start it persists nothing when it does not act. A want that
|
||||
// Unlike Start it persists nothing when it does not act. A request that
|
||||
// is retried weekly for a year would otherwise leave fifty failed
|
||||
// request rows behind it, all saying the same thing the want itself
|
||||
// download rows behind it, all saying the same thing the request itself
|
||||
// already says — and none of them anything the user can do something
|
||||
// about. Nobody is watching a reconcile pass, so the only two honest
|
||||
// outcomes are "downloading it now" and "still looking".
|
||||
func (m *Manager) Attempt(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
dl Download,
|
||||
) (bool, string, error) {
|
||||
if req.ID == "" {
|
||||
req.ID = newID()
|
||||
if dl.ID == "" {
|
||||
dl.ID = newID()
|
||||
}
|
||||
|
||||
ranked, err := m.Search(ctx, req)
|
||||
ranked, err := m.Search(ctx, dl)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
if !AutoPickable(req, ranked) {
|
||||
if !m.AutoPickable(dl, ranked) {
|
||||
best := ranked[0]
|
||||
|
||||
return false, fmt.Sprintf(
|
||||
@@ -594,28 +624,28 @@ func (m *Manager) Attempt(
|
||||
), nil
|
||||
}
|
||||
|
||||
if err := m.store.CreateRequest(ctx, req); err != nil {
|
||||
if err := m.store.CreateDownload(ctx, dl); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
m.resMu.Lock()
|
||||
m.results[req.ID] = ranked
|
||||
m.results[dl.ID] = ranked
|
||||
m.resMu.Unlock()
|
||||
|
||||
if err := m.store.SetRequestState(ctx, req.ID, StateFound, ""); err != nil {
|
||||
if err := m.store.SetDownloadState(ctx, dl.ID, StateFound, ""); err != nil {
|
||||
m.logger.Warn("could not record found state", "error", err)
|
||||
}
|
||||
|
||||
job := m.startJob(req)
|
||||
job := m.startJob(dl)
|
||||
|
||||
if job != nil {
|
||||
job.Logf(jobs.LevelInfo, fmt.Sprintf(
|
||||
"Wanted list: auto-selected the best of %d candidates",
|
||||
"Request list: auto-selected the best of %d candidates",
|
||||
len(ranked),
|
||||
))
|
||||
}
|
||||
|
||||
go m.grab(context.WithoutCancel(ctx), req, ranked[0], job)
|
||||
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
|
||||
|
||||
return true, "", nil
|
||||
}
|
||||
@@ -623,15 +653,15 @@ func (m *Manager) Attempt(
|
||||
// Pick starts the transfer for a candidate the user chose.
|
||||
func (m *Manager) Pick(
|
||||
ctx context.Context,
|
||||
requestID, candidateID string,
|
||||
downloadID, candidateID string,
|
||||
) error {
|
||||
req, err := m.store.GetRequest(ctx, requestID)
|
||||
dl, err := m.store.GetDownload(ctx, downloadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.resMu.RLock()
|
||||
ranked := m.results[requestID]
|
||||
ranked := m.results[downloadID]
|
||||
m.resMu.RUnlock()
|
||||
|
||||
var chosen *Candidate
|
||||
@@ -648,25 +678,25 @@ func (m *Manager) Pick(
|
||||
return fmt.Errorf("%w: %s", ErrCandidateGone, candidateID)
|
||||
}
|
||||
|
||||
job := m.startJob(req)
|
||||
job := m.startJob(dl)
|
||||
|
||||
go m.grab(context.WithoutCancel(ctx), req, *chosen, job)
|
||||
go m.grab(context.WithoutCancel(ctx), dl, *chosen, job)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel aborts a live request.
|
||||
func (m *Manager) Cancel(ctx context.Context, requestID string) error {
|
||||
func (m *Manager) Cancel(ctx context.Context, downloadID string) error {
|
||||
m.actMu.Lock()
|
||||
cancel, ok := m.active[requestID]
|
||||
cancel, ok := m.active[downloadID]
|
||||
m.actMu.Unlock()
|
||||
|
||||
if ok {
|
||||
cancel()
|
||||
}
|
||||
|
||||
if err := m.store.SetRequestState(
|
||||
ctx, requestID, StateCancelled, "",
|
||||
if err := m.store.SetDownloadState(
|
||||
ctx, downloadID, StateCancelled, "",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -678,7 +708,7 @@ func (m *Manager) Cancel(ctx context.Context, requestID string) error {
|
||||
// own goroutine and owns the job from here on.
|
||||
func (m *Manager) grab(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
dl Download,
|
||||
c Candidate,
|
||||
job *jobs.Handle,
|
||||
) {
|
||||
@@ -686,12 +716,12 @@ func (m *Manager) grab(
|
||||
defer cancel()
|
||||
|
||||
m.actMu.Lock()
|
||||
m.active[req.ID] = cancel
|
||||
m.active[dl.ID] = cancel
|
||||
m.actMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
m.actMu.Lock()
|
||||
delete(m.active, req.ID)
|
||||
delete(m.active, dl.ID)
|
||||
m.actMu.Unlock()
|
||||
}()
|
||||
|
||||
@@ -701,9 +731,9 @@ func (m *Manager) grab(
|
||||
// happening inside another system, which is doing its own limiting,
|
||||
// and blocking a local slot on it would be counting someone else's
|
||||
// work against our budget.
|
||||
plan, err := m.planTransfer(req, c)
|
||||
plan, err := m.planTransfer(dl, c)
|
||||
if err != nil {
|
||||
m.failRequest(ctx, job, req.ID, err)
|
||||
m.failDownload(ctx, job, dl.ID, err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -715,7 +745,7 @@ func (m *Manager) grab(
|
||||
case provSem <- struct{}{}:
|
||||
defer func() { <-provSem }()
|
||||
case <-ctx.Done():
|
||||
m.failRequest(ctx, job, req.ID, ctx.Err())
|
||||
m.failDownload(ctx, job, dl.ID, ctx.Err())
|
||||
|
||||
return
|
||||
}
|
||||
@@ -726,15 +756,15 @@ func (m *Manager) grab(
|
||||
case globalSem <- struct{}{}:
|
||||
defer func() { <-globalSem }()
|
||||
case <-ctx.Done():
|
||||
m.failRequest(ctx, job, req.ID, ctx.Err())
|
||||
m.failDownload(ctx, job, dl.ID, ctx.Err())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
item := Item{
|
||||
item := DownloadItem{
|
||||
ID: newID(),
|
||||
RequestID: req.ID,
|
||||
DownloadID: dl.ID,
|
||||
ProviderID: c.ProviderID,
|
||||
Candidate: c,
|
||||
State: StateQueued,
|
||||
@@ -743,7 +773,7 @@ func (m *Manager) grab(
|
||||
|
||||
dir, err := m.staging.Reserve(item.ID)
|
||||
if err != nil {
|
||||
m.failRequest(ctx, job, req.ID, err)
|
||||
m.failDownload(ctx, job, dl.ID, err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -751,19 +781,19 @@ func (m *Manager) grab(
|
||||
item.StagingDir = dir
|
||||
|
||||
if err := m.store.CreateItem(ctx, item); err != nil {
|
||||
m.failRequest(ctx, job, req.ID, err)
|
||||
m.failDownload(ctx, job, dl.ID, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
result, err := m.transfer(ctx, req, item, plan, job)
|
||||
result, err := m.transfer(ctx, dl, item, plan, job)
|
||||
if err != nil {
|
||||
m.failItem(ctx, job, item, req.ID, err)
|
||||
m.failItem(ctx, job, item, dl.ID, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
m.setStates(ctx, req.ID, item.ID, StateImporting)
|
||||
m.setStates(ctx, dl.ID, item.ID, StateImporting)
|
||||
|
||||
if job != nil {
|
||||
job.SetPhase("Importing")
|
||||
@@ -790,17 +820,17 @@ func (m *Manager) grab(
|
||||
opts := m.importOptions()
|
||||
opts.WriteTags = true
|
||||
|
||||
opts.LibraryRoot, err = m.library.LibraryPath(req.LibraryID)
|
||||
opts.LibraryRoot, err = m.library.LibraryPath(dl.LibraryID)
|
||||
if err != nil {
|
||||
m.failItem(ctx, job, item, req.ID,
|
||||
m.failItem(ctx, job, item, dl.ID,
|
||||
fmt.Errorf("resolve library root: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
imported, err = m.importer.Import(ctx, req, result, opts)
|
||||
imported, err = m.importer.Import(ctx, dl, result, opts)
|
||||
if err != nil {
|
||||
m.failItem(ctx, job, item, req.ID, err)
|
||||
m.failItem(ctx, job, item, dl.ID, err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -812,21 +842,21 @@ func (m *Manager) grab(
|
||||
m.logger.Warn("could not record imported paths", "error", err)
|
||||
}
|
||||
|
||||
if err := m.store.SetRequestState(
|
||||
ctx, req.ID, StateComplete, "",
|
||||
if err := m.store.SetDownloadState(
|
||||
ctx, dl.ID, StateComplete, "",
|
||||
); err != nil {
|
||||
m.logger.Warn("could not record complete state", "error", err)
|
||||
}
|
||||
|
||||
// A request raised from the wanted list retires its want here
|
||||
// A download raised from a durable Request retires it here
|
||||
// rather than waiting for the next reconcile pass to notice the
|
||||
// files, so the wanted list is right the moment the download
|
||||
// files, so the request list is right the moment the download
|
||||
// finishes. The pass would reach the same conclusion by asking the
|
||||
// library; this is the same answer, sooner.
|
||||
if req.WantID != 0 {
|
||||
if err := m.store.SatisfyWant(ctx, req.WantID); err != nil {
|
||||
if dl.RequestID != 0 {
|
||||
if err := m.store.SatisfyRequest(ctx, dl.RequestID); err != nil {
|
||||
m.logger.Warn(
|
||||
"could not satisfy want", "want", req.WantID, "error", err,
|
||||
"could not satisfy request", "request", dl.RequestID, "error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -835,7 +865,7 @@ func (m *Manager) grab(
|
||||
// mid-flight. Holding it after the download completes would leak a
|
||||
// few hundred candidates per request for the life of the process.
|
||||
m.resMu.Lock()
|
||||
delete(m.results, req.ID)
|
||||
delete(m.results, dl.ID)
|
||||
m.resMu.Unlock()
|
||||
|
||||
// Staging is only released on a fully successful import; a failure
|
||||
@@ -845,10 +875,10 @@ func (m *Manager) grab(
|
||||
}
|
||||
|
||||
if m.library != nil {
|
||||
if err := m.library.ScanLibrary(req.LibraryID); err != nil {
|
||||
if err := m.library.ScanLibrary(dl.LibraryID); err != nil {
|
||||
m.logger.Warn(
|
||||
"could not trigger scan after import",
|
||||
"library", req.LibraryID,
|
||||
"library", dl.LibraryID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
@@ -871,16 +901,16 @@ func (m *Manager) grab(
|
||||
// delegates the whole thing.
|
||||
func (m *Manager) transfer(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
item Item,
|
||||
dl Download,
|
||||
item DownloadItem,
|
||||
plan transferPlan,
|
||||
job *jobs.Handle,
|
||||
) (Result, error) {
|
||||
if plan.delegated() {
|
||||
return m.delegate(ctx, req, item, plan.delegate, job)
|
||||
return m.delegate(ctx, dl, item, plan.delegate, job)
|
||||
}
|
||||
|
||||
m.setStates(ctx, req.ID, item.ID, StateGrabbing)
|
||||
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
|
||||
|
||||
if job != nil {
|
||||
job.SetPhase("Downloading")
|
||||
@@ -896,7 +926,7 @@ func (m *Manager) transfer(
|
||||
return Result{}, fmt.Errorf("grab failed: %w", err)
|
||||
}
|
||||
|
||||
m.setStates(ctx, req.ID, item.ID, StateVerifying)
|
||||
m.setStates(ctx, dl.ID, item.ID, StateVerifying)
|
||||
|
||||
if job != nil {
|
||||
job.SetPhase("Verifying")
|
||||
@@ -966,7 +996,7 @@ type transferPlan struct {
|
||||
func (p transferPlan) delegated() bool { return p.delegate != nil }
|
||||
|
||||
// planTransfer decides how a candidate will be fetched.
|
||||
func (m *Manager) planTransfer(_ Request, c Candidate) (transferPlan, error) {
|
||||
func (m *Manager) planTransfer(_ Download, c Candidate) (transferPlan, error) {
|
||||
providers := m.enabledProviders()
|
||||
|
||||
source, ok := providers[c.ProviderID]
|
||||
@@ -992,12 +1022,12 @@ func (m *Manager) planTransfer(_ Request, c Candidate) (transferPlan, error) {
|
||||
// reports terminal state.
|
||||
func (m *Manager) delegate(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
item Item,
|
||||
dl Download,
|
||||
item DownloadItem,
|
||||
d Delegator,
|
||||
job *jobs.Handle,
|
||||
) (Result, error) {
|
||||
externalID, err := d.Delegate(ctx, req)
|
||||
externalID, err := d.Delegate(ctx, dl)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("delegate request: %w", err)
|
||||
}
|
||||
@@ -1006,7 +1036,7 @@ func (m *Manager) delegate(
|
||||
m.logger.Warn("could not record external id", "error", err)
|
||||
}
|
||||
|
||||
m.setStates(ctx, req.ID, item.ID, StateGrabbing)
|
||||
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
|
||||
|
||||
if job != nil {
|
||||
job.SetPhase("Waiting on external manager")
|
||||
@@ -1104,12 +1134,12 @@ func (m *Manager) progressReporter(
|
||||
}
|
||||
|
||||
// Candidates returns the ranked candidates for a live request.
|
||||
func (m *Manager) Candidates(requestID string) []Candidate {
|
||||
func (m *Manager) Candidates(downloadID string) []Candidate {
|
||||
m.resMu.RLock()
|
||||
defer m.resMu.RUnlock()
|
||||
|
||||
out := make([]Candidate, len(m.results[requestID]))
|
||||
copy(out, m.results[requestID])
|
||||
out := make([]Candidate, len(m.results[downloadID]))
|
||||
copy(out, m.results[downloadID])
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -1117,10 +1147,10 @@ func (m *Manager) Candidates(requestID string) []Candidate {
|
||||
// setStates advances a request and its item together.
|
||||
func (m *Manager) setStates(
|
||||
ctx context.Context,
|
||||
requestID, itemID string,
|
||||
downloadID, itemID string,
|
||||
state State,
|
||||
) {
|
||||
if err := m.store.SetRequestState(ctx, requestID, state, ""); err != nil {
|
||||
if err := m.store.SetDownloadState(ctx, downloadID, state, ""); err != nil {
|
||||
m.logger.Warn("could not set request state", "error", err)
|
||||
}
|
||||
|
||||
@@ -1129,17 +1159,17 @@ func (m *Manager) setStates(
|
||||
}
|
||||
}
|
||||
|
||||
// failRequest records a request-level failure.
|
||||
func (m *Manager) failRequest(
|
||||
// failDownload records a download-level failure.
|
||||
func (m *Manager) failDownload(
|
||||
ctx context.Context,
|
||||
job *jobs.Handle,
|
||||
requestID string,
|
||||
downloadID string,
|
||||
err error,
|
||||
) {
|
||||
m.logger.Warn("download request failed", "request", requestID, "error", err)
|
||||
m.logger.Warn("download failed", "download", downloadID, "error", err)
|
||||
|
||||
if serr := m.store.SetRequestState(
|
||||
ctx, requestID, StateFailed, err.Error(),
|
||||
if serr := m.store.SetDownloadState(
|
||||
ctx, downloadID, StateFailed, err.Error(),
|
||||
); serr != nil {
|
||||
m.logger.Warn("could not record failure", "error", serr)
|
||||
}
|
||||
@@ -1149,12 +1179,12 @@ func (m *Manager) failRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// failItem records an item-level failure and fails its request.
|
||||
// failItem records an item-level failure and fails its download.
|
||||
func (m *Manager) failItem(
|
||||
ctx context.Context,
|
||||
job *jobs.Handle,
|
||||
item Item,
|
||||
requestID string,
|
||||
item DownloadItem,
|
||||
downloadID string,
|
||||
err error,
|
||||
) {
|
||||
if serr := m.store.SetItemState(
|
||||
@@ -1163,30 +1193,30 @@ func (m *Manager) failItem(
|
||||
m.logger.Warn("could not record item failure", "error", serr)
|
||||
}
|
||||
|
||||
m.failRequest(ctx, job, requestID, err)
|
||||
m.failDownload(ctx, job, downloadID, err)
|
||||
}
|
||||
|
||||
// startJob registers the request in the background jobs panel.
|
||||
func (m *Manager) startJob(req Request) *jobs.Handle {
|
||||
func (m *Manager) startJob(dl Download) *jobs.Handle {
|
||||
if m.jobsReg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
title := req.Album
|
||||
title := dl.Album
|
||||
if title == "" {
|
||||
title = req.SearchText()
|
||||
title = dl.SearchText()
|
||||
}
|
||||
|
||||
return m.jobsReg.Start(jobs.Spec{
|
||||
ID: "download-" + req.ID,
|
||||
ID: "download-" + dl.ID,
|
||||
Kind: jobs.KindDownload,
|
||||
Title: "Downloading " + title,
|
||||
Subtitle: req.Artist,
|
||||
Subtitle: dl.Artist,
|
||||
State: jobs.StateRunning,
|
||||
Caps: jobs.Caps{Cancellable: true},
|
||||
Controls: jobs.Controls{
|
||||
Cancel: func() {
|
||||
if err := m.Cancel(context.Background(), req.ID); err != nil {
|
||||
if err := m.Cancel(context.Background(), dl.ID); err != nil {
|
||||
m.logger.Warn("cancel failed", "error", err)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestManagerSearchRanksAcrossProviders(t *testing.T) {
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, mp3)
|
||||
f.manager.installProvider(Config{ID: 2, Priority: 50}, flac)
|
||||
|
||||
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
|
||||
ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestManagerSearchToleratesProviderFailure(t *testing.T) {
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, broken)
|
||||
f.manager.installProvider(Config{ID: 2, Priority: 50}, working)
|
||||
|
||||
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
|
||||
ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func TestManagerSearchNoProviders(t *testing.T) {
|
||||
|
||||
f := newManagerFixture(t)
|
||||
|
||||
_, err := f.manager.Search(context.Background(), fourTrackRequest())
|
||||
_, err := f.manager.Search(context.Background(), fourTrackDownload())
|
||||
if !errors.Is(err, ErrNoProviders) {
|
||||
t.Fatalf("error = %v, want ErrNoProviders", err)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func TestManagerSearchNoCandidates(t *testing.T) {
|
||||
empty := NewFakeProvider(1, "empty", Caps{CanSearch: true})
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, empty)
|
||||
|
||||
_, err := f.manager.Search(context.Background(), fourTrackRequest())
|
||||
_, err := f.manager.Search(context.Background(), fourTrackDownload())
|
||||
if !errors.Is(err, ErrNoCandidates) {
|
||||
t.Fatalf("error = %v, want ErrNoCandidates", err)
|
||||
}
|
||||
@@ -173,21 +173,21 @@ func TestManagerEndToEndAutoPick(t *testing.T) {
|
||||
provider := fakeWithAlbum(1, "flac-source", ".flac")
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
req := fourTrackRequest()
|
||||
dl := fourTrackDownload()
|
||||
|
||||
ranked, err := f.manager.Start(context.Background(), req)
|
||||
ranked, err := f.manager.Start(context.Background(), dl)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
if !AutoPickable(req, ranked) {
|
||||
if !f.manager.AutoPickable(dl, ranked) {
|
||||
t.Fatalf(
|
||||
"expected a clear winner to auto-pick; best match %f quality %f",
|
||||
ranked[0].Match.Overall, ranked[0].Quality.Overall,
|
||||
)
|
||||
}
|
||||
|
||||
waitForRequestState(t, f.store, req.ID, StateComplete)
|
||||
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||
|
||||
if provider.GrabCalls != 1 {
|
||||
t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
|
||||
@@ -231,14 +231,14 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
|
||||
f.manager.installProvider(Config{ID: 2, Priority: 50}, b)
|
||||
|
||||
req := fourTrackRequest()
|
||||
dl := fourTrackDownload()
|
||||
|
||||
ranked, err := f.manager.Start(context.Background(), req)
|
||||
ranked, err := f.manager.Start(context.Background(), dl)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
if AutoPickable(req, ranked) {
|
||||
if f.manager.AutoPickable(dl, ranked) {
|
||||
t.Fatal("two equivalent candidates must not auto-pick")
|
||||
}
|
||||
|
||||
@@ -250,23 +250,23 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
stored, err := f.store.GetRequest(context.Background(), req.ID)
|
||||
stored, err := f.store.GetDownload(context.Background(), dl.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequest: %v", err)
|
||||
t.Fatalf("GetDownload: %v", err)
|
||||
}
|
||||
|
||||
if stored.ID != req.ID {
|
||||
t.Errorf("stored request id = %s, want %s", stored.ID, req.ID)
|
||||
if stored.ID != dl.ID {
|
||||
t.Errorf("stored request id = %s, want %s", stored.ID, dl.ID)
|
||||
}
|
||||
|
||||
// The user picks the second one explicitly.
|
||||
if err := f.manager.Pick(
|
||||
context.Background(), req.ID, ranked[1].ID,
|
||||
context.Background(), dl.ID, ranked[1].ID,
|
||||
); err != nil {
|
||||
t.Fatalf("Pick: %v", err)
|
||||
}
|
||||
|
||||
waitForRequestState(t, f.store, req.ID, StateComplete)
|
||||
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||
}
|
||||
|
||||
func TestManagerPickUnknownCandidate(t *testing.T) {
|
||||
@@ -277,14 +277,14 @@ func TestManagerPickUnknownCandidate(t *testing.T) {
|
||||
provider := fakeWithAlbum(1, "source", ".flac")
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
req := fourTrackRequest()
|
||||
req.Expected = nil // free text: never auto-picks
|
||||
dl := fourTrackDownload()
|
||||
dl.Expected = nil // free text: never auto-picks
|
||||
|
||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
||||
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
err := f.manager.Pick(context.Background(), req.ID, "no-such-candidate")
|
||||
err := f.manager.Pick(context.Background(), dl.ID, "no-such-candidate")
|
||||
if !errors.Is(err, ErrCandidateGone) {
|
||||
t.Fatalf("error = %v, want ErrCandidateGone", err)
|
||||
}
|
||||
@@ -302,13 +302,13 @@ func TestManagerFailedGrabLeavesLibraryClean(t *testing.T) {
|
||||
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
req := fourTrackRequest()
|
||||
dl := fourTrackDownload()
|
||||
|
||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
||||
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
waitForRequestState(t, f.store, req.ID, StateFailed)
|
||||
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
||||
|
||||
entries, err := os.ReadDir(f.root)
|
||||
if err != nil {
|
||||
@@ -355,13 +355,13 @@ func TestManagerPairsSearcherWithTransport(t *testing.T) {
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
|
||||
f.manager.installProvider(Config{ID: 2, Priority: 50}, transport)
|
||||
|
||||
req := fourTrackRequest()
|
||||
dl := fourTrackDownload()
|
||||
|
||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
||||
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
waitForRequestState(t, f.store, req.ID, StateComplete)
|
||||
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||
|
||||
if transport.GrabCalls != 1 {
|
||||
t.Errorf("transport grabs = %d, want 1", transport.GrabCalls)
|
||||
@@ -387,22 +387,22 @@ func TestManagerNoTransportForProtocol(t *testing.T) {
|
||||
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
|
||||
|
||||
req := fourTrackRequest()
|
||||
dl := fourTrackDownload()
|
||||
|
||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
||||
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
waitForRequestState(t, f.store, req.ID, StateFailed)
|
||||
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
||||
}
|
||||
|
||||
// waitForRequestState polls until a request reaches the wanted state.
|
||||
// waitForDownloadState polls until a download reaches the wanted state.
|
||||
// The pipeline runs on its own goroutine, so tests observe it through
|
||||
// the store rather than by reaching into the manager.
|
||||
func waitForRequestState(
|
||||
func waitForDownloadState(
|
||||
t *testing.T,
|
||||
store *Store,
|
||||
requestID string,
|
||||
downloadID string,
|
||||
want State,
|
||||
) {
|
||||
t.Helper()
|
||||
@@ -412,7 +412,7 @@ func waitForRequestState(
|
||||
var last State
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
state, _, err := store.GetRequestState(context.Background(), requestID)
|
||||
state, _, err := store.GetDownloadState(context.Background(), downloadID)
|
||||
if err == nil {
|
||||
last = state
|
||||
if last == want {
|
||||
@@ -423,7 +423,7 @@ func waitForRequestState(
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("request state = %q after 5s, want %q", last, want)
|
||||
t.Fatalf("download state = %q after 5s, want %q", last, want)
|
||||
}
|
||||
|
||||
// A delegate's files are already in the external manager's library,
|
||||
@@ -457,13 +457,13 @@ func TestManagerDelegateReconcilesInPlace(t *testing.T) {
|
||||
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, delegate)
|
||||
|
||||
req := fourTrackRequest()
|
||||
dl := fourTrackDownload()
|
||||
|
||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
||||
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
waitForRequestState(t, f.store, req.ID, StateComplete)
|
||||
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||
|
||||
if delegate.DelegateCalls != 1 {
|
||||
t.Errorf("delegate calls = %d, want 1", delegate.DelegateCalls)
|
||||
@@ -489,9 +489,9 @@ func TestManagerDelegateReconcilesInPlace(t *testing.T) {
|
||||
}
|
||||
|
||||
// The external paths were recorded against the item.
|
||||
items, err := f.store.ListItemsForRequest(context.Background(), req.ID)
|
||||
items, err := f.store.ListItemsForDownload(context.Background(), dl.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListItemsForRequest: %v", err)
|
||||
t.Fatalf("ListItemsForDownload: %v", err)
|
||||
}
|
||||
|
||||
if len(items) != 1 {
|
||||
|
||||
@@ -50,7 +50,7 @@ type Provider interface {
|
||||
// respect ctx deadlines: the pipeline searches providers concurrently
|
||||
// with a per-provider timeout and takes whatever came back in time.
|
||||
type Searcher interface {
|
||||
Search(ctx context.Context, req Request) ([]Candidate, error)
|
||||
Search(ctx context.Context, dl Download) ([]Candidate, error)
|
||||
}
|
||||
|
||||
// Transporter moves a candidate's bytes into dst, which the pipeline
|
||||
@@ -73,7 +73,7 @@ type Transporter interface {
|
||||
// the manager reports terminal state.
|
||||
type Delegator interface {
|
||||
// Delegate submits the request and returns the manager's own ID.
|
||||
Delegate(ctx context.Context, req Request) (string, error)
|
||||
Delegate(ctx context.Context, dl Download) (string, error)
|
||||
|
||||
// Poll reports on a previously delegated request.
|
||||
Poll(ctx context.Context, externalID string) (DelegateStatus, error)
|
||||
@@ -92,18 +92,18 @@ type Delegator interface {
|
||||
// pushes, the external system receives. Pulling happens only when the
|
||||
// user explicitly imports.
|
||||
type Lister interface {
|
||||
// PushWant records a want in the provider's own list and returns
|
||||
// the provider's identifier for it. Implementations must be
|
||||
// idempotent: pushing a want the provider already has returns the
|
||||
// existing identifier rather than duplicating it.
|
||||
PushWant(ctx context.Context, w Want) (string, error)
|
||||
// PushRequest records a request in the provider's own list and
|
||||
// returns the provider's identifier for it. Implementations must
|
||||
// be idempotent: pushing a request the provider already has returns
|
||||
// the existing identifier rather than duplicating it.
|
||||
PushRequest(ctx context.Context, r Request) (string, error)
|
||||
|
||||
// RemoveWant drops a previously pushed want. Best-effort.
|
||||
RemoveWant(ctx context.Context, externalID string) error
|
||||
// RemoveRequest drops a previously pushed request. Best-effort.
|
||||
RemoveRequest(ctx context.Context, externalID string) error
|
||||
|
||||
// ListWants reads the provider's list back, for the deliberate
|
||||
// ListRequests reads the provider's list back, for the deliberate
|
||||
// import path. LibraryID is filled in by the caller.
|
||||
ListWants(ctx context.Context) ([]Want, error)
|
||||
ListRequests(ctx context.Context) ([]Request, error)
|
||||
}
|
||||
|
||||
// ProviderInfo is a provider's identity as the frontend sees it.
|
||||
|
||||
@@ -237,8 +237,8 @@ type lidarrTrackFile struct {
|
||||
// Delegate adds the album to Lidarr, monitors it and triggers a search.
|
||||
// The external ID returned is Lidarr's album ID, which is what Poll
|
||||
// needs and what survives a restart.
|
||||
func (l *lidarr) Delegate(ctx context.Context, req Request) (string, error) {
|
||||
album, err := l.findAlbum(ctx, req)
|
||||
func (l *lidarr) Delegate(ctx context.Context, dl Download) (string, error) {
|
||||
album, err := l.findAlbum(ctx, dl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -276,12 +276,12 @@ func (l *lidarr) Delegate(ctx context.Context, req Request) (string, error) {
|
||||
// not.
|
||||
func (l *lidarr) findAlbum(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
dl Download,
|
||||
) (lidarrAlbum, error) {
|
||||
term := req.SearchText()
|
||||
term := dl.SearchText()
|
||||
|
||||
if req.ReleaseGroupMBID != "" {
|
||||
term = "lidarr:" + req.ReleaseGroupMBID
|
||||
if dl.ReleaseGroupMBID != "" {
|
||||
term = "lidarr:" + dl.ReleaseGroupMBID
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
@@ -300,7 +300,7 @@ func (l *lidarr) findAlbum(
|
||||
}
|
||||
}
|
||||
|
||||
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, req.SearchText())
|
||||
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, dl.SearchText())
|
||||
}
|
||||
|
||||
// addArtistForAlbum adds the album's artist so the album becomes a real
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Lidarr's Lister role: mirroring this app's wanted list into Lidarr's
|
||||
// Lidarr's Lister role: mirroring this app's request list into Lidarr's
|
||||
// own monitoring.
|
||||
//
|
||||
// Lidarr already models exactly what a want is — a monitored artist or
|
||||
// Lidarr already models exactly what a request is — a monitored artist or
|
||||
// a monitored album — so the mapping is direct and, more usefully, it
|
||||
// means an artist subscription made here keeps working through Lidarr's
|
||||
// own release-checking even while this app is closed. That is the
|
||||
@@ -21,52 +21,52 @@ import (
|
||||
//
|
||||
// The mapping is deliberately lossy in one direction only:
|
||||
//
|
||||
// artist want -> Lidarr artist, monitored
|
||||
// release-group want -> Lidarr album, monitored (artist added if new)
|
||||
// release want -> same, at release-group granularity
|
||||
// recording want -> not pushed; Lidarr has no concept of wanting
|
||||
// artist request -> Lidarr artist, monitored
|
||||
// release-group request -> Lidarr album, monitored (artist added if new)
|
||||
// release request -> same, at release-group granularity
|
||||
// recording request -> not pushed; Lidarr has no concept of wanting
|
||||
// one track, and monitoring the whole album to
|
||||
// get it would download far more than asked.
|
||||
//
|
||||
// Nothing here searches. Pushing a want expresses intent; Lidarr
|
||||
// Nothing here searches. Pushing a request expresses intent; Lidarr
|
||||
// decides when to act on it, which is the point of delegating.
|
||||
|
||||
// PushWant records a want in Lidarr's own monitoring.
|
||||
// PushRequest records a request in Lidarr's own monitoring.
|
||||
//
|
||||
// It is idempotent because Lidarr is: adding an artist that already
|
||||
// exists returns the existing record, and monitoring an already-
|
||||
// monitored album is a no-op. Callers rely on that — the reconciler
|
||||
// pushes on every pass until it gets an ID back.
|
||||
func (l *lidarr) PushWant(ctx context.Context, w Want) (string, error) {
|
||||
switch w.Entity {
|
||||
func (l *lidarr) PushRequest(ctx context.Context, r Request) (string, error) {
|
||||
switch r.Entity {
|
||||
case EntityArtist:
|
||||
return l.pushArtistWant(ctx, w)
|
||||
return l.pushArtistRequest(ctx, r)
|
||||
case EntityReleaseGroup, EntityRelease:
|
||||
return l.pushAlbumWant(ctx, w)
|
||||
return l.pushAlbumRequest(ctx, r)
|
||||
case EntityRecording:
|
||||
// Deliberately unsupported rather than approximated. See the
|
||||
// mapping note above.
|
||||
return "", nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, w.Entity)
|
||||
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, r.Entity)
|
||||
}
|
||||
}
|
||||
|
||||
// pushArtistWant makes Lidarr monitor an artist.
|
||||
// pushArtistRequest makes Lidarr monitor an artist.
|
||||
//
|
||||
// Scope is honoured through Lidarr's own monitor option rather than by
|
||||
// pushing each album separately: "future" maps to monitoring new
|
||||
// releases only, "all" to monitoring everything missing. Letting
|
||||
// Lidarr apply the policy means it stays applied to albums released
|
||||
// after this push, which is what a subscription is for.
|
||||
func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
|
||||
existing, err := l.findArtistByMBID(ctx, w.MBID)
|
||||
func (l *lidarr) pushArtistRequest(ctx context.Context, r Request) (string, error) {
|
||||
existing, err := l.findArtistByMBID(ctx, r.MBID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
monitor := "future"
|
||||
if w.Scope == ScopeAll {
|
||||
if r.Scope == ScopeAll {
|
||||
monitor = "missing"
|
||||
}
|
||||
|
||||
@@ -84,13 +84,13 @@ func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
name := w.Artist
|
||||
name := r.Artist
|
||||
if name == "" {
|
||||
name = w.Title
|
||||
name = r.Title
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"foreignArtistId": w.MBID,
|
||||
"foreignArtistId": r.MBID,
|
||||
"artistName": name,
|
||||
"qualityProfileId": quality,
|
||||
"metadataProfileId": metadata,
|
||||
@@ -118,13 +118,13 @@ func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
|
||||
return strconv.Itoa(created.ID), nil
|
||||
}
|
||||
|
||||
// pushAlbumWant makes Lidarr monitor one album, adding its artist if
|
||||
// pushAlbumRequest makes Lidarr monitor one album, adding its artist if
|
||||
// Lidarr has never heard of them.
|
||||
func (l *lidarr) pushAlbumWant(ctx context.Context, w Want) (string, error) {
|
||||
album, err := l.findAlbum(ctx, Request{
|
||||
ReleaseGroupMBID: w.MBID,
|
||||
Artist: w.Artist,
|
||||
Album: w.Title,
|
||||
func (l *lidarr) pushAlbumRequest(ctx context.Context, r Request) (string, error) {
|
||||
album, err := l.findAlbum(ctx, Download{
|
||||
ReleaseGroupMBID: r.MBID,
|
||||
Artist: r.Artist,
|
||||
Album: r.Title,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -146,13 +146,13 @@ func (l *lidarr) pushAlbumWant(ctx context.Context, w Want) (string, error) {
|
||||
return strconv.Itoa(album.ID), nil
|
||||
}
|
||||
|
||||
// RemoveWant stops Lidarr monitoring something.
|
||||
// RemoveRequest stops Lidarr monitoring something.
|
||||
//
|
||||
// It unmonitors rather than deletes: the user's Lidarr may have been
|
||||
// monitoring that artist long before this app existed, and removing a
|
||||
// want here is not permission to tear down their setup. An unmonitored
|
||||
// request here is not permission to tear down their setup. An unmonitored
|
||||
// artist stays in their library with its files intact.
|
||||
func (l *lidarr) RemoveWant(ctx context.Context, externalID string) error {
|
||||
func (l *lidarr) RemoveRequest(ctx context.Context, externalID string) error {
|
||||
id, err := strconv.Atoi(externalID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: bad lidarr id %q", ErrLidarrNoMatch, externalID)
|
||||
@@ -180,14 +180,14 @@ func (l *lidarr) RemoveWant(ctx context.Context, externalID string) error {
|
||||
return l.client.put(ctx, endpoint, artist, nil)
|
||||
}
|
||||
|
||||
// ListWants reads Lidarr's monitored artists back, for the deliberate
|
||||
// ListRequests reads Lidarr's monitored artists back, for the deliberate
|
||||
// "import what Lidarr is already watching" action.
|
||||
//
|
||||
// Only artists are imported, not their individual monitored albums: an
|
||||
// artist is the durable statement of intent, and importing every
|
||||
// monitored album alongside it would produce a wanted list that is
|
||||
// monitored album alongside it would produce a request list that is
|
||||
// mostly redundant with the subscription that generated it.
|
||||
func (l *lidarr) ListWants(ctx context.Context) ([]Want, error) {
|
||||
func (l *lidarr) ListRequests(ctx context.Context) ([]Request, error) {
|
||||
var artists []struct {
|
||||
lidarrArtist
|
||||
|
||||
@@ -198,14 +198,14 @@ func (l *lidarr) ListWants(ctx context.Context) ([]Want, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Want, 0, len(artists))
|
||||
out := make([]Request, 0, len(artists))
|
||||
|
||||
for _, a := range artists {
|
||||
if !a.Monitored || a.ForeignArtistID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, Want{
|
||||
out = append(out, Request{
|
||||
MBID: a.ForeignArtistID,
|
||||
Entity: EntityArtist,
|
||||
Artist: a.ArtistName,
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
// scope translated into Lidarr's own monitor option — so the policy
|
||||
// keeps applying to albums released after the push, which is the whole
|
||||
// reason to mirror a subscription rather than a list of albums.
|
||||
func TestLidarrPushArtistWantMapsScope(t *testing.T) {
|
||||
func TestLidarrPushArtistRequestMapsScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
scope WantScope
|
||||
scope RequestScope
|
||||
wantMonitor string
|
||||
}{
|
||||
{name: "future", scope: ScopeFuture, wantMonitor: "future"},
|
||||
@@ -25,14 +25,14 @@ func TestLidarrPushArtistWantMapsScope(t *testing.T) {
|
||||
stub := newLidarrStub(t)
|
||||
l := newStubLidarr(t, stub)
|
||||
|
||||
id, err := l.PushWant(context.Background(), Want{
|
||||
id, err := l.PushRequest(context.Background(), Request{
|
||||
MBID: "artist-mbid",
|
||||
Entity: EntityArtist,
|
||||
Artist: "Radiohead",
|
||||
Scope: tt.scope,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: PushWant: %v", tt.name, err)
|
||||
t.Fatalf("%s: PushRequest: %v", tt.name, err)
|
||||
}
|
||||
|
||||
if id != "42" {
|
||||
@@ -70,7 +70,7 @@ func TestLidarrPushArtistWantMapsScope(t *testing.T) {
|
||||
// Pushing a want Lidarr already has returns the existing ID instead of
|
||||
// adding a second copy — the reconciler pushes on every pass, so this
|
||||
// is load-bearing rather than tidy.
|
||||
func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
|
||||
func TestLidarrPushArtistRequestIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newLidarrStub(t)
|
||||
@@ -83,13 +83,13 @@ func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
|
||||
|
||||
l := newStubLidarr(t, stub)
|
||||
|
||||
id, err := l.PushWant(context.Background(), Want{
|
||||
id, err := l.PushRequest(context.Background(), Request{
|
||||
MBID: "artist-mbid",
|
||||
Entity: EntityArtist,
|
||||
Artist: "Radiohead",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PushWant: %v", err)
|
||||
t.Fatalf("PushRequest: %v", err)
|
||||
}
|
||||
|
||||
if id != "7" {
|
||||
@@ -107,19 +107,19 @@ func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
|
||||
|
||||
// Lidarr cannot express "I want one track", and monitoring the whole
|
||||
// album to get it would download far more than was asked for.
|
||||
func TestLidarrPushRecordingWantIsSkipped(t *testing.T) {
|
||||
func TestLidarrPushRecordingRequestIsSkipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newLidarrStub(t)
|
||||
l := newStubLidarr(t, stub)
|
||||
|
||||
id, err := l.PushWant(context.Background(), Want{
|
||||
id, err := l.PushRequest(context.Background(), Request{
|
||||
MBID: "recording-mbid",
|
||||
Entity: EntityRecording,
|
||||
Title: "Paranoid Android",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PushWant: %v", err)
|
||||
t.Fatalf("PushRequest: %v", err)
|
||||
}
|
||||
|
||||
if id != "" {
|
||||
@@ -141,7 +141,7 @@ func TestLidarrPushRecordingWantIsSkipped(t *testing.T) {
|
||||
|
||||
// Importing adopts monitored artists conservatively: a subscription
|
||||
// pulled in from elsewhere must not queue a back catalogue.
|
||||
func TestLidarrListWantsImportsMonitoredArtistsOnly(t *testing.T) {
|
||||
func TestLidarrListRequestsImportsMonitoredArtistsOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newLidarrStub(t)
|
||||
@@ -168,40 +168,40 @@ func TestLidarrListWantsImportsMonitoredArtistsOnly(t *testing.T) {
|
||||
|
||||
l := newStubLidarr(t, stub)
|
||||
|
||||
wants, err := l.ListWants(context.Background())
|
||||
requests, err := l.ListRequests(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListWants: %v", err)
|
||||
t.Fatalf("ListRequests: %v", err)
|
||||
}
|
||||
|
||||
if len(wants) != 1 {
|
||||
t.Fatalf("imported %d wants, want 1", len(wants))
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("imported %d requests, want 1", len(requests))
|
||||
}
|
||||
|
||||
w := wants[0]
|
||||
req := requests[0]
|
||||
|
||||
if w.MBID != "artist-1" {
|
||||
t.Errorf("mbid = %q, want artist-1", w.MBID)
|
||||
if req.MBID != "artist-1" {
|
||||
t.Errorf("mbid = %q, want artist-1", req.MBID)
|
||||
}
|
||||
|
||||
if w.Entity != EntityArtist {
|
||||
t.Errorf("entity = %q, want artist", w.Entity)
|
||||
if req.Entity != EntityArtist {
|
||||
t.Errorf("entity = %q, want artist", req.Entity)
|
||||
}
|
||||
|
||||
if w.Scope != ScopeFuture {
|
||||
t.Errorf("scope = %q, want the conservative future", w.Scope)
|
||||
if req.Scope != ScopeFuture {
|
||||
t.Errorf("scope = %q, want the conservative future", req.Scope)
|
||||
}
|
||||
}
|
||||
|
||||
// Removing a want must not tear down a Lidarr setup that may predate
|
||||
// this app: it unmonitors, it does not delete.
|
||||
func TestLidarrRemoveWantUnmonitorsOnly(t *testing.T) {
|
||||
func TestLidarrRemoveRequestUnmonitorsOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newLidarrStub(t)
|
||||
l := newStubLidarr(t, stub)
|
||||
|
||||
if err := l.RemoveWant(context.Background(), "55"); err != nil {
|
||||
t.Fatalf("RemoveWant: %v", err)
|
||||
if err := l.RemoveRequest(context.Background(), "55"); err != nil {
|
||||
t.Fatalf("RemoveRequest: %v", err)
|
||||
}
|
||||
|
||||
stub.mu.Lock()
|
||||
|
||||
@@ -327,7 +327,7 @@ func TestLidarrDelegateExistingAlbum(t *testing.T) {
|
||||
|
||||
l := newStubLidarr(t, stub)
|
||||
|
||||
externalID, err := l.Delegate(context.Background(), Request{
|
||||
externalID, err := l.Delegate(context.Background(), Download{
|
||||
ReleaseGroupMBID: "rg-mbid",
|
||||
Artist: "Radiohead",
|
||||
Album: "OK Computer",
|
||||
@@ -373,7 +373,7 @@ func TestLidarrDelegateNewArtistMonitorsNothingByDefault(t *testing.T) {
|
||||
|
||||
l := newStubLidarr(t, stub)
|
||||
|
||||
if _, err := l.Delegate(context.Background(), Request{
|
||||
if _, err := l.Delegate(context.Background(), Download{
|
||||
ReleaseGroupMBID: "rg-mbid",
|
||||
Artist: "Radiohead",
|
||||
Album: "OK Computer",
|
||||
@@ -414,7 +414,7 @@ func TestLidarrDelegateNoMatch(t *testing.T) {
|
||||
|
||||
l := newStubLidarr(t, stub)
|
||||
|
||||
_, err := l.Delegate(context.Background(), Request{
|
||||
_, err := l.Delegate(context.Background(), Download{
|
||||
Artist: "Nobody",
|
||||
Album: "Nothing",
|
||||
})
|
||||
|
||||
@@ -210,10 +210,10 @@ type prowlarrResult struct {
|
||||
// Search queries every configured indexer through Prowlarr.
|
||||
func (p *prowlarr) Search(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
dl Download,
|
||||
) ([]Candidate, error) {
|
||||
query := url.Values{}
|
||||
query.Set("query", req.SearchText())
|
||||
query.Set("query", dl.SearchText())
|
||||
query.Set("categories", prowlarrMusicCategory)
|
||||
query.Set("type", "search")
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestProwlarrSearchMarksProtocols(t *testing.T) {
|
||||
|
||||
p := newStubProwlarr(t, stub, nil)
|
||||
|
||||
got, err := p.Search(context.Background(), Request{
|
||||
got, err := p.Search(context.Background(), Download{
|
||||
Artist: "Radiohead",
|
||||
Album: "OK Computer",
|
||||
})
|
||||
@@ -230,7 +230,7 @@ func TestProwlarrFiltersDeadTorrents(t *testing.T) {
|
||||
|
||||
p := newStubProwlarr(t, stub, map[string]string{"minSeeders": "1"})
|
||||
|
||||
got, err := p.Search(context.Background(), Request{Query: "x"})
|
||||
got, err := p.Search(context.Background(), Download{Query: "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
@@ -253,7 +253,7 @@ func TestProwlarrSearchesMusicCategory(t *testing.T) {
|
||||
p := newStubProwlarr(t, stub, nil)
|
||||
|
||||
if _, err := p.Search(
|
||||
context.Background(), Request{Query: "radiohead"},
|
||||
context.Background(), Download{Query: "radiohead"},
|
||||
); err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ func (t slskdTransfer) done() (finished, ok bool) {
|
||||
// per-folder candidates. A folder from one peer is the unit a user
|
||||
// actually wants: Soulseek has no album concept, but people organise
|
||||
// their shares by album directory.
|
||||
func (s *slskd) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
||||
func (s *slskd) Search(ctx context.Context, dl Download) ([]Candidate, error) {
|
||||
// slskd's search endpoint deserializes id as a .NET Guid server-side,
|
||||
// so it must be a dashed UUID — the app's own newID() (a plain hex
|
||||
// string, used for request/item IDs elsewhere) is rejected with an
|
||||
@@ -287,7 +287,7 @@ func (s *slskd) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
||||
|
||||
body := map[string]any{
|
||||
"id": searchID,
|
||||
"searchText": req.SearchText(),
|
||||
"searchText": dl.SearchText(),
|
||||
}
|
||||
|
||||
if err := s.client.post(ctx, "/api/v0/searches", body, nil); err != nil {
|
||||
|
||||
@@ -288,7 +288,7 @@ func TestSlskdGroupsResultsByPeerAndFolder(t *testing.T) {
|
||||
|
||||
s, _ := newStubSlskd(t, stub)
|
||||
|
||||
got, err := s.Search(context.Background(), Request{
|
||||
got, err := s.Search(context.Background(), Download{
|
||||
Artist: "Radiohead",
|
||||
Album: "OK Computer",
|
||||
})
|
||||
@@ -354,7 +354,7 @@ func TestSlskdSkipsTinyFolders(t *testing.T) {
|
||||
|
||||
s, _ := newStubSlskd(t, stub)
|
||||
|
||||
got, err := s.Search(context.Background(), Request{Query: "x"})
|
||||
got, err := s.Search(context.Background(), Download{Query: "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
|
||||
@@ -201,9 +201,9 @@ func (e ytEntry) link() string {
|
||||
// how yt-dlp is actually useful for albums — a single "full album"
|
||||
// video is one file and cannot be imported as tracks. Without a
|
||||
// tracklist it falls back to returning the top individual results.
|
||||
func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
||||
if len(req.Expected) > 0 {
|
||||
c, err := y.assembleAlbum(ctx, req)
|
||||
func (y *ytDlp) Search(ctx context.Context, dl Download) ([]Candidate, error) {
|
||||
if len(dl.Expected) > 0 {
|
||||
c, err := y.assembleAlbum(ctx, dl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := y.search(ctx, req.SearchText(), ytSearchCount)
|
||||
entries, err := y.search(ctx, dl.SearchText(), ytSearchCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -257,7 +257,7 @@ func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
||||
// threshold decides whether what arrived is enough.
|
||||
func (y *ytDlp) assembleAlbum(
|
||||
ctx context.Context,
|
||||
req Request,
|
||||
dl Download,
|
||||
) (Candidate, error) {
|
||||
type hit struct {
|
||||
index int
|
||||
@@ -272,10 +272,10 @@ func (y *ytDlp) assembleAlbum(
|
||||
group, gctx := errgroup.WithContext(ctx)
|
||||
group.SetLimit(ytTrackConcurrency)
|
||||
|
||||
for i, track := range req.Expected {
|
||||
for i, track := range dl.Expected {
|
||||
group.Go(func() error {
|
||||
query := strings.TrimSpace(
|
||||
req.Artist + " " + track.Title,
|
||||
dl.Artist + " " + track.Title,
|
||||
)
|
||||
|
||||
entries, err := y.search(gctx, query, 1)
|
||||
@@ -300,11 +300,11 @@ func (y *ytDlp) assembleAlbum(
|
||||
}
|
||||
|
||||
c := Candidate{
|
||||
ID: "ytdlp:album:" + req.ID,
|
||||
ID: "ytdlp:album:" + dl.ID,
|
||||
Kind: KindYtDlp,
|
||||
Protocol: ProtocolDirect,
|
||||
Title: req.Album,
|
||||
Artist: req.Artist,
|
||||
Title: dl.Album,
|
||||
Artist: dl.Artist,
|
||||
Origin: "yt-dlp (assembled per track)",
|
||||
Health: 0.75,
|
||||
Payload: map[string]string{},
|
||||
@@ -312,7 +312,7 @@ func (y *ytDlp) assembleAlbum(
|
||||
}
|
||||
|
||||
for _, h := range hits {
|
||||
track := req.Expected[h.index]
|
||||
track := dl.Expected[h.index]
|
||||
|
||||
link := h.entry.link()
|
||||
if link == "" {
|
||||
|
||||
@@ -130,7 +130,7 @@ cat <<'EOF'
|
||||
EOF
|
||||
`)
|
||||
|
||||
got, err := y.Search(context.Background(), Request{
|
||||
got, err := y.Search(context.Background(), Download{
|
||||
Artist: "Radiohead",
|
||||
Album: "OK Computer",
|
||||
})
|
||||
@@ -173,7 +173,7 @@ not json at all
|
||||
EOF
|
||||
`)
|
||||
|
||||
got, err := y.Search(context.Background(), Request{Query: "radiohead"})
|
||||
got, err := y.Search(context.Background(), Download{Query: "radiohead"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
@@ -193,8 +193,8 @@ func TestYtDlpAssemblesAlbumFromTracklist(t *testing.T) {
|
||||
echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https://example.com/x","filesize_approx":4000000}'
|
||||
`)
|
||||
|
||||
req := Request{
|
||||
ID: "req-1",
|
||||
dl := Download{
|
||||
ID: "dl-1",
|
||||
ReleaseMBID: "mbid-1",
|
||||
Artist: "Radiohead",
|
||||
Album: "OK Computer",
|
||||
@@ -205,7 +205,7 @@ echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https:/
|
||||
},
|
||||
}
|
||||
|
||||
got, err := y.Search(context.Background(), req)
|
||||
got, err := y.Search(context.Background(), dl)
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ case "$*" in
|
||||
esac
|
||||
`)
|
||||
|
||||
got, err := y.Search(context.Background(), Request{
|
||||
got, err := y.Search(context.Background(), Download{
|
||||
ID: "req-1",
|
||||
ReleaseMBID: "mbid-1",
|
||||
Artist: "Radiohead",
|
||||
|
||||
+137
-23
@@ -34,12 +34,13 @@ const (
|
||||
weightArtistFit = 0.12
|
||||
)
|
||||
|
||||
// Quality sub-weights.
|
||||
// Quality sub-weights. They sum to 1.0 along with weightSizeFit below.
|
||||
const (
|
||||
weightFormat = 0.45
|
||||
weightBitrate = 0.25
|
||||
weightFormat = 0.42
|
||||
weightBitrate = 0.23
|
||||
weightHealth = 0.20
|
||||
weightPriority = 0.10
|
||||
weightSizeFit = 0.05
|
||||
)
|
||||
|
||||
// unanchoredCap bounds the match score of a free-text request. Without
|
||||
@@ -47,20 +48,119 @@ const (
|
||||
// looking score would be a lie — and auto-pick keys off this.
|
||||
const unanchoredCap = 0.65
|
||||
|
||||
// AutoDownloadPrefs gates and scores what AutoPickable may choose
|
||||
// without asking. Zero values are permissive: no size window and no
|
||||
// format restriction.
|
||||
type AutoDownloadPrefs struct {
|
||||
// MinSizeMB and MaxSizeMB bound what auto-pick will grab. Zero
|
||||
// means no bound on that side. A candidate outside the window is
|
||||
// filtered out of auto-pick entirely, not merely scored down — a
|
||||
// tiny "sampler" torrent or a boxset ten times the expected size is
|
||||
// usually the wrong thing entirely, not a worse copy of the right
|
||||
// thing.
|
||||
MinSizeMB int `json:"minSizeMb"`
|
||||
MaxSizeMB int `json:"maxSizeMb"`
|
||||
|
||||
// PreferredSizeMB nudges the score toward a target size within the
|
||||
// min/max window (a lossless rip and a heavily-padded lossless rip
|
||||
// can both pass the window). Zero disables the nudge; sizeFit then
|
||||
// returns a neutral value that does not affect ranking.
|
||||
PreferredSizeMB int `json:"preferredSizeMb"`
|
||||
|
||||
// AllowedFormats restricts auto-pick to candidates whose audio
|
||||
// files are all in one of these formats. Empty means no
|
||||
// restriction.
|
||||
AllowedFormats []Format `json:"allowedFormats"`
|
||||
}
|
||||
|
||||
// eligible reports whether a candidate may be auto-picked under these
|
||||
// preferences: within the size window (when set) and, when a format
|
||||
// list is given, every audio file in an allowed format.
|
||||
func (p AutoDownloadPrefs) eligible(c Candidate) bool {
|
||||
const bytesPerMB = 1 << 20
|
||||
|
||||
if p.MinSizeMB > 0 && c.TotalSize < int64(p.MinSizeMB)*bytesPerMB {
|
||||
return false
|
||||
}
|
||||
|
||||
if p.MaxSizeMB > 0 && c.TotalSize > int64(p.MaxSizeMB)*bytesPerMB {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(p.AllowedFormats) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
allowed := make(map[Format]bool, len(p.AllowedFormats))
|
||||
for _, f := range p.AllowedFormats {
|
||||
allowed[f] = true
|
||||
}
|
||||
|
||||
for _, f := range c.AudioFiles() {
|
||||
if !allowed[f.Format] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// filter returns only the candidates these preferences allow to be
|
||||
// auto-picked, in the same (already ranked) order.
|
||||
func (p AutoDownloadPrefs) filter(ranked []Candidate) []Candidate {
|
||||
out := make([]Candidate, 0, len(ranked))
|
||||
|
||||
for _, c := range ranked {
|
||||
if p.eligible(c) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// sizeFit scores how close totalSize is to PreferredSizeMB, 0..1,
|
||||
// falling off linearly as the size doubles or halves away from it.
|
||||
// Returns a neutral 0.5 when no preference is set, so the absence of a
|
||||
// preference does not bias ranking.
|
||||
func (p AutoDownloadPrefs) sizeFit(totalSize int64) float64 {
|
||||
const (
|
||||
bytesPerMB = 1 << 20
|
||||
neutral = 0.5
|
||||
)
|
||||
|
||||
if p.PreferredSizeMB <= 0 || totalSize <= 0 {
|
||||
return neutral
|
||||
}
|
||||
|
||||
preferred := float64(p.PreferredSizeMB) * bytesPerMB
|
||||
ratio := float64(totalSize) / preferred
|
||||
|
||||
if ratio < 1 {
|
||||
ratio = 1 / ratio
|
||||
}
|
||||
|
||||
// ratio is now >= 1: 1.0 is an exact match, 2.0 is double or half
|
||||
// the preferred size. Falls to 0 at 2x away and beyond.
|
||||
fit := 1 - (ratio - 1)
|
||||
|
||||
return clamp01(fit)
|
||||
}
|
||||
|
||||
// Score fills a candidate's Match, Quality and Score fields.
|
||||
func Score(req Request, c Candidate, priority int) Candidate {
|
||||
func Score(dl Download, c Candidate, priority int, prefs AutoDownloadPrefs) Candidate {
|
||||
c.Files = AnnotateFiles(c.Files)
|
||||
|
||||
audio := c.AudioFiles()
|
||||
|
||||
matched, titleFit := matchFiles(audio, req.Expected)
|
||||
matched, titleFit := matchFiles(audio, dl.Expected)
|
||||
|
||||
// Write the alignment back so the picker can show which file maps
|
||||
// to which track.
|
||||
c.Files = mergeMatched(c.Files, matched)
|
||||
|
||||
c.Match = scoreMatch(req, c, audio, titleFit)
|
||||
c.Quality = scoreQuality(c, audio, priority)
|
||||
c.Match = scoreMatch(dl, c, audio, titleFit)
|
||||
c.Quality = scoreQuality(c, audio, priority, prefs)
|
||||
|
||||
c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall
|
||||
|
||||
@@ -69,17 +169,17 @@ func Score(req Request, c Candidate, priority int) Candidate {
|
||||
|
||||
// scoreMatch answers whether this candidate is the requested release.
|
||||
func scoreMatch(
|
||||
req Request,
|
||||
dl Download,
|
||||
c Candidate,
|
||||
audio []CandidateFile,
|
||||
titleFit float64,
|
||||
) MatchScore {
|
||||
m := MatchScore{
|
||||
Anchored: req.Anchored(),
|
||||
Anchored: dl.Anchored(),
|
||||
TitleFit: titleFit,
|
||||
}
|
||||
|
||||
m.Completeness = completeness(len(audio), len(req.Expected))
|
||||
m.Completeness = completeness(len(audio), len(dl.Expected))
|
||||
|
||||
// The candidate's own title, and the folder its files sit in, are
|
||||
// two independent guesses at the album name. Take the better one:
|
||||
@@ -90,16 +190,16 @@ func scoreMatch(
|
||||
}
|
||||
|
||||
m.AlbumFit = math.Max(
|
||||
autotag.TitleSimilarity(req.Album, c.Title),
|
||||
autotag.TitleSimilarity(req.Album, folder),
|
||||
autotag.TitleSimilarity(dl.Album, c.Title),
|
||||
autotag.TitleSimilarity(dl.Album, folder),
|
||||
)
|
||||
|
||||
m.ArtistFit = artistFit(req.Artist, c)
|
||||
m.ArtistFit = artistFit(dl.Artist, c)
|
||||
|
||||
// With no expected tracklist there is no title signal at all, so
|
||||
// redistribute its weight onto the album/artist evidence rather
|
||||
// than scoring every free-text result as half-wrong.
|
||||
if len(req.Expected) == 0 {
|
||||
if len(dl.Expected) == 0 {
|
||||
m.Overall = 0.55*m.AlbumFit + 0.45*m.ArtistFit
|
||||
} else {
|
||||
m.Overall = weightTitleFit*m.TitleFit +
|
||||
@@ -178,10 +278,12 @@ func scoreQuality(
|
||||
c Candidate,
|
||||
audio []CandidateFile,
|
||||
priority int,
|
||||
prefs AutoDownloadPrefs,
|
||||
) QualityScore {
|
||||
q := QualityScore{
|
||||
Health: clamp01(c.Health),
|
||||
Priority: clamp01(float64(priority) / 100.0),
|
||||
SizeFit: prefs.sizeFit(c.TotalSize),
|
||||
}
|
||||
|
||||
if len(audio) == 0 {
|
||||
@@ -211,7 +313,8 @@ func scoreQuality(
|
||||
q.Overall = weightFormat*q.FormatRank +
|
||||
weightBitrate*q.Bitrate +
|
||||
weightHealth*q.Health +
|
||||
weightPriority*q.Priority
|
||||
weightPriority*q.Priority +
|
||||
weightSizeFit*q.SizeFit
|
||||
|
||||
if q.Mixed {
|
||||
q.Overall *= 0.9
|
||||
@@ -316,9 +419,10 @@ func lossyBitrateScore(kbps int) float64 {
|
||||
// on match, then on provider priority, then on file count, so the order
|
||||
// is stable across runs rather than map-iteration dependent.
|
||||
func Rank(
|
||||
req Request,
|
||||
dl Download,
|
||||
candidates []Candidate,
|
||||
priority func(providerID int64) int,
|
||||
prefs AutoDownloadPrefs,
|
||||
) []Candidate {
|
||||
out := make([]Candidate, 0, len(candidates))
|
||||
|
||||
@@ -328,7 +432,7 @@ func Rank(
|
||||
p = priority(c.ProviderID)
|
||||
}
|
||||
|
||||
out = append(out, Score(req, c, p))
|
||||
out = append(out, Score(dl, c, p, prefs))
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
@@ -354,31 +458,41 @@ func Rank(
|
||||
// to grab without asking. It demands an anchored request, a high match,
|
||||
// decent quality, and daylight between first and second place — if two
|
||||
// candidates are close, the choice is the user's.
|
||||
func AutoPickable(req Request, ranked []Candidate) bool {
|
||||
func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool {
|
||||
const (
|
||||
minMatch = 0.85
|
||||
minQuality = 0.5
|
||||
minLead = 0.08
|
||||
)
|
||||
|
||||
if !req.Anchored() || len(ranked) == 0 {
|
||||
if !dl.Anchored() || len(ranked) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// An anchor with no tracklist behind it is an anchor in name only:
|
||||
// the match score then rests on album and artist text alone, which
|
||||
// is exactly the evidence a wrong-album candidate also has. This
|
||||
// matters most for the wanted list, where nobody is watching.
|
||||
if len(req.Expected) == 0 {
|
||||
// matters most for the request list, where nobody is watching.
|
||||
if len(dl.Expected) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
best := ranked[0]
|
||||
// The guardrails apply before the match/quality/lead checks: a
|
||||
// candidate outside the allowed size or format is not a worse
|
||||
// choice, it is not a choice auto-pick may make at all, so it must
|
||||
// not count as "the winner" nor as "second place" for the lead
|
||||
// check below.
|
||||
eligible := prefs.filter(ranked)
|
||||
if len(eligible) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
best := eligible[0]
|
||||
if best.Match.Overall < minMatch || best.Quality.Overall < minQuality {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ranked) > 1 && best.Score-ranked[1].Score < minLead {
|
||||
if len(eligible) > 1 && best.Score-eligible[1].Score < minLead {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+160
-22
@@ -3,8 +3,8 @@ package download
|
||||
import "testing"
|
||||
|
||||
// okComputer is the reference request used across ranking tests.
|
||||
func okComputer() Request {
|
||||
return Request{
|
||||
func okComputer() Download {
|
||||
return Download{
|
||||
ReleaseMBID: "mbid-ok-computer",
|
||||
Artist: "Radiohead",
|
||||
Album: "OK Computer",
|
||||
@@ -53,7 +53,7 @@ func allTitles() []string {
|
||||
func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := okComputer()
|
||||
dl := okComputer()
|
||||
|
||||
flac := candidateFor("flac", allTitles(), ".flac", 30_000_000)
|
||||
mp3 := candidateFor("mp3", allTitles(), ".mp3", 3_000_000)
|
||||
@@ -62,7 +62,7 @@ func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
|
||||
mp3.Files[i].Bitrate = 128
|
||||
}
|
||||
|
||||
ranked := Rank(req, []Candidate{mp3, flac}, nil)
|
||||
ranked := Rank(dl, []Candidate{mp3, flac}, nil, AutoDownloadPrefs{})
|
||||
|
||||
if ranked[0].ID != "flac" {
|
||||
t.Fatalf("winner = %s, want flac", ranked[0].ID)
|
||||
@@ -83,7 +83,7 @@ func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
|
||||
func TestRankMatchDominatesQuality(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := okComputer()
|
||||
dl := okComputer()
|
||||
|
||||
// Right album, poor bitrate.
|
||||
right := candidateFor("right", allTitles(), ".mp3", 2_000_000)
|
||||
@@ -103,7 +103,7 @@ func TestRankMatchDominatesQuality(t *testing.T) {
|
||||
trackToken(i+1) + " - x.flac"
|
||||
}
|
||||
|
||||
ranked := Rank(req, []Candidate{wrong, right}, nil)
|
||||
ranked := Rank(dl, []Candidate{wrong, right}, nil, AutoDownloadPrefs{})
|
||||
|
||||
if ranked[0].ID != "right" {
|
||||
t.Fatalf(
|
||||
@@ -116,12 +116,12 @@ func TestRankMatchDominatesQuality(t *testing.T) {
|
||||
func TestIncompleteCandidateScoresLower(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := okComputer()
|
||||
dl := okComputer()
|
||||
|
||||
full := candidateFor("full", allTitles(), ".flac", 30_000_000)
|
||||
partial := candidateFor("partial", allTitles()[:2], ".flac", 30_000_000)
|
||||
|
||||
ranked := Rank(req, []Candidate{partial, full}, nil)
|
||||
ranked := Rank(dl, []Candidate{partial, full}, nil, AutoDownloadPrefs{})
|
||||
|
||||
if ranked[0].ID != "full" {
|
||||
t.Fatalf("winner = %s, want full", ranked[0].ID)
|
||||
@@ -138,7 +138,7 @@ func TestIncompleteCandidateScoresLower(t *testing.T) {
|
||||
func TestMixedFormatIsPenalized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := okComputer()
|
||||
dl := okComputer()
|
||||
|
||||
clean := candidateFor("clean", allTitles(), ".flac", 30_000_000)
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestMixedFormatIsPenalized(t *testing.T) {
|
||||
mixed.Files[2].Path = "Radiohead - OK Computer/03 - x.mp3"
|
||||
mixed.Files[2].Format = FormatUnknown
|
||||
|
||||
ranked := Rank(req, []Candidate{mixed, clean}, nil)
|
||||
ranked := Rank(dl, []Candidate{mixed, clean}, nil, AutoDownloadPrefs{})
|
||||
|
||||
var mixedScore QualityScore
|
||||
|
||||
@@ -170,10 +170,10 @@ func TestMixedFormatIsPenalized(t *testing.T) {
|
||||
func TestUnanchoredMatchIsCapped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := Request{Artist: "Radiohead", Album: "OK Computer"}
|
||||
dl := Download{Artist: "Radiohead", Album: "OK Computer"}
|
||||
c := candidateFor("c", allTitles(), ".flac", 30_000_000)
|
||||
|
||||
scored := Score(req, c, 50)
|
||||
scored := Score(dl, c, 50, AutoDownloadPrefs{})
|
||||
|
||||
if scored.Match.Anchored {
|
||||
t.Error("free-text request reported as anchored")
|
||||
@@ -190,19 +190,20 @@ func TestUnanchoredMatchIsCapped(t *testing.T) {
|
||||
func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := okComputer()
|
||||
best := Score(req, candidateFor("a", allTitles(), ".flac", 30_000_000), 50)
|
||||
dl := okComputer()
|
||||
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
|
||||
|
||||
t.Run("clear winner is auto-pickable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
weak := Score(
|
||||
req,
|
||||
dl,
|
||||
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
|
||||
50,
|
||||
AutoDownloadPrefs{},
|
||||
)
|
||||
|
||||
if !AutoPickable(req, []Candidate{best, weak}) {
|
||||
if !AutoPickable(dl, []Candidate{best, weak}, AutoDownloadPrefs{}) {
|
||||
t.Errorf(
|
||||
"want auto-pickable: match %f quality %f lead %f",
|
||||
best.Match.Overall, best.Quality.Overall, best.Score-weak.Score,
|
||||
@@ -216,7 +217,7 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
|
||||
twin := best
|
||||
twin.ID = "twin"
|
||||
|
||||
if AutoPickable(req, []Candidate{best, twin}) {
|
||||
if AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) {
|
||||
t.Error("identical candidates must not auto-pick")
|
||||
}
|
||||
})
|
||||
@@ -224,9 +225,9 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
|
||||
t.Run("free text is never auto-pickable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
free := Request{Artist: "Radiohead", Album: "OK Computer"}
|
||||
free := Download{Artist: "Radiohead", Album: "OK Computer"}
|
||||
|
||||
if AutoPickable(free, []Candidate{best}) {
|
||||
if AutoPickable(free, []Candidate{best}, AutoDownloadPrefs{}) {
|
||||
t.Error("unanchored request must not auto-pick")
|
||||
}
|
||||
})
|
||||
@@ -234,7 +235,7 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
|
||||
t.Run("empty list is not", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if AutoPickable(req, nil) {
|
||||
if AutoPickable(dl, nil, AutoDownloadPrefs{}) {
|
||||
t.Error("empty candidate list must not auto-pick")
|
||||
}
|
||||
})
|
||||
@@ -276,7 +277,7 @@ func TestCompleteness(t *testing.T) {
|
||||
func TestProviderPriorityBreaksTies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := okComputer()
|
||||
dl := okComputer()
|
||||
|
||||
a := candidateFor("a", allTitles(), ".flac", 30_000_000)
|
||||
a.ProviderID = 1
|
||||
@@ -292,9 +293,146 @@ func TestProviderPriorityBreaksTies(t *testing.T) {
|
||||
return 10
|
||||
}
|
||||
|
||||
ranked := Rank(req, []Candidate{a, b}, priority)
|
||||
ranked := Rank(dl, []Candidate{a, b}, priority, AutoDownloadPrefs{})
|
||||
|
||||
if ranked[0].ID != "b" {
|
||||
t.Errorf("winner = %s, want b (higher provider priority)", ranked[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
const mb = 1 << 20
|
||||
|
||||
func TestAutoDownloadPrefsEligible(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
flacCandidate := candidateFor("c", allTitles(), ".flac", 30_000_000)
|
||||
flacCandidate.Files = AnnotateFiles(flacCandidate.Files)
|
||||
flacCandidate.TotalSize = 300 * mb
|
||||
|
||||
mp3Candidate := candidateFor("c", allTitles(), ".mp3", 3_000_000)
|
||||
mp3Candidate.Files = AnnotateFiles(mp3Candidate.Files)
|
||||
mp3Candidate.TotalSize = 30 * mb
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prefs AutoDownloadPrefs
|
||||
c Candidate
|
||||
want bool
|
||||
}{
|
||||
{"zero value is permissive", AutoDownloadPrefs{}, flacCandidate, true},
|
||||
{
|
||||
"within min/max window",
|
||||
AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 500},
|
||||
flacCandidate, true,
|
||||
},
|
||||
{
|
||||
"below minimum",
|
||||
AutoDownloadPrefs{MinSizeMB: 400},
|
||||
flacCandidate, false,
|
||||
},
|
||||
{
|
||||
"above maximum",
|
||||
AutoDownloadPrefs{MaxSizeMB: 200},
|
||||
flacCandidate, false,
|
||||
},
|
||||
{
|
||||
"allowed format passes",
|
||||
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
|
||||
flacCandidate, true,
|
||||
},
|
||||
{
|
||||
"disallowed format rejected",
|
||||
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
|
||||
mp3Candidate, false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.prefs.eligible(tt.c); got != tt.want {
|
||||
t.Errorf("eligible() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoDownloadPrefsFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
small := candidateFor("small", allTitles(), ".flac", 10_000_000)
|
||||
small.TotalSize = 50 * mb
|
||||
|
||||
big := candidateFor("big", allTitles(), ".flac", 30_000_000)
|
||||
big.TotalSize = 500 * mb
|
||||
|
||||
prefs := AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 600}
|
||||
|
||||
filtered := prefs.filter([]Candidate{small, big})
|
||||
|
||||
if len(filtered) != 1 || filtered[0].ID != "big" {
|
||||
t.Errorf("filter() = %v, want only the in-window candidate", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoDownloadPrefsSizeFit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const neutral = 0.5
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prefs AutoDownloadPrefs
|
||||
totalSize int64
|
||||
want float64
|
||||
}{
|
||||
{"no preference is neutral", AutoDownloadPrefs{}, 300 * mb, neutral},
|
||||
{
|
||||
"exact match scores 1",
|
||||
AutoDownloadPrefs{PreferredSizeMB: 300},
|
||||
300 * mb, 1.0,
|
||||
},
|
||||
{
|
||||
"double the preferred size scores 0",
|
||||
AutoDownloadPrefs{PreferredSizeMB: 300},
|
||||
600 * mb, 0.0,
|
||||
},
|
||||
{
|
||||
"half the preferred size scores 0",
|
||||
AutoDownloadPrefs{PreferredSizeMB: 300},
|
||||
150 * mb, 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := tt.prefs.sizeFit(tt.totalSize); got != tt.want {
|
||||
t.Errorf("sizeFit(%d) = %f, want %f", tt.totalSize, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An otherwise-perfect candidate must not auto-pick when it falls
|
||||
// outside the configured size guard: the guardrail applies before the
|
||||
// match/quality/lead checks, not as one more input averaged into them.
|
||||
func TestAutoPickableRejectsCandidateOutsideSizeGuard(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dl := okComputer()
|
||||
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
|
||||
best.TotalSize = 500 * mb
|
||||
|
||||
if !AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{}) {
|
||||
t.Fatal("expected this candidate to be auto-pickable with no guardrails")
|
||||
}
|
||||
|
||||
tight := AutoDownloadPrefs{MinSizeMB: 10, MaxSizeMB: 100}
|
||||
|
||||
if AutoPickable(dl, []Candidate{best}, tight) {
|
||||
t.Error("candidate outside the size guard must not auto-pick")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// The reconciler is the only thing that turns wants into downloads.
|
||||
// The reconciler is the only thing that turns requests into downloads.
|
||||
//
|
||||
// It runs on a slow loop rather than reacting to events, because
|
||||
// everything it cares about changes slowly: a release the user wants
|
||||
// everything it cares about changes slowly: a release the user requests
|
||||
// appears on a source days or weeks after they asked, an artist puts
|
||||
// out an album once a year, and a library gains files by scan rather
|
||||
// than by notification. A loop that wakes a few times a day is
|
||||
@@ -24,17 +24,17 @@ import (
|
||||
//
|
||||
// Each pass does four things, in this order and for this reason:
|
||||
//
|
||||
// 1. Expand artist subscriptions into per-album wants, so step 2 sees
|
||||
// 1. Expand artist subscriptions into per-album requests, so step 2 sees
|
||||
// them this pass rather than next.
|
||||
// 2. Retire wants the library already owns — including ones the user
|
||||
// 2. Retire requests the library already owns — including ones the user
|
||||
// satisfied by other means, which is why ownership is checked
|
||||
// rather than assumed from our own downloads.
|
||||
// 3. Push the list to clients that keep their own (Lidarr), so the
|
||||
// user's intent is expressed in both places.
|
||||
// 4. Attempt a bounded batch of due wants.
|
||||
// 4. Attempt a bounded batch of due requests.
|
||||
//
|
||||
// Nothing here fails a want. A want that cannot be found gets an
|
||||
// attempt recorded and a longer backoff, and stays exactly as wanted as
|
||||
// Nothing here fails a request. A request that cannot be found gets an
|
||||
// attempt recorded and a longer backoff, and stays exactly as requested as
|
||||
// it was.
|
||||
|
||||
// CatalogPort is what the reconciler needs to know about the world of
|
||||
@@ -51,8 +51,8 @@ type CatalogPort interface {
|
||||
) ([]CatalogItem, error)
|
||||
|
||||
// Tracklist resolves a release group or release to the tracks it
|
||||
// should contain. This is what makes a want's download safe to
|
||||
// complete unattended, so a want with no tracklist is never
|
||||
// should contain. This is what makes a request's download safe to
|
||||
// complete unattended, so a request with no tracklist is never
|
||||
// auto-grabbed.
|
||||
Tracklist(
|
||||
ctx context.Context,
|
||||
@@ -64,8 +64,8 @@ type CatalogPort interface {
|
||||
// names.
|
||||
Owns(ctx context.Context, entity Entity, mbid string) (bool, error)
|
||||
|
||||
// Describe fills in display text for a want the user added by MBID
|
||||
// alone. Best-effort: an unknown MBID returns false and the want
|
||||
// Describe fills in display text for a request the user added by MBID
|
||||
// alone. Best-effort: an unknown MBID returns false and the request
|
||||
// is still perfectly valid.
|
||||
Describe(
|
||||
ctx context.Context,
|
||||
@@ -87,7 +87,7 @@ type CatalogItem struct {
|
||||
PrimaryType string
|
||||
|
||||
// SecondaryTypes carries "Compilation", "Live", "Remix" and
|
||||
// friends. Their presence is what an artist want's default scope
|
||||
// friends. Their presence is what an artist request's default scope
|
||||
// filters out.
|
||||
SecondaryTypes []string
|
||||
|
||||
@@ -100,25 +100,25 @@ type CatalogItem struct {
|
||||
|
||||
// Reconciler defaults.
|
||||
const (
|
||||
// defaultReconcileInterval is how often the wanted list is worked.
|
||||
// defaultReconcileInterval is how often the request list is worked.
|
||||
// Four times a day is far more often than new music appears and far
|
||||
// less often than any provider would object to.
|
||||
defaultReconcileInterval = 6 * time.Hour
|
||||
|
||||
// startupDelay lets the app finish starting — library scan, explore
|
||||
// index, provider construction — before the first pass. A wanted
|
||||
// index, provider construction — before the first pass. A requested
|
||||
// list worked against an index that has not loaded yet would record
|
||||
// a pile of pointless attempts.
|
||||
startupDelay = 3 * time.Minute
|
||||
|
||||
// maxExpandPerArtist bounds how many child wants one artist
|
||||
// maxExpandPerArtist bounds how many child requests one artist
|
||||
// subscription creates in a single pass, so switching an artist to
|
||||
// full-discography scope does not enqueue four hundred albums at
|
||||
// once. The remainder is picked up next pass.
|
||||
maxExpandPerArtist = 40
|
||||
)
|
||||
|
||||
// Reconciler works the wanted list.
|
||||
// Reconciler works the request list.
|
||||
type Reconciler struct {
|
||||
logger *slog.Logger
|
||||
store *Store
|
||||
@@ -143,12 +143,12 @@ type Reconciler struct {
|
||||
stop chan struct{}
|
||||
|
||||
// runMu serializes passes: two reconcilers racing would search for
|
||||
// the same want twice.
|
||||
// the same request twice.
|
||||
runMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewReconciler builds a reconciler. catalog may be nil, in which case
|
||||
// the wanted list still stores and lists wants but never acts on them —
|
||||
// the request list still stores and lists requests but never acts on them —
|
||||
// which is the right behaviour when the explore index is unavailable.
|
||||
func NewReconciler(
|
||||
logger *slog.Logger,
|
||||
@@ -176,7 +176,7 @@ func (r *Reconciler) SetInterval(d time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetBatch overrides how many wants one pass attempts.
|
||||
// SetBatch overrides how many requests one pass attempts.
|
||||
func (r *Reconciler) SetBatch(n int) {
|
||||
if n > 0 {
|
||||
r.batch = n
|
||||
@@ -200,7 +200,7 @@ func (r *Reconciler) Stop() {
|
||||
}
|
||||
|
||||
// Trigger asks for a pass as soon as possible without blocking the
|
||||
// caller. Used when the user adds a want and expects something to
|
||||
// caller. Used when the user adds a request and expects something to
|
||||
// happen.
|
||||
func (r *Reconciler) Trigger() {
|
||||
select {
|
||||
@@ -229,27 +229,27 @@ func (r *Reconciler) loop(ctx context.Context) {
|
||||
}
|
||||
|
||||
if _, err := r.RunOnce(ctx); err != nil {
|
||||
r.logger.Warn("wanted list reconcile failed", "error", err)
|
||||
r.logger.Warn("request list reconcile failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary reports what a pass did, for logging and for the UI.
|
||||
type Summary struct {
|
||||
// Expanded is how many child wants artist subscriptions produced.
|
||||
// Expanded is how many child requests artist subscriptions produced.
|
||||
Expanded int `json:"expanded"`
|
||||
|
||||
// Satisfied is how many wants the library turned out to own.
|
||||
// Satisfied is how many requests the library turned out to own.
|
||||
Satisfied int `json:"satisfied"`
|
||||
|
||||
// Attempted is how many wants were searched for.
|
||||
// Attempted is how many requests were searched for.
|
||||
Attempted int `json:"attempted"`
|
||||
|
||||
// Started is how many of those found a clear enough winner to
|
||||
// download unattended.
|
||||
Started int `json:"started"`
|
||||
|
||||
// Synced is how many wants were pushed to an external list.
|
||||
// Synced is how many requests were pushed to an external list.
|
||||
Synced int `json:"synced"`
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ func (s Summary) changed() bool {
|
||||
return s.Expanded > 0 || s.Satisfied > 0 || s.Started > 0
|
||||
}
|
||||
|
||||
// RunOnce works the wanted list once. It is safe to call directly, and
|
||||
// RunOnce works the request list once. It is safe to call directly, and
|
||||
// the "search now" button does.
|
||||
func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
|
||||
r.runMu.Lock()
|
||||
@@ -296,7 +296,7 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
|
||||
summary.Started = started
|
||||
|
||||
r.logger.Info(
|
||||
"reconciled wanted list",
|
||||
"reconciled request list",
|
||||
"expanded", summary.Expanded,
|
||||
"satisfied", summary.Satisfied,
|
||||
"attempted", summary.Attempted,
|
||||
@@ -315,15 +315,15 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
|
||||
// Artist expansion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// expandArtists turns artist subscriptions into per-album wants.
|
||||
// expandArtists turns artist subscriptions into per-album requests.
|
||||
//
|
||||
// The expansion is idempotent: child wants are upserted on (mbid,
|
||||
// The expansion is idempotent: child requests are upserted on (mbid,
|
||||
// library), so re-running adds only what is genuinely new. That is
|
||||
// what makes an artist want a standing subscription rather than a
|
||||
// what makes an artist request a standing subscription rather than a
|
||||
// one-time queue-filling operation — an album released next year gets
|
||||
// picked up by the same code path that ran today.
|
||||
func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
|
||||
artists, err := r.store.ListArtistWants(ctx)
|
||||
artists, err := r.store.ListActiveRequests(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
|
||||
// One artist whose discography will not resolve must not
|
||||
// stop the rest of the list.
|
||||
r.logger.Warn(
|
||||
"could not expand artist want",
|
||||
"could not expand artist request",
|
||||
"artist", artist.Label(),
|
||||
"mbid", artist.MBID,
|
||||
"error", err,
|
||||
@@ -352,7 +352,7 @@ func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
|
||||
}
|
||||
|
||||
// expandArtist expands one subscription.
|
||||
func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error) {
|
||||
func (r *Reconciler) expandArtist(ctx context.Context, artist Request) (int, error) {
|
||||
groups, err := r.catalog.ReleaseGroupsForArtist(ctx, artist.MBID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -365,16 +365,16 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
|
||||
break
|
||||
}
|
||||
|
||||
if !wantsReleaseGroup(artist, rg) {
|
||||
if !requestsReleaseGroup(artist, rg) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Existence is checked before inserting rather than relying on
|
||||
// the upsert, because "how many albums are new this pass" is
|
||||
// the number the UI reports and an upsert cannot tell an insert
|
||||
// from a no-op. It also means a want the user pinned by hand
|
||||
// from a no-op. It also means a request the user pinned by hand
|
||||
// is never quietly reparented under the artist.
|
||||
if _, exists, err := r.store.FindWant(
|
||||
if _, exists, err := r.store.FindRequest(
|
||||
ctx, rg.MBID, artist.LibraryID,
|
||||
); err != nil || exists {
|
||||
continue
|
||||
@@ -385,7 +385,7 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
|
||||
credit = artist.Artist
|
||||
}
|
||||
|
||||
if _, err := r.store.AddWant(ctx, Want{
|
||||
if _, err := r.store.AddRequest(ctx, Request{
|
||||
MBID: rg.MBID,
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: artist.LibraryID,
|
||||
@@ -394,7 +394,7 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
|
||||
ParentID: artist.ID,
|
||||
}); err != nil {
|
||||
r.logger.Warn(
|
||||
"could not add derived want",
|
||||
"could not add derived request",
|
||||
"release_group", rg.MBID,
|
||||
"error", err,
|
||||
)
|
||||
@@ -408,9 +408,9 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// wantsReleaseGroup applies an artist subscription's filters to one
|
||||
// requestsReleaseGroup applies an artist subscription's filters to one
|
||||
// release group.
|
||||
func wantsReleaseGroup(artist Want, rg CatalogItem) bool {
|
||||
func requestsReleaseGroup(artist Request, rg CatalogItem) bool {
|
||||
if rg.MBID == "" || rg.InLibrary {
|
||||
return false
|
||||
}
|
||||
@@ -457,30 +457,30 @@ func releaseDateAfter(date string, since time.Time) bool {
|
||||
// Retiring what the library already has
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// retireOwned satisfies wants the library turns out to own.
|
||||
// retireOwned satisfies requests the library turns out to own.
|
||||
//
|
||||
// Ownership is asked of the library rather than inferred from our own
|
||||
// completed downloads on purpose: the user may have bought the album,
|
||||
// ripped their CD, or copied it in from another machine, and a wanted
|
||||
// ripped their CD, or copied it in from another machine, and a requested
|
||||
// list that keeps hunting for music already sitting on disk is worse
|
||||
// than no wanted list at all.
|
||||
// than no request list at all.
|
||||
func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
|
||||
wants, err := r.store.ListWants(ctx)
|
||||
requests, err := r.store.ListRequests(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
satisfied := 0
|
||||
|
||||
for _, w := range wants {
|
||||
if w.State != WantStateWanted || w.Entity.Expands() {
|
||||
for _, req := range requests {
|
||||
if req.State != RequestStateWanted || req.Entity.Expands() {
|
||||
continue
|
||||
}
|
||||
|
||||
owned, err := r.catalog.Owns(ctx, w.Entity, w.MBID)
|
||||
owned, err := r.catalog.Owns(ctx, req.Entity, req.MBID)
|
||||
if err != nil {
|
||||
r.logger.Debug(
|
||||
"ownership check failed", "want", w.MBID, "error", err,
|
||||
"ownership check failed", "request", req.MBID, "error", err,
|
||||
)
|
||||
|
||||
continue
|
||||
@@ -490,8 +490,8 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.store.SatisfyWant(ctx, w.ID); err != nil {
|
||||
r.logger.Warn("could not satisfy want", "want", w.ID, "error", err)
|
||||
if err := r.store.SatisfyRequest(ctx, req.ID); err != nil {
|
||||
r.logger.Warn("could not satisfy request", "request", req.ID, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
@@ -506,15 +506,15 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
|
||||
// Attempting downloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// attemptDue searches for a bounded batch of due wants and grabs the
|
||||
// attemptDue searches for a bounded batch of due requests and grabs the
|
||||
// ones with a clear winner.
|
||||
func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, err error) {
|
||||
due, err := r.store.ListDueWants(ctx, r.batch)
|
||||
due, err := r.store.ListDueRequests(ctx, r.batch)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, w := range due {
|
||||
for _, req := range due {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return attempted, started, nil
|
||||
@@ -523,7 +523,7 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
|
||||
|
||||
attempted++
|
||||
|
||||
ok, reason := r.attempt(ctx, w)
|
||||
ok, reason := r.attempt(ctx, req)
|
||||
if ok {
|
||||
started++
|
||||
|
||||
@@ -531,10 +531,10 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
|
||||
}
|
||||
|
||||
if err := r.store.RecordAttempt(
|
||||
ctx, w.ID, w.Attempts, reason,
|
||||
ctx, req.ID, req.Attempts, reason,
|
||||
); err != nil {
|
||||
r.logger.Warn(
|
||||
"could not record want attempt", "want", w.ID, "error", err,
|
||||
"could not record request attempt", "request", req.ID, "error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -542,64 +542,64 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
|
||||
return attempted, started, nil
|
||||
}
|
||||
|
||||
// tracklistFor resolves what a want should contain, which is the
|
||||
// tracklistFor resolves what a request should contain, which is the
|
||||
// evidence an unattended download is checked against.
|
||||
//
|
||||
// A track want is its own tracklist: one entry, built from the title
|
||||
// the want already carries. That single expected title is what lets
|
||||
// A track request is its own tracklist: one entry, built from the title
|
||||
// the request already carries. That single expected title is what lets
|
||||
// filename matching score a track download at all — without it a
|
||||
// request for one song would be scored as an album with no tracks and
|
||||
// could never clear the auto-pick bar.
|
||||
func (r *Reconciler) tracklistFor(
|
||||
ctx context.Context,
|
||||
w Want,
|
||||
req Request,
|
||||
) ([]ExpectedTrack, error) {
|
||||
if w.Entity != EntityRecording {
|
||||
return r.catalog.Tracklist(ctx, w.Entity, w.MBID)
|
||||
if req.Entity != EntityRecording {
|
||||
return r.catalog.Tracklist(ctx, req.Entity, req.MBID)
|
||||
}
|
||||
|
||||
if w.Title == "" {
|
||||
if req.Title == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []ExpectedTrack{{
|
||||
Position: 1,
|
||||
Title: w.Title,
|
||||
Artist: w.Artist,
|
||||
Title: req.Title,
|
||||
Artist: req.Artist,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// attempt tries one want. It returns false with a human-readable
|
||||
// attempt tries one request. It returns false with a human-readable
|
||||
// reason rather than an error, because none of the ways this does not
|
||||
// work out are failures: no providers configured yet, nothing on any
|
||||
// source, or nothing good enough to take without asking are all just
|
||||
// "not today".
|
||||
func (r *Reconciler) attempt(ctx context.Context, w Want) (bool, string) {
|
||||
expected, err := r.tracklistFor(ctx, w)
|
||||
func (r *Reconciler) attempt(ctx context.Context, req Request) (bool, string) {
|
||||
expected, err := r.tracklistFor(ctx, req)
|
||||
if err != nil || len(expected) == 0 {
|
||||
// Without a tracklist an unattended grab has nothing to verify
|
||||
// itself against, so this want waits rather than guessing. The
|
||||
// itself against, so this request waits rather than guessing. The
|
||||
// tracklist usually arrives on its own once the explore index
|
||||
// fetches the release.
|
||||
return false, "waiting for the tracklist to resolve"
|
||||
}
|
||||
|
||||
req := w.ToRequest(newID())
|
||||
req.Expected = expected
|
||||
dl := req.ToDownload(newID())
|
||||
dl.Expected = expected
|
||||
|
||||
if req.Artist == "" || req.Album == "" {
|
||||
if item, ok := r.catalog.Describe(ctx, w.Entity, w.MBID); ok {
|
||||
if req.Artist == "" {
|
||||
req.Artist = item.Artist
|
||||
if dl.Artist == "" || dl.Album == "" {
|
||||
if item, ok := r.catalog.Describe(ctx, req.Entity, req.MBID); ok {
|
||||
if dl.Artist == "" {
|
||||
dl.Artist = item.Artist
|
||||
}
|
||||
|
||||
if req.Album == "" {
|
||||
req.Album = item.Title
|
||||
if dl.Album == "" {
|
||||
dl.Album = item.Title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
started, reason, err := r.manager.Attempt(ctx, req)
|
||||
started, reason, err := r.manager.Attempt(ctx, dl)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoProviders) {
|
||||
return false, "no download clients are enabled"
|
||||
@@ -619,7 +619,7 @@ func (r *Reconciler) attempt(ctx context.Context, w Want) (bool, string) {
|
||||
// External list sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// syncExternalLists pushes wants to providers that keep a persistent
|
||||
// syncExternalLists pushes requests to providers that keep a persistent
|
||||
// list of their own.
|
||||
//
|
||||
// The sync is one-directional by design. Two systems that both accept
|
||||
@@ -634,21 +634,21 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
wants, err := r.store.ListWants(ctx)
|
||||
requests, err := r.store.ListRequests(ctx)
|
||||
if err != nil {
|
||||
r.logger.Warn("could not list wants for sync", "error", err)
|
||||
r.logger.Warn("could not list requests for sync", "error", err)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
synced := 0
|
||||
|
||||
for _, w := range wants {
|
||||
if w.State != WantStateWanted {
|
||||
for _, req := range requests {
|
||||
if req.State != RequestStateWanted {
|
||||
continue
|
||||
}
|
||||
|
||||
external := w.ExternalIDs
|
||||
external := req.ExternalIDs
|
||||
if external == nil {
|
||||
external = map[string]string{}
|
||||
}
|
||||
@@ -661,11 +661,11 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
|
||||
continue
|
||||
}
|
||||
|
||||
externalID, err := l.PushWant(ctx, w)
|
||||
externalID, err := l.PushRequest(ctx, req)
|
||||
if err != nil {
|
||||
r.logger.Debug(
|
||||
"could not push want to external list",
|
||||
"want", w.MBID,
|
||||
"could not push request to external list",
|
||||
"request", req.MBID,
|
||||
"provider", id,
|
||||
"error", err,
|
||||
)
|
||||
@@ -686,9 +686,9 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.store.SetWantExternalIDs(ctx, w.ID, external); err != nil {
|
||||
if err := r.store.SetRequestExternalIDs(ctx, req.ID, external); err != nil {
|
||||
r.logger.Warn(
|
||||
"could not record external want ids", "want", w.ID, "error", err,
|
||||
"could not record external request ids", "request", req.ID, "error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -696,7 +696,7 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
|
||||
return synced
|
||||
}
|
||||
|
||||
// ImportExternal pulls an external manager's own list into the wanted
|
||||
// ImportExternal pulls an external manager's own list into the requested
|
||||
// list. This is the one place data flows the other way, and it is a
|
||||
// deliberate user action ("import my monitored Lidarr artists") rather
|
||||
// than part of the loop, because silently adopting whatever another
|
||||
@@ -715,19 +715,19 @@ func (r *Reconciler) ImportExternal(
|
||||
)
|
||||
}
|
||||
|
||||
external, err := l.ListWants(ctx)
|
||||
external, err := l.ListRequests(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list external wants: %w", err)
|
||||
return 0, fmt.Errorf("list external requests: %w", err)
|
||||
}
|
||||
|
||||
imported := 0
|
||||
|
||||
for _, w := range external {
|
||||
w.LibraryID = libraryID
|
||||
for _, req := range external {
|
||||
req.LibraryID = libraryID
|
||||
|
||||
if _, err := r.store.AddWant(ctx, w); err != nil {
|
||||
if _, err := r.store.AddRequest(ctx, req); err != nil {
|
||||
r.logger.Warn(
|
||||
"could not import external want", "mbid", w.MBID, "error", err,
|
||||
"could not import external request", "mbid", req.MBID, "error", err,
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
@@ -111,14 +111,14 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
|
||||
{MBID: "rg-2", Title: "Second", FirstReleaseDate: "2030-06-01"},
|
||||
}
|
||||
|
||||
if _, err := f.store.AddWant(ctx, Want{
|
||||
if _, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "artist-1",
|
||||
Entity: EntityArtist,
|
||||
LibraryID: 1,
|
||||
Artist: "Radiohead",
|
||||
Scope: ScopeAll,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
first, err := f.reconciler.expandArtists(ctx)
|
||||
@@ -127,7 +127,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
|
||||
}
|
||||
|
||||
if first != 2 {
|
||||
t.Fatalf("first pass created %d wants, want 2", first)
|
||||
t.Fatalf("first pass created %d requests, want 2", first)
|
||||
}
|
||||
|
||||
second, err := f.reconciler.expandArtists(ctx)
|
||||
@@ -136,7 +136,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
|
||||
}
|
||||
|
||||
if second != 0 {
|
||||
t.Errorf("second pass created %d wants, want 0", second)
|
||||
t.Errorf("second pass created %d requests, want 0", second)
|
||||
}
|
||||
|
||||
// A new album appearing later is picked up by the same pass.
|
||||
@@ -153,7 +153,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
|
||||
}
|
||||
|
||||
if third != 1 {
|
||||
t.Errorf("third pass created %d wants, want 1", third)
|
||||
t.Errorf("third pass created %d requests, want 1", third)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,32 +170,32 @@ func TestExpandArtistFutureScopeSkipsBackCatalogue(t *testing.T) {
|
||||
{MBID: "rg-new", Title: "New", FirstReleaseDate: "2099-01-01"},
|
||||
}
|
||||
|
||||
if _, err := f.store.AddWant(ctx, Want{
|
||||
if _, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "artist-1",
|
||||
Entity: EntityArtist,
|
||||
LibraryID: 1,
|
||||
Scope: ScopeFuture,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
if _, err := f.reconciler.expandArtists(ctx); err != nil {
|
||||
t.Fatalf("expandArtists: %v", err)
|
||||
}
|
||||
|
||||
wants, err := f.store.ListWants(ctx)
|
||||
requests, err := f.store.ListRequests(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWants: %v", err)
|
||||
t.Fatalf("ListDownloads: %v", err)
|
||||
}
|
||||
|
||||
for _, w := range wants {
|
||||
if w.MBID == "rg-old" {
|
||||
for _, req := range requests {
|
||||
if req.MBID == "rg-old" {
|
||||
t.Error("future scope queued a back-catalogue album")
|
||||
}
|
||||
}
|
||||
|
||||
if len(wants) != 2 {
|
||||
t.Errorf("got %d wants (artist + new album), want 2", len(wants))
|
||||
if len(requests) != 2 {
|
||||
t.Errorf("got %d requests (artist + new album), want 2", len(requests))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,12 +209,12 @@ func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
|
||||
|
||||
f.catalog.discographyErr = errors.New("index not ready") //nolint:err113 // test
|
||||
|
||||
if _, err := f.store.AddWant(ctx, Want{
|
||||
if _, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "artist-1",
|
||||
Entity: EntityArtist,
|
||||
LibraryID: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
created, err := f.reconciler.expandArtists(ctx)
|
||||
@@ -223,24 +223,24 @@ func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
if created != 0 {
|
||||
t.Errorf("created %d wants from a failing catalog, want 0", created)
|
||||
t.Errorf("created %d requests from a failing catalog, want 0", created)
|
||||
}
|
||||
}
|
||||
|
||||
// Something the library already owns is retired, however it got there.
|
||||
func TestRetireOwnedSatisfiesWants(t *testing.T) {
|
||||
func TestRetireOwnedSatisfiesRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newReconcileFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := f.store.AddWant(ctx, Want{
|
||||
id, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "rg-1",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
f.catalog.owned["rg-1"] = true
|
||||
@@ -251,16 +251,16 @@ func TestRetireOwnedSatisfiesWants(t *testing.T) {
|
||||
}
|
||||
|
||||
if n != 1 {
|
||||
t.Fatalf("retired %d wants, want 1", n)
|
||||
t.Fatalf("retired %d requests, want 1", n)
|
||||
}
|
||||
|
||||
w, err := f.store.GetWant(ctx, id)
|
||||
req, err := f.store.GetRequest(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWant: %v", err)
|
||||
t.Fatalf("GetRequest: %v", err)
|
||||
}
|
||||
|
||||
if w.State != WantStateSatisfied {
|
||||
t.Errorf("state = %q, want satisfied", w.State)
|
||||
if req.State != RequestStateSatisfied {
|
||||
t.Errorf("state = %q, want satisfied", req.State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
|
||||
provider := fakeWithAlbum(1, "source", ".flac")
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
id, err := f.store.AddWant(ctx, Want{
|
||||
id, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "rg-1",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
@@ -283,10 +283,10 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
|
||||
Title: "OK Computer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
|
||||
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
|
||||
|
||||
summary, err := f.reconciler.RunOnce(ctx)
|
||||
if err != nil {
|
||||
@@ -301,9 +301,9 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
|
||||
}
|
||||
|
||||
waitFor(t, func() bool {
|
||||
w, err := f.store.GetWant(ctx, id)
|
||||
req, err := f.store.GetRequest(ctx, id)
|
||||
|
||||
return err == nil && w.State == WantStateSatisfied
|
||||
return err == nil && req.State == RequestStateSatisfied
|
||||
}, "want was never satisfied after its download completed")
|
||||
|
||||
if provider.GrabCalls != 1 {
|
||||
@@ -313,7 +313,7 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
|
||||
|
||||
// Nothing good enough is not a failure. The want stays wanted, gains
|
||||
// an attempt and a reason, and leaves no request row behind.
|
||||
func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
|
||||
func TestReconcileKeepsRequestingWhenNothingIsGoodEnough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newReconcileFixture(t)
|
||||
@@ -328,7 +328,7 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
|
||||
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
id, err := f.store.AddWant(ctx, Want{
|
||||
id, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "rg-1",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
@@ -336,10 +336,10 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
|
||||
Title: "OK Computer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
|
||||
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
|
||||
|
||||
summary, err := f.reconciler.RunOnce(ctx)
|
||||
if err != nil {
|
||||
@@ -350,32 +350,32 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
|
||||
t.Fatalf("started %d downloads, want 0", summary.Started)
|
||||
}
|
||||
|
||||
w, err := f.store.GetWant(ctx, id)
|
||||
req, err := f.store.GetRequest(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWant: %v", err)
|
||||
t.Fatalf("GetRequest: %v", err)
|
||||
}
|
||||
|
||||
if w.State != WantStateWanted {
|
||||
t.Errorf("state = %q, want it still wanted", w.State)
|
||||
if req.State != RequestStateWanted {
|
||||
t.Errorf("state = %q, want it still wanted", req.State)
|
||||
}
|
||||
|
||||
if w.Attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1", w.Attempts)
|
||||
if req.Attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1", req.Attempts)
|
||||
}
|
||||
|
||||
if w.LastError == "" {
|
||||
if req.LastError == "" {
|
||||
t.Error("no reason was recorded for the user")
|
||||
}
|
||||
|
||||
if !w.NextTryAt.After(time.Now()) {
|
||||
t.Errorf("next try at %v, want it in the future", w.NextTryAt)
|
||||
if !req.NextTryAt.After(time.Now()) {
|
||||
t.Errorf("next try at %v, want it in the future", req.NextTryAt)
|
||||
}
|
||||
|
||||
// The whole point of Attempt over Start: an unsuccessful pass
|
||||
// leaves no request row to clutter the downloads list.
|
||||
requests, err := f.store.ListRequests(ctx, 50)
|
||||
requests, err := f.store.ListDownloads(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRequests: %v", err)
|
||||
t.Fatalf("ListDownloads: %v", err)
|
||||
}
|
||||
|
||||
if len(requests) != 0 {
|
||||
@@ -393,14 +393,14 @@ func TestReconcileWaitsWithoutTracklist(t *testing.T) {
|
||||
provider := fakeWithAlbum(1, "source", ".flac")
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
if _, err := f.store.AddWant(ctx, Want{
|
||||
if _, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "rg-1",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
Artist: "Radiohead",
|
||||
Title: "OK Computer",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
summary, err := f.reconciler.RunOnce(ctx)
|
||||
@@ -421,27 +421,27 @@ func TestReconcileWaitsWithoutTracklist(t *testing.T) {
|
||||
}
|
||||
|
||||
// Artist subscriptions are never attempted as downloads: they expand.
|
||||
func TestArtistWantsAreNeverDue(t *testing.T) {
|
||||
func TestArtistRequestsAreNeverDue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newReconcileFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := f.store.AddWant(ctx, Want{
|
||||
if _, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "artist-1",
|
||||
Entity: EntityArtist,
|
||||
LibraryID: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
due, err := f.store.ListDueWants(ctx, 10)
|
||||
due, err := f.store.ListDueRequests(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDueWants: %v", err)
|
||||
t.Fatalf("ListDueRequests: %v", err)
|
||||
}
|
||||
|
||||
if len(due) != 0 {
|
||||
t.Errorf("got %d due wants, want 0 — artists expand, not download", len(due))
|
||||
t.Errorf("got %d due requests, want 0 — artists expand, not download", len(due))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,16 +456,16 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
|
||||
f.reconciler.SetBatch(2)
|
||||
|
||||
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
|
||||
if _, err := f.store.AddWant(ctx, Want{
|
||||
if _, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: mbid,
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
Title: "Album " + mbid,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
f.catalog.tracklists[mbid] = fourTrackRequest().Expected
|
||||
f.catalog.tracklists[mbid] = fourTrackDownload().Expected
|
||||
}
|
||||
|
||||
summary, err := f.reconciler.RunOnce(ctx)
|
||||
@@ -474,7 +474,7 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
|
||||
}
|
||||
|
||||
if summary.Attempted != 2 {
|
||||
t.Errorf("attempted %d wants, want 2 (the batch size)", summary.Attempted)
|
||||
t.Errorf("attempted %d requests, want 2 (the batch size)", summary.Attempted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A Request is a persistent "I want this", stored as a MusicBrainz ID
|
||||
// and almost nothing else.
|
||||
//
|
||||
// The distinction from Download is the whole point of this file. A
|
||||
// Download is one attempt: it searches, it grabs, it succeeds or fails,
|
||||
// and then it is history. A Request outlives every attempt made on its
|
||||
// behalf. Nothing being findable today is the normal case for obscure
|
||||
// music, and the correct response is to try again next week, not to
|
||||
// show the user a failed row they have to remember to retry.
|
||||
//
|
||||
// Because a Request is only an MBID, it stays true when everything
|
||||
// around it changes: the explore index is rebuilt, a provider is
|
||||
// swapped out, the release the user originally saw is superseded by a
|
||||
// remaster. The display fields are a cache for the list view and are
|
||||
// never consulted for matching.
|
||||
|
||||
// Entity says what a request's MBID names, and is the only type
|
||||
// distinction the durable request list makes.
|
||||
type Entity string
|
||||
|
||||
// Request entity types.
|
||||
const (
|
||||
// EntityArtist is a subscription rather than a thing to fetch: it
|
||||
// is never satisfied, and each reconcile expands the artist's
|
||||
// discography into child requests.
|
||||
EntityArtist Entity = "artist"
|
||||
|
||||
// EntityReleaseGroup is an album in the abstract — any release of
|
||||
// it satisfies the request, which is what a user means by "I want
|
||||
// this album".
|
||||
EntityReleaseGroup Entity = "release-group"
|
||||
|
||||
// EntityRelease is one specific edition, used when the user picked
|
||||
// a particular pressing.
|
||||
EntityRelease Entity = "release"
|
||||
|
||||
// EntityRecording is a single track.
|
||||
EntityRecording Entity = "recording"
|
||||
)
|
||||
|
||||
// Valid reports whether e is a known entity type.
|
||||
func (e Entity) Valid() bool {
|
||||
switch e {
|
||||
case EntityArtist, EntityReleaseGroup, EntityRelease, EntityRecording:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Expands reports whether this entity produces child requests rather
|
||||
// than being downloaded directly.
|
||||
func (e Entity) Expands() bool {
|
||||
return e == EntityArtist
|
||||
}
|
||||
|
||||
// RequestState is where a request sits. There is deliberately no
|
||||
// "failed": an attempt can fail, a request cannot. A request that has
|
||||
// tried and not found anything is still wanted, with attempts and
|
||||
// last_error recording why it is taking a while.
|
||||
type RequestState string
|
||||
|
||||
// Request states.
|
||||
const (
|
||||
// RequestStateWanted is the active state: due for another attempt
|
||||
// when its backoff elapses.
|
||||
RequestStateWanted RequestState = "wanted"
|
||||
|
||||
// RequestStateSatisfied means the library owns it. How it got
|
||||
// there — downloaded here, ripped, bought elsewhere — does not
|
||||
// matter.
|
||||
RequestStateSatisfied RequestState = "satisfied"
|
||||
|
||||
// RequestStatePaused is the user saying "keep this on the list but
|
||||
// stop trying".
|
||||
RequestStatePaused RequestState = "paused"
|
||||
)
|
||||
|
||||
// RequestScope applies to artist requests only.
|
||||
type RequestScope string
|
||||
|
||||
// Artist request scopes.
|
||||
const (
|
||||
// ScopeFuture takes only releases first published after the artist
|
||||
// was added. Default, because subscribing to an artist should not
|
||||
// silently queue their entire back catalogue.
|
||||
ScopeFuture RequestScope = "future"
|
||||
|
||||
// ScopeAll backfills the whole discography as well.
|
||||
ScopeAll RequestScope = "all"
|
||||
)
|
||||
|
||||
// Request is one row of the durable request list.
|
||||
type Request struct {
|
||||
ID int64 `json:"id"`
|
||||
MBID string `json:"mbid"`
|
||||
Entity Entity `json:"entity"`
|
||||
LibraryID int64 `json:"libraryId"`
|
||||
|
||||
// Artist and Title are display cache only. Matching always uses
|
||||
// the MBID.
|
||||
Artist string `json:"artist"`
|
||||
Title string `json:"title"`
|
||||
|
||||
Scope RequestScope `json:"scope"`
|
||||
|
||||
// Secondary includes compilations, live albums and remixes in an
|
||||
// artist request's expansion.
|
||||
Secondary bool `json:"secondary"`
|
||||
|
||||
State RequestState `json:"state"`
|
||||
|
||||
// ParentID is set on requests the reconciler derived from an artist
|
||||
// subscription. A request the user pinned directly has none, so
|
||||
// removing the artist leaves it alone.
|
||||
ParentID int64 `json:"parentId,omitempty"`
|
||||
|
||||
Attempts int `json:"attempts"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
LastTriedAt time.Time `json:"lastTriedAt,omitempty"`
|
||||
NextTryAt time.Time `json:"nextTryAt,omitempty"`
|
||||
|
||||
// ExternalIDs maps provider row ID (as a string, because JSON
|
||||
// object keys are strings) to that provider's own identifier for
|
||||
// this request. Only set for providers that keep a persistent
|
||||
// list of their own.
|
||||
ExternalIDs map[string]string `json:"externalIds,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Anchored is always true for a request: it is an MBID by construction.
|
||||
// The method exists so requests and downloads read the same at call
|
||||
// sites.
|
||||
func (r Request) Anchored() bool { return r.MBID != "" }
|
||||
|
||||
// Label is the request list's one-line description of a request.
|
||||
func (r Request) Label() string {
|
||||
switch {
|
||||
case r.Artist != "" && r.Title != "":
|
||||
return r.Artist + " — " + r.Title
|
||||
case r.Title != "":
|
||||
return r.Title
|
||||
case r.Artist != "":
|
||||
return r.Artist
|
||||
default:
|
||||
return string(r.Entity) + " " + r.MBID
|
||||
}
|
||||
}
|
||||
|
||||
// Retry backoff. A request that cannot be found is usually one that
|
||||
// will not be findable for a while — a pre-release, something only
|
||||
// ever on physical media, an artist no source indexes — so the
|
||||
// schedule climbs fast and then sits at a weekly poll rather than
|
||||
// hammering providers with the same fruitless search.
|
||||
const (
|
||||
// requestRetryBase is the delay after the first unsuccessful
|
||||
// attempt.
|
||||
requestRetryBase = 6 * time.Hour
|
||||
|
||||
// requestRetryMax caps the backoff. A weekly retry on a list of a
|
||||
// few hundred requests is a handful of searches a day, which every
|
||||
// provider tolerates.
|
||||
requestRetryMax = 7 * 24 * time.Hour
|
||||
|
||||
// requestRetryJitter spreads retries so a list added in one sitting
|
||||
// does not come due in one burst.
|
||||
requestRetryJitter = 0.2
|
||||
)
|
||||
|
||||
// nextRetry returns when a request with the given attempt count should
|
||||
// be tried again: exponential from requestRetryBase, capped at
|
||||
// requestRetryMax, jittered so a batch added together does not stay in
|
||||
// lockstep forever.
|
||||
func nextRetry(now time.Time, attempts int) time.Time {
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
|
||||
// Cap the exponent before shifting so a long-lived request cannot
|
||||
// overflow the duration into something negative.
|
||||
const maxExp = 16
|
||||
|
||||
exp := min(attempts-1, maxExp)
|
||||
|
||||
delay := float64(requestRetryBase) * math.Pow(2, float64(exp))
|
||||
if delay > float64(requestRetryMax) {
|
||||
delay = float64(requestRetryMax)
|
||||
}
|
||||
|
||||
jitter := delay * requestRetryJitter * (rand.Float64()*2 - 1) //nolint:gosec // spreading retries, not a secret
|
||||
|
||||
return now.Add(time.Duration(delay + jitter))
|
||||
}
|
||||
|
||||
// requestSource is the download source recorded for reconciler-raised
|
||||
// downloads, so the downloads list can tell them apart from the ones a
|
||||
// user started by hand.
|
||||
const requestSource = "wanted"
|
||||
|
||||
// ToDownload builds the download that would satisfy this request.
|
||||
// Expected is filled by the caller from the catalog, since resolving a
|
||||
// tracklist is I/O and this is not.
|
||||
func (r Request) ToDownload(id string) Download {
|
||||
d := Download{
|
||||
ID: id,
|
||||
LibraryID: r.LibraryID,
|
||||
Artist: r.Artist,
|
||||
Album: r.Title,
|
||||
RequestID: r.ID,
|
||||
Source: requestSource,
|
||||
}
|
||||
|
||||
switch r.Entity {
|
||||
case EntityRelease:
|
||||
d.ReleaseMBID = r.MBID
|
||||
case EntityReleaseGroup:
|
||||
d.ReleaseGroupMBID = r.MBID
|
||||
case EntityRecording:
|
||||
// A recording has no release anchor, so ranking has only the
|
||||
// title to go on and auto-pick stays off. The MBID is still
|
||||
// carried in RecordingMBID so a provider that can use it does.
|
||||
d.RecordingMBID = r.MBID
|
||||
case EntityArtist:
|
||||
// Artist requests expand into children and are never turned
|
||||
// into a download directly; this case exists so the switch is
|
||||
// exhaustive rather than because it can happen.
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
@@ -17,18 +17,18 @@ func TestNextRetryClimbsAndCaps(t *testing.T) {
|
||||
attempts int
|
||||
nominal time.Duration
|
||||
}{
|
||||
{attempts: 1, nominal: wantRetryBase},
|
||||
{attempts: 2, nominal: 2 * wantRetryBase},
|
||||
{attempts: 3, nominal: 4 * wantRetryBase},
|
||||
{attempts: 20, nominal: wantRetryMax},
|
||||
{attempts: 500, nominal: wantRetryMax},
|
||||
{attempts: 1, nominal: requestRetryBase},
|
||||
{attempts: 2, nominal: 2 * requestRetryBase},
|
||||
{attempts: 3, nominal: 4 * requestRetryBase},
|
||||
{attempts: 20, nominal: requestRetryMax},
|
||||
{attempts: 500, nominal: requestRetryMax},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := nextRetry(now, tt.attempts).Sub(now)
|
||||
|
||||
lo := time.Duration(float64(tt.nominal) * (1 - wantRetryJitter))
|
||||
hi := time.Duration(float64(tt.nominal) * (1 + wantRetryJitter))
|
||||
lo := time.Duration(float64(tt.nominal) * (1 - requestRetryJitter))
|
||||
hi := time.Duration(float64(tt.nominal) * (1 + requestRetryJitter))
|
||||
|
||||
if got < lo || got > hi {
|
||||
t.Errorf(
|
||||
@@ -83,14 +83,14 @@ func TestReleaseDateAfter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWantsReleaseGroupFilters(t *testing.T) {
|
||||
func TestRequestsReleaseGroupFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
subscribed := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
future := Want{Scope: ScopeFuture, CreatedAt: subscribed}
|
||||
all := Want{Scope: ScopeAll, CreatedAt: subscribed}
|
||||
allSecondary := Want{
|
||||
future := Request{Scope: ScopeFuture, CreatedAt: subscribed}
|
||||
all := Request{Scope: ScopeAll, CreatedAt: subscribed}
|
||||
allSecondary := Request{
|
||||
Scope: ScopeAll, Secondary: true, CreatedAt: subscribed,
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func TestWantsReleaseGroupFilters(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
artist Want
|
||||
artist Request
|
||||
rg CatalogItem
|
||||
want bool
|
||||
}{
|
||||
@@ -131,54 +131,54 @@ func TestWantsReleaseGroupFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := wantsReleaseGroup(tt.artist, tt.rg); got != tt.want {
|
||||
if got := requestsReleaseGroup(tt.artist, tt.rg); got != tt.want {
|
||||
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWantToRequestAnchors(t *testing.T) {
|
||||
func TestRequestToDownloadAnchors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
entity Entity
|
||||
check func(Request) string
|
||||
check func(Download) string
|
||||
}{
|
||||
{
|
||||
entity: EntityReleaseGroup,
|
||||
check: func(r Request) string {
|
||||
check: func(r Download) string {
|
||||
return r.ReleaseGroupMBID
|
||||
},
|
||||
},
|
||||
{
|
||||
entity: EntityRelease,
|
||||
check: func(r Request) string { return r.ReleaseMBID },
|
||||
check: func(r Download) string { return r.ReleaseMBID },
|
||||
},
|
||||
{
|
||||
entity: EntityRecording,
|
||||
check: func(r Request) string { return r.RecordingMBID },
|
||||
check: func(r Download) string { return r.RecordingMBID },
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
w := Want{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
|
||||
req := Request{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
|
||||
|
||||
req := w.ToRequest("req-1")
|
||||
dl := req.ToDownload("dl-1")
|
||||
|
||||
if got := tt.check(req); got != "mbid-x" {
|
||||
if got := tt.check(dl); got != "mbid-x" {
|
||||
t.Errorf("%s: anchor = %q, want mbid-x", tt.entity, got)
|
||||
}
|
||||
|
||||
if !req.Anchored() {
|
||||
t.Errorf("%s: request is not anchored", tt.entity)
|
||||
if !dl.Anchored() {
|
||||
t.Errorf("%s: download is not anchored", tt.entity)
|
||||
}
|
||||
|
||||
if req.WantID != 7 {
|
||||
t.Errorf("%s: WantID = %d, want 7", tt.entity, req.WantID)
|
||||
if dl.RequestID != 7 {
|
||||
t.Errorf("%s: RequestID = %d, want 7", tt.entity, dl.RequestID)
|
||||
}
|
||||
|
||||
if req.Source != wantSource {
|
||||
t.Errorf("%s: Source = %q, want %q", tt.entity, req.Source, wantSource)
|
||||
if dl.Source != requestSource {
|
||||
t.Errorf("%s: Source = %q, want %q", tt.entity, dl.Source, requestSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func TestWantToRequestAnchors(t *testing.T) {
|
||||
func TestAutoPickableRequiresTracklist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := Request{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
|
||||
dl := Download{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
|
||||
|
||||
ranked := []Candidate{{
|
||||
Match: MatchScore{Overall: 0.99, Anchored: true},
|
||||
@@ -197,42 +197,42 @@ func TestAutoPickableRequiresTracklist(t *testing.T) {
|
||||
Score: 0.95,
|
||||
}}
|
||||
|
||||
if AutoPickable(req, ranked) {
|
||||
t.Error("auto-picked a request with no expected tracklist")
|
||||
if AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
|
||||
t.Error("auto-picked a download with no expected tracklist")
|
||||
}
|
||||
|
||||
req.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
|
||||
dl.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
|
||||
|
||||
if !AutoPickable(req, ranked) {
|
||||
t.Error("did not auto-pick a well-anchored, well-matched request")
|
||||
if !AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
|
||||
t.Error("did not auto-pick a well-anchored, well-matched download")
|
||||
}
|
||||
}
|
||||
|
||||
// The wanted list's identity is the MBID, so the same one arriving
|
||||
// twice — in a different case, with whitespace — is one row.
|
||||
func TestAddWantNormalizesAndDeduplicates(t *testing.T) {
|
||||
func TestAddRequestNormalizesAndDeduplicates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newManagerFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := f.store.AddWant(ctx, Want{
|
||||
first, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: " ABC-123 ",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
Title: "OK Computer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
second, err := f.store.AddWant(ctx, Want{
|
||||
second, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "abc-123",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddWant again: %v", err)
|
||||
t.Fatalf("AddRequest again: %v", err)
|
||||
}
|
||||
|
||||
if first != second {
|
||||
@@ -240,27 +240,27 @@ func TestAddWantNormalizesAndDeduplicates(t *testing.T) {
|
||||
}
|
||||
|
||||
// Re-adding with no title must not wipe the one we have.
|
||||
w, err := f.store.GetWant(ctx, first)
|
||||
req, err := f.store.GetRequest(ctx, first)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWant: %v", err)
|
||||
t.Fatalf("GetRequest: %v", err)
|
||||
}
|
||||
|
||||
if w.Title != "OK Computer" {
|
||||
t.Errorf("title = %q, want it preserved", w.Title)
|
||||
if req.Title != "OK Computer" {
|
||||
t.Errorf("title = %q, want it preserved", req.Title)
|
||||
}
|
||||
|
||||
if w.MBID != "abc-123" {
|
||||
t.Errorf("mbid = %q, want normalized", w.MBID)
|
||||
if req.MBID != "abc-123" {
|
||||
t.Errorf("mbid = %q, want normalized", req.MBID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWantStoreLifecycle(t *testing.T) {
|
||||
func TestRequestStoreLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newManagerFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := f.store.AddWant(ctx, Want{
|
||||
id, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "rg-1",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
@@ -268,17 +268,17 @@ func TestWantStoreLifecycle(t *testing.T) {
|
||||
Title: "OK Computer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddWant: %v", err)
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
// A brand new want is due immediately.
|
||||
due, err := f.store.ListDueWants(ctx, 10)
|
||||
due, err := f.store.ListDueRequests(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDueWants: %v", err)
|
||||
t.Fatalf("ListDueRequests: %v", err)
|
||||
}
|
||||
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("got %d due wants, want 1", len(due))
|
||||
t.Fatalf("got %d due requests, want 1", len(due))
|
||||
}
|
||||
|
||||
// Recording an attempt pushes it out of the due set without
|
||||
@@ -287,92 +287,92 @@ func TestWantStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("RecordAttempt: %v", err)
|
||||
}
|
||||
|
||||
due, err = f.store.ListDueWants(ctx, 10)
|
||||
due, err = f.store.ListDueRequests(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDueWants after attempt: %v", err)
|
||||
t.Fatalf("ListDueRequests after attempt: %v", err)
|
||||
}
|
||||
|
||||
if len(due) != 0 {
|
||||
t.Errorf("got %d due wants after an attempt, want 0", len(due))
|
||||
t.Errorf("got %d due requests after an attempt, want 0", len(due))
|
||||
}
|
||||
|
||||
w, err := f.store.GetWant(ctx, id)
|
||||
req, err := f.store.GetRequest(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWant: %v", err)
|
||||
t.Fatalf("GetRequest: %v", err)
|
||||
}
|
||||
|
||||
if w.State != WantStateWanted {
|
||||
t.Errorf("state = %q, want it still wanted", w.State)
|
||||
if req.State != RequestStateWanted {
|
||||
t.Errorf("state = %q, want it still wanted", req.State)
|
||||
}
|
||||
|
||||
if w.Attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1", w.Attempts)
|
||||
if req.Attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1", req.Attempts)
|
||||
}
|
||||
|
||||
if w.LastError == "" {
|
||||
if req.LastError == "" {
|
||||
t.Error("last error was not recorded")
|
||||
}
|
||||
|
||||
if err := f.store.SatisfyWant(ctx, id); err != nil {
|
||||
t.Fatalf("SatisfyWant: %v", err)
|
||||
if err := f.store.SatisfyRequest(ctx, id); err != nil {
|
||||
t.Fatalf("SatisfyRequest: %v", err)
|
||||
}
|
||||
|
||||
w, err = f.store.GetWant(ctx, id)
|
||||
req, err = f.store.GetRequest(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWant after satisfy: %v", err)
|
||||
t.Fatalf("GetRequest after satisfy: %v", err)
|
||||
}
|
||||
|
||||
if w.State != WantStateSatisfied {
|
||||
t.Errorf("state = %q, want satisfied", w.State)
|
||||
if req.State != RequestStateSatisfied {
|
||||
t.Errorf("state = %q, want satisfied", req.State)
|
||||
}
|
||||
}
|
||||
|
||||
// Removing an artist subscription takes the albums it derived with it,
|
||||
// so a user who unsubscribes does not keep downloading that artist.
|
||||
func TestDeleteArtistWantCascadesToChildren(t *testing.T) {
|
||||
func TestDeleteArtistRequestCascadesToChildren(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newManagerFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
artist, err := f.store.AddWant(ctx, Want{
|
||||
artist, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "artist-1",
|
||||
Entity: EntityArtist,
|
||||
LibraryID: 1,
|
||||
Artist: "Radiohead",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddWant artist: %v", err)
|
||||
t.Fatalf("AddRequest artist: %v", err)
|
||||
}
|
||||
|
||||
if _, err := f.store.AddWant(ctx, Want{
|
||||
if _, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "rg-1",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
ParentID: artist,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddWant child: %v", err)
|
||||
t.Fatalf("AddRequest child: %v", err)
|
||||
}
|
||||
|
||||
children, err := f.store.ListChildWants(ctx, artist)
|
||||
children, err := f.store.ListChildRequests(ctx, artist)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChildWants: %v", err)
|
||||
t.Fatalf("ListChildRequests: %v", err)
|
||||
}
|
||||
|
||||
if len(children) != 1 {
|
||||
t.Fatalf("got %d children, want 1", len(children))
|
||||
}
|
||||
|
||||
if err := f.store.DeleteWant(ctx, artist); err != nil {
|
||||
t.Fatalf("DeleteWant: %v", err)
|
||||
if err := f.store.DeleteRequest(ctx, artist); err != nil {
|
||||
t.Fatalf("DeleteRequest: %v", err)
|
||||
}
|
||||
|
||||
all, err := f.store.ListWants(ctx)
|
||||
all, err := f.store.ListRequests(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWants: %v", err)
|
||||
t.Fatalf("ListRequests: %v", err)
|
||||
}
|
||||
|
||||
if len(all) != 0 {
|
||||
t.Errorf("got %d wants after deleting the artist, want 0", len(all))
|
||||
t.Errorf("got %d requests after deleting the artist, want 0", len(all))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -12,10 +12,10 @@ import (
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
// ErrNoLibrary means the request did not name a library to attach the
|
||||
// download to. Letting it through would hit the download_requests /
|
||||
// download_wants foreign key on library_id and surface as a raw SQLite
|
||||
// error, so it is rejected here with a message the UI can show.
|
||||
// ErrNoLibrary means the caller did not name a library to attach the
|
||||
// download to. Letting it through would hit the download_downloads /
|
||||
// download_requests foreign key on library_id and surface as a raw
|
||||
// SQLite error, so it is rejected here with a message the UI can show.
|
||||
var ErrNoLibrary = errors.New("no library selected")
|
||||
|
||||
// Service is the frontend-facing surface of the download subsystem.
|
||||
@@ -258,8 +258,20 @@ func (s *Service) TestProvider(id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPreferences pushes the auto-download guardrails straight into the
|
||||
// running Manager, without persisting them. Persistence is
|
||||
// config.Config's job (GetDownloadPreferences/SetDownloadPreferences);
|
||||
// this package cannot depend on config, since config already depends on
|
||||
// download for UserConfig. The frontend settings save is expected to
|
||||
// call the config setter and this method in the same action, the way
|
||||
// UpdateProvider already achieves "live without a restart" by touching
|
||||
// storage and the running Manager together.
|
||||
func (s *Service) SetPreferences(prefs AutoDownloadPrefs) {
|
||||
s.manager.SetPreferences(prefs)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requests
|
||||
// Downloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SearchRequest is what the frontend submits to start a download.
|
||||
@@ -275,7 +287,7 @@ type SearchRequest struct {
|
||||
|
||||
// StartResult is what the picker needs after a search.
|
||||
type StartResult struct {
|
||||
RequestID string `json:"requestId"`
|
||||
DownloadID string `json:"downloadId"`
|
||||
Candidates []Candidate `json:"candidates"`
|
||||
|
||||
// AutoPicked reports that the pipeline already chose and is
|
||||
@@ -284,14 +296,21 @@ type StartResult struct {
|
||||
AutoPicked bool `json:"autoPicked"`
|
||||
}
|
||||
|
||||
// Start searches for a release and either auto-picks a clear winner or
|
||||
// returns ranked candidates for the user to choose from.
|
||||
func (s *Service) Start(req SearchRequest) (StartResult, error) {
|
||||
// StartDownload searches for a release and either auto-picks a clear
|
||||
// winner or returns ranked candidates for the user to choose from.
|
||||
//
|
||||
// When the search carries a MusicBrainz anchor, it also resolves or
|
||||
// creates the durable Request the anchor names and attaches it, via
|
||||
// ensureRequest — so a manual download that fails or finds nothing
|
||||
// right now leaves a durable record behind instead of just vanishing,
|
||||
// and the reconciler picks it up on its normal schedule exactly as if
|
||||
// the user had explicitly added it to the request list.
|
||||
func (s *Service) StartDownload(req SearchRequest) (StartResult, error) {
|
||||
if req.LibraryID <= 0 {
|
||||
return StartResult{}, ErrNoLibrary
|
||||
}
|
||||
|
||||
r := Request{
|
||||
dl := Download{
|
||||
ID: newID(),
|
||||
LibraryID: req.LibraryID,
|
||||
ReleaseMBID: req.ReleaseMBID,
|
||||
@@ -302,15 +321,19 @@ func (s *Service) Start(req SearchRequest) (StartResult, error) {
|
||||
Expected: req.Expected,
|
||||
}
|
||||
|
||||
candidates, err := s.manager.Start(context.Background(), r)
|
||||
if id, ok := s.ensureRequest(dl); ok {
|
||||
dl.RequestID = id
|
||||
}
|
||||
|
||||
candidates, err := s.manager.Start(context.Background(), dl)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
|
||||
result := StartResult{
|
||||
RequestID: r.ID,
|
||||
DownloadID: dl.ID,
|
||||
Candidates: candidates,
|
||||
AutoPicked: AutoPickable(r, candidates),
|
||||
AutoPicked: s.manager.AutoPickable(dl, candidates),
|
||||
}
|
||||
|
||||
s.emit(events.DownloadsChanged)
|
||||
@@ -318,10 +341,51 @@ func (s *Service) Start(req SearchRequest) (StartResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ensureRequest resolves or creates the durable Request an anchored
|
||||
// manual download should be attached to. Free-text downloads (no
|
||||
// MBID) have nothing stable to attach to and are left alone.
|
||||
//
|
||||
// AddRequest already treats "asking twice" as one request and never
|
||||
// resets backoff or un-pauses a paused request on conflict, so a
|
||||
// manual download on something already paused still runs its one
|
||||
// interactive attempt now without disturbing the request's state.
|
||||
func (s *Service) ensureRequest(d Download) (int64, bool) {
|
||||
var (
|
||||
entity Entity
|
||||
mbid string
|
||||
)
|
||||
|
||||
switch {
|
||||
case d.ReleaseMBID != "":
|
||||
entity, mbid = EntityRelease, d.ReleaseMBID
|
||||
case d.ReleaseGroupMBID != "":
|
||||
entity, mbid = EntityReleaseGroup, d.ReleaseGroupMBID
|
||||
case d.RecordingMBID != "":
|
||||
entity, mbid = EntityRecording, d.RecordingMBID
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
|
||||
id, err := s.store.AddRequest(context.Background(), Request{
|
||||
MBID: mbid,
|
||||
Entity: entity,
|
||||
LibraryID: d.LibraryID,
|
||||
Artist: d.Artist,
|
||||
Title: d.Album,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("could not attach request to manual download", "error", err)
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return id, true
|
||||
}
|
||||
|
||||
// Pick starts the transfer for the candidate the user chose.
|
||||
func (s *Service) Pick(requestID, candidateID string) error {
|
||||
func (s *Service) Pick(downloadID, candidateID string) error {
|
||||
if err := s.manager.Pick(
|
||||
context.Background(), requestID, candidateID,
|
||||
context.Background(), downloadID, candidateID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -331,9 +395,9 @@ func (s *Service) Pick(requestID, candidateID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel aborts a live request.
|
||||
func (s *Service) Cancel(requestID string) error {
|
||||
if err := s.manager.Cancel(context.Background(), requestID); err != nil {
|
||||
// Cancel aborts a live download.
|
||||
func (s *Service) Cancel(downloadID string) error {
|
||||
if err := s.manager.Cancel(context.Background(), downloadID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -342,23 +406,28 @@ func (s *Service) Cancel(requestID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Candidates returns the ranked candidates of a live request, so the
|
||||
// Candidates returns the ranked candidates of a live download, so the
|
||||
// picker can be reopened without searching again.
|
||||
func (s *Service) Candidates(requestID string) []Candidate {
|
||||
return s.manager.Candidates(requestID)
|
||||
func (s *Service) Candidates(downloadID string) []Candidate {
|
||||
return s.manager.Candidates(downloadID)
|
||||
}
|
||||
|
||||
// RequestView is one row of the downloads list.
|
||||
type RequestView struct {
|
||||
Request
|
||||
// DownloadView is one row of the downloads list.
|
||||
//
|
||||
// would stop reading as "one row of the Downloads list" the moment this
|
||||
// package also has a Requests list — see RequestInput/Request nearby.
|
||||
//
|
||||
//nolint:revive // stutters as download.DownloadView, but a bare "View"
|
||||
type DownloadView struct {
|
||||
Download
|
||||
|
||||
State State `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Items []Item `json:"items"`
|
||||
State State `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Items []DownloadItem `json:"items"`
|
||||
}
|
||||
|
||||
// ListRequests returns recent download requests, newest first.
|
||||
func (s *Service) ListRequests(limit int) ([]RequestView, error) {
|
||||
// ListDownloads returns recent downloads, newest first.
|
||||
func (s *Service) ListDownloads(limit int) ([]DownloadView, error) {
|
||||
const defaultLimit = 50
|
||||
|
||||
if limit <= 0 {
|
||||
@@ -367,36 +436,36 @@ func (s *Service) ListRequests(limit int) ([]RequestView, error) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
requests, err := s.store.ListRequests(ctx, limit)
|
||||
downloads, err := s.store.ListDownloads(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]RequestView, 0, len(requests))
|
||||
out := make([]DownloadView, 0, len(downloads))
|
||||
|
||||
for _, r := range requests {
|
||||
state, errText, err := s.store.GetRequestState(ctx, r.ID)
|
||||
for _, d := range downloads {
|
||||
state, errText, err := s.store.GetDownloadState(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items, err := s.store.ListItemsForRequest(ctx, r.ID)
|
||||
items, err := s.store.ListItemsForDownload(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, RequestView{
|
||||
Request: r,
|
||||
State: state,
|
||||
Error: errText,
|
||||
Items: items,
|
||||
out = append(out, DownloadView{
|
||||
Download: d,
|
||||
State: state,
|
||||
Error: errText,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ClearFinished removes terminal requests from the list.
|
||||
// ClearFinished removes terminal downloads from the list.
|
||||
func (s *Service) ClearFinished() error {
|
||||
if err := s.store.ClearFinished(context.Background()); err != nil {
|
||||
return err
|
||||
@@ -408,19 +477,20 @@ func (s *Service) ClearFinished() error {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wanted list
|
||||
// Request list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetReconciler wires the wanted-list loop. Optional: without it the
|
||||
// wanted list still stores and lists wants, it just never acts on them.
|
||||
// SetReconciler wires the request-list loop. Optional: without it the
|
||||
// request list still stores and lists requests, it just never acts on
|
||||
// them.
|
||||
func (s *Service) SetReconciler(r *Reconciler) {
|
||||
s.reconciler = r
|
||||
}
|
||||
|
||||
// WantRequest is what the frontend submits to want something. It is
|
||||
// one MBID and the type of thing it names, because that is genuinely
|
||||
// all a want is.
|
||||
type WantRequest struct {
|
||||
// RequestInput is what the frontend submits to request something. It
|
||||
// is one MBID and the type of thing it names, because that is
|
||||
// genuinely all a durable request is.
|
||||
type RequestInput struct {
|
||||
MBID string `json:"mbid"`
|
||||
Entity string `json:"entity"`
|
||||
LibraryID int64 `json:"libraryId"`
|
||||
@@ -431,15 +501,15 @@ type WantRequest struct {
|
||||
Artist string `json:"artist"`
|
||||
Title string `json:"title"`
|
||||
|
||||
// Scope and Secondary apply to artist wants.
|
||||
// Scope and Secondary apply to artist requests.
|
||||
Scope string `json:"scope"`
|
||||
Secondary bool `json:"secondary"`
|
||||
}
|
||||
|
||||
// AddWant puts something on the wanted list and asks for a reconcile
|
||||
// pass, so the user sees something happen rather than waiting six hours
|
||||
// for the next scheduled one.
|
||||
func (s *Service) AddWant(req WantRequest) (int64, error) {
|
||||
// AddRequest puts something on the request list and asks for a
|
||||
// reconcile pass, so the user sees something happen rather than
|
||||
// waiting six hours for the next scheduled one.
|
||||
func (s *Service) AddRequest(req RequestInput) (int64, error) {
|
||||
if req.LibraryID <= 0 {
|
||||
return 0, ErrNoLibrary
|
||||
}
|
||||
@@ -449,12 +519,12 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
|
||||
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, req.Entity)
|
||||
}
|
||||
|
||||
scope := WantScope(req.Scope)
|
||||
scope := RequestScope(req.Scope)
|
||||
if scope != ScopeAll {
|
||||
scope = ScopeFuture
|
||||
}
|
||||
|
||||
id, err := s.store.AddWant(context.Background(), Want{
|
||||
id, err := s.store.AddRequest(context.Background(), Request{
|
||||
MBID: req.MBID,
|
||||
Entity: entity,
|
||||
LibraryID: req.LibraryID,
|
||||
@@ -467,7 +537,7 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
s.emit(events.WantedListChanged)
|
||||
s.emit(events.RequestsChanged)
|
||||
|
||||
if s.reconciler != nil {
|
||||
s.reconciler.Trigger()
|
||||
@@ -476,73 +546,73 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListWants returns the whole wanted list.
|
||||
func (s *Service) ListWants() ([]Want, error) {
|
||||
return s.store.ListWants(context.Background())
|
||||
// ListRequests returns the whole durable request list.
|
||||
func (s *Service) ListRequests() ([]Request, error) {
|
||||
return s.store.ListRequests(context.Background())
|
||||
}
|
||||
|
||||
// IsWanted answers the Explore pages' question — should this album show
|
||||
// "want" or "wanted?" — without making them load the whole list.
|
||||
func (s *Service) IsWanted(mbid string, libraryID int64) (bool, error) {
|
||||
_, found, err := s.store.FindWant(context.Background(), mbid, libraryID)
|
||||
// IsRequested answers the Explore pages' question — should this album
|
||||
// show "want" or "wanted?" — without making them load the whole list.
|
||||
func (s *Service) IsRequested(mbid string, libraryID int64) (bool, error) {
|
||||
_, found, err := s.store.FindRequest(context.Background(), mbid, libraryID)
|
||||
|
||||
return found, err
|
||||
}
|
||||
|
||||
// RemoveWant takes something off the list. Removing an artist takes
|
||||
// RemoveRequest takes something off the list. Removing an artist takes
|
||||
// its derived albums with it, by cascade; an album the user pinned
|
||||
// themselves has no parent and survives.
|
||||
func (s *Service) RemoveWant(id int64) error {
|
||||
func (s *Service) RemoveRequest(id int64) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Tell any external list first, while the row is still readable.
|
||||
s.withdrawExternal(ctx, id)
|
||||
|
||||
if err := s.store.DeleteWant(ctx, id); err != nil {
|
||||
if err := s.store.DeleteRequest(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.emit(events.WantedListChanged)
|
||||
s.emit(events.RequestsChanged)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PauseWant stops attempts without forgetting the want.
|
||||
func (s *Service) PauseWant(id int64, paused bool) error {
|
||||
state := WantStateWanted
|
||||
// PauseRequest stops attempts without forgetting the request.
|
||||
func (s *Service) PauseRequest(id int64, paused bool) error {
|
||||
state := RequestStateWanted
|
||||
if paused {
|
||||
state = WantStatePaused
|
||||
state = RequestStatePaused
|
||||
}
|
||||
|
||||
if err := s.store.SetWantState(
|
||||
if err := s.store.SetRequestState(
|
||||
context.Background(), id, state, "",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.emit(events.WantedListChanged)
|
||||
s.emit(events.RequestsChanged)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSatisfiedWants drops everything already owned.
|
||||
func (s *Service) ClearSatisfiedWants() error {
|
||||
if err := s.store.ClearSatisfiedWants(context.Background()); err != nil {
|
||||
// ClearSatisfiedRequests drops everything already owned.
|
||||
func (s *Service) ClearSatisfiedRequests() error {
|
||||
if err := s.store.ClearSatisfiedRequests(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.emit(events.WantedListChanged)
|
||||
s.emit(events.RequestsChanged)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReconcileWanted runs a pass now and reports what it did. This backs
|
||||
// the "check now" button, so it runs synchronously: the user pressed it
|
||||
// and is waiting for an answer.
|
||||
func (s *Service) ReconcileWanted() (Summary, error) {
|
||||
// ReconcileRequests runs a pass now and reports what it did. This
|
||||
// backs the "check now" button, so it runs synchronously: the user
|
||||
// pressed it and is waiting for an answer.
|
||||
func (s *Service) ReconcileRequests() (Summary, error) {
|
||||
if s.reconciler == nil {
|
||||
return Summary{}, fmt.Errorf(
|
||||
"%w: the wanted list is not running", ErrUnsupported,
|
||||
"%w: the request list is not running", ErrUnsupported,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -551,20 +621,20 @@ func (s *Service) ReconcileWanted() (Summary, error) {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
s.emit(events.WantedListChanged)
|
||||
s.emit(events.RequestsChanged)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// ImportExternalWants adopts a provider's own list — "import the
|
||||
// ImportExternalRequests adopts a provider's own list — "import the
|
||||
// artists Lidarr is already monitoring".
|
||||
func (s *Service) ImportExternalWants(
|
||||
func (s *Service) ImportExternalRequests(
|
||||
providerID int64,
|
||||
libraryID int64,
|
||||
) (int, error) {
|
||||
if s.reconciler == nil {
|
||||
return 0, fmt.Errorf(
|
||||
"%w: the wanted list is not running", ErrUnsupported,
|
||||
"%w: the request list is not running", ErrUnsupported,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -575,22 +645,22 @@ func (s *Service) ImportExternalWants(
|
||||
return 0, err
|
||||
}
|
||||
|
||||
s.emit(events.WantedListChanged)
|
||||
s.emit(events.RequestsChanged)
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// withdrawExternal best-effort unmonitors a want in the external lists
|
||||
// it was pushed to. Failures are logged and ignored: the user asked to
|
||||
// remove it from *this* list, and an unreachable Lidarr is not a reason
|
||||
// to refuse.
|
||||
// withdrawExternal best-effort unmonitors a request in the external
|
||||
// lists it was pushed to. Failures are logged and ignored: the user
|
||||
// asked to remove it from *this* list, and an unreachable Lidarr is not
|
||||
// a reason to refuse.
|
||||
func (s *Service) withdrawExternal(ctx context.Context, id int64) {
|
||||
w, err := s.store.GetWant(ctx, id)
|
||||
if err != nil || len(w.ExternalIDs) == 0 {
|
||||
r, err := s.store.GetRequest(ctx, id)
|
||||
if err != nil || len(r.ExternalIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for key, externalID := range w.ExternalIDs {
|
||||
for key, externalID := range r.ExternalIDs {
|
||||
providerID, err := strconv.ParseInt(key, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -601,10 +671,10 @@ func (s *Service) withdrawExternal(ctx context.Context, id int64) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := l.RemoveWant(ctx, externalID); err != nil {
|
||||
if err := l.RemoveRequest(ctx, externalID); err != nil {
|
||||
s.logger.Debug(
|
||||
"could not withdraw want from external list",
|
||||
"want", id,
|
||||
"could not withdraw request from external list",
|
||||
"request", id,
|
||||
"provider", providerID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
@@ -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
@@ -137,148 +137,148 @@ func providerRowToConfig(r sqlcgen.DownloadProvider) Config {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requests
|
||||
// Downloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateRequest persists a new request.
|
||||
func (s *Store) CreateRequest(ctx context.Context, req Request) error {
|
||||
expected, err := json.Marshal(req.Expected)
|
||||
// CreateDownload persists a new download.
|
||||
func (s *Store) CreateDownload(ctx context.Context, dl Download) error {
|
||||
expected, err := json.Marshal(dl.Expected)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode expected tracks: %w", err)
|
||||
}
|
||||
|
||||
source := req.Source
|
||||
source := dl.Source
|
||||
if source == "" {
|
||||
source = "manual"
|
||||
}
|
||||
|
||||
wantID := sql.NullInt64{}
|
||||
if req.WantID != 0 {
|
||||
wantID = sql.NullInt64{Int64: req.WantID, Valid: true}
|
||||
requestID := sql.NullInt64{}
|
||||
if dl.RequestID != 0 {
|
||||
requestID = sql.NullInt64{Int64: dl.RequestID, Valid: true}
|
||||
}
|
||||
|
||||
if err := s.db.Queries.CreateDownloadRequest(
|
||||
if err := s.db.Queries.CreateDownload(
|
||||
ctx,
|
||||
sqlcgen.CreateDownloadRequestParams{
|
||||
ID: req.ID,
|
||||
LibraryID: req.LibraryID,
|
||||
sqlcgen.CreateDownloadParams{
|
||||
ID: dl.ID,
|
||||
LibraryID: dl.LibraryID,
|
||||
Source: source,
|
||||
WantID: wantID,
|
||||
ReleaseMbid: toNullString(req.ReleaseMBID),
|
||||
ReleaseGroupMbid: toNullString(req.ReleaseGroupMBID),
|
||||
RecordingMbid: toNullString(req.RecordingMBID),
|
||||
Artist: req.Artist,
|
||||
Album: req.Album,
|
||||
Query: req.Query,
|
||||
RequestID: requestID,
|
||||
ReleaseMbid: toNullString(dl.ReleaseMBID),
|
||||
ReleaseGroupMbid: toNullString(dl.ReleaseGroupMBID),
|
||||
RecordingMbid: toNullString(dl.RecordingMBID),
|
||||
Artist: dl.Artist,
|
||||
Album: dl.Album,
|
||||
Query: dl.Query,
|
||||
Expected: string(expected),
|
||||
State: string(StateSearching),
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("create download request: %w", err)
|
||||
return fmt.Errorf("create download: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRequest loads a request by ID.
|
||||
func (s *Store) GetRequest(ctx context.Context, id string) (Request, error) {
|
||||
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
|
||||
// GetDownload loads a download by ID.
|
||||
func (s *Store) GetDownload(ctx context.Context, id string) (Download, error) {
|
||||
row, err := s.db.ReadQueries.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Request{}, fmt.Errorf("%w: request %s", ErrNotFound, id)
|
||||
return Download{}, fmt.Errorf("%w: download %s", ErrNotFound, id)
|
||||
}
|
||||
|
||||
return Request{}, fmt.Errorf("get download request: %w", err)
|
||||
return Download{}, fmt.Errorf("get download: %w", err)
|
||||
}
|
||||
|
||||
return requestRowToRequest(row), nil
|
||||
return downloadRowToDownload(row), nil
|
||||
}
|
||||
|
||||
// GetRequestState returns a request's current state and error text.
|
||||
// Kept separate from GetRequest because state is the one field that
|
||||
// GetDownloadState returns a download's current state and error text.
|
||||
// Kept separate from GetDownload because state is the one field that
|
||||
// changes constantly while the rest of the row is immutable.
|
||||
func (s *Store) GetRequestState(
|
||||
func (s *Store) GetDownloadState(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
) (State, string, error) {
|
||||
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
|
||||
row, err := s.db.ReadQueries.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", fmt.Errorf("%w: request %s", ErrNotFound, id)
|
||||
return "", "", fmt.Errorf("%w: download %s", ErrNotFound, id)
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("get download request state: %w", err)
|
||||
return "", "", fmt.Errorf("get download state: %w", err)
|
||||
}
|
||||
|
||||
return State(row.State), row.Error, nil
|
||||
}
|
||||
|
||||
// ListRequests returns the most recent requests, newest first.
|
||||
func (s *Store) ListRequests(ctx context.Context, limit int) ([]Request, error) {
|
||||
rows, err := s.db.ReadQueries.ListDownloadRequests(ctx, int64(limit))
|
||||
// ListDownloads returns the most recent downloads, newest first.
|
||||
func (s *Store) ListDownloads(ctx context.Context, limit int) ([]Download, error) {
|
||||
rows, err := s.db.ReadQueries.ListDownloads(ctx, int64(limit))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list download requests: %w", err)
|
||||
return nil, fmt.Errorf("list downloads: %w", err)
|
||||
}
|
||||
|
||||
out := make([]Request, 0, len(rows))
|
||||
out := make([]Download, 0, len(rows))
|
||||
|
||||
for _, r := range rows {
|
||||
out = append(out, requestRowToRequest(r))
|
||||
out = append(out, downloadRowToDownload(r))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetRequestState updates a request's state and error text.
|
||||
func (s *Store) SetRequestState(
|
||||
// SetDownloadState updates a download's state and error text.
|
||||
func (s *Store) SetDownloadState(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
state State,
|
||||
errText string,
|
||||
) error {
|
||||
if err := s.db.Queries.SetDownloadRequestState(
|
||||
if err := s.db.Queries.SetDownloadState(
|
||||
ctx,
|
||||
sqlcgen.SetDownloadRequestStateParams{
|
||||
sqlcgen.SetDownloadStateParams{
|
||||
State: string(state),
|
||||
Error: errText,
|
||||
ID: id,
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("set download request state: %w", err)
|
||||
return fmt.Errorf("set download state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRequest removes a request and, by cascade, its items.
|
||||
func (s *Store) DeleteRequest(ctx context.Context, id string) error {
|
||||
if err := s.db.Queries.DeleteDownloadRequest(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete download request: %w", err)
|
||||
// DeleteDownload removes a download and, by cascade, its items.
|
||||
func (s *Store) DeleteDownload(ctx context.Context, id string) error {
|
||||
if err := s.db.Queries.DeleteDownload(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete download: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearFinished removes every terminal request.
|
||||
// ClearFinished removes every terminal download.
|
||||
func (s *Store) ClearFinished(ctx context.Context) error {
|
||||
if err := s.db.Queries.DeleteFinishedDownloadRequests(ctx); err != nil {
|
||||
return fmt.Errorf("clear finished download requests: %w", err)
|
||||
if err := s.db.Queries.DeleteFinishedDownloads(ctx); err != nil {
|
||||
return fmt.Errorf("clear finished downloads: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestRowToRequest decodes a stored request row.
|
||||
func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
|
||||
// downloadRowToDownload decodes a stored download row.
|
||||
func downloadRowToDownload(r sqlcgen.DownloadDownload) Download {
|
||||
var expected []ExpectedTrack
|
||||
|
||||
_ = json.Unmarshal([]byte(r.Expected), &expected)
|
||||
|
||||
return Request{
|
||||
return Download{
|
||||
ID: r.ID,
|
||||
LibraryID: r.LibraryID,
|
||||
Source: r.Source,
|
||||
WantID: r.WantID.Int64,
|
||||
RequestID: r.RequestID.Int64,
|
||||
ReleaseMBID: r.ReleaseMbid.String,
|
||||
ReleaseGroupMBID: r.ReleaseGroupMbid.String,
|
||||
RecordingMBID: r.RecordingMbid.String,
|
||||
@@ -294,10 +294,16 @@ func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
|
||||
// Items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Item is one grab attempt, as stored.
|
||||
type Item struct {
|
||||
// DownloadItem is one grab attempt, as stored.
|
||||
//
|
||||
// deliberate: distinguishes it from download.Download (the attempt) and
|
||||
// download.Request (the durable record) at every call site, which a bare
|
||||
// "Item" would not.
|
||||
//
|
||||
//nolint:revive // stutters as download.DownloadItem, but the name is
|
||||
type DownloadItem struct {
|
||||
ID string `json:"id"`
|
||||
RequestID string `json:"requestId"`
|
||||
DownloadID string `json:"downloadId"`
|
||||
ProviderID int64 `json:"providerId"`
|
||||
Transport int64 `json:"transportId,omitempty"`
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
@@ -313,7 +319,7 @@ type Item struct {
|
||||
}
|
||||
|
||||
// CreateItem persists a grab attempt.
|
||||
func (s *Store) CreateItem(ctx context.Context, item Item) error {
|
||||
func (s *Store) CreateItem(ctx context.Context, item DownloadItem) error {
|
||||
candidate, err := json.Marshal(item.Candidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode candidate: %w", err)
|
||||
@@ -328,7 +334,7 @@ func (s *Store) CreateItem(ctx context.Context, item Item) error {
|
||||
ctx,
|
||||
sqlcgen.CreateDownloadItemParams{
|
||||
ID: item.ID,
|
||||
RequestID: item.RequestID,
|
||||
DownloadID: item.DownloadID,
|
||||
ProviderID: item.ProviderID,
|
||||
TransportID: transport,
|
||||
ExternalID: item.ExternalID,
|
||||
@@ -345,30 +351,31 @@ func (s *Store) CreateItem(ctx context.Context, item Item) error {
|
||||
}
|
||||
|
||||
// GetItem loads one item.
|
||||
func (s *Store) GetItem(ctx context.Context, id string) (Item, error) {
|
||||
func (s *Store) GetItem(ctx context.Context, id string) (DownloadItem, error) {
|
||||
row, err := s.db.ReadQueries.GetDownloadItem(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Item{}, fmt.Errorf("%w: item %s", ErrNotFound, id)
|
||||
return DownloadItem{}, fmt.Errorf("%w: item %s", ErrNotFound, id)
|
||||
}
|
||||
|
||||
return Item{}, fmt.Errorf("get download item: %w", err)
|
||||
return DownloadItem{}, fmt.Errorf("get download item: %w", err)
|
||||
}
|
||||
|
||||
return itemRowToItem(row), nil
|
||||
}
|
||||
|
||||
// ListItemsForRequest returns a request's grab attempts, oldest first.
|
||||
func (s *Store) ListItemsForRequest(
|
||||
// ListItemsForDownload returns a download's grab attempts, oldest
|
||||
// first.
|
||||
func (s *Store) ListItemsForDownload(
|
||||
ctx context.Context,
|
||||
requestID string,
|
||||
) ([]Item, error) {
|
||||
rows, err := s.db.ReadQueries.ListDownloadItemsForRequest(ctx, requestID)
|
||||
downloadID string,
|
||||
) ([]DownloadItem, error) {
|
||||
rows, err := s.db.ReadQueries.ListDownloadItemsForDownload(ctx, downloadID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list download items: %w", err)
|
||||
}
|
||||
|
||||
out := make([]Item, 0, len(rows))
|
||||
out := make([]DownloadItem, 0, len(rows))
|
||||
|
||||
for _, r := range rows {
|
||||
out = append(out, itemRowToItem(r))
|
||||
@@ -379,13 +386,13 @@ func (s *Store) ListItemsForRequest(
|
||||
|
||||
// ListLiveItems returns every non-terminal item. Called at startup to
|
||||
// decide what to resume, reconcile or abandon.
|
||||
func (s *Store) ListLiveItems(ctx context.Context) ([]Item, error) {
|
||||
func (s *Store) ListLiveItems(ctx context.Context) ([]DownloadItem, error) {
|
||||
rows, err := s.db.ReadQueries.ListLiveDownloadItems(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list live download items: %w", err)
|
||||
}
|
||||
|
||||
out := make([]Item, 0, len(rows))
|
||||
out := make([]DownloadItem, 0, len(rows))
|
||||
|
||||
for _, r := range rows {
|
||||
out = append(out, itemRowToItem(r))
|
||||
@@ -479,7 +486,7 @@ func (s *Store) SetItemImported(
|
||||
}
|
||||
|
||||
// itemRowToItem decodes a stored item row.
|
||||
func itemRowToItem(r sqlcgen.DownloadItem) Item {
|
||||
func itemRowToItem(r sqlcgen.DownloadItem) DownloadItem {
|
||||
var (
|
||||
candidate Candidate
|
||||
imported []string
|
||||
@@ -488,9 +495,9 @@ func itemRowToItem(r sqlcgen.DownloadItem) Item {
|
||||
_ = json.Unmarshal([]byte(r.Candidate), &candidate)
|
||||
_ = json.Unmarshal([]byte(r.ImportedPaths), &imported)
|
||||
|
||||
return Item{
|
||||
return DownloadItem{
|
||||
ID: r.ID,
|
||||
RequestID: r.RequestID,
|
||||
DownloadID: r.DownloadID,
|
||||
ProviderID: r.ProviderID,
|
||||
Transport: r.TransportID.Int64,
|
||||
ExternalID: r.ExternalID,
|
||||
|
||||
+33
-25
@@ -82,29 +82,36 @@ func (c Caps) Handles(p Protocol) bool {
|
||||
return slices.Contains(c.Transports, p)
|
||||
}
|
||||
|
||||
// Request is what the user asked for. Requests that carry a MusicBrainz
|
||||
// anchor are far more reliable than free-text ones, because the anchor
|
||||
// gives the import step an expected tracklist to match against — so the
|
||||
// pipeline records which it got and refuses to auto-pick without one.
|
||||
type Request struct {
|
||||
// Download is one search-and-grab attempt: it searches, it grabs, it
|
||||
// succeeds or fails, and then it is history. A Download that carries a
|
||||
// MusicBrainz anchor is far more reliable than a free-text one, because
|
||||
// the anchor gives the import step an expected tracklist to match
|
||||
// against — so the pipeline records which it got and refuses to
|
||||
// auto-pick without one.
|
||||
//
|
||||
// A Download is not the same thing as a Request (request.go): a
|
||||
// Request is durable and outlives every attempt made on its behalf,
|
||||
// while a Download is one such attempt and is disposable.
|
||||
type Download struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
// Anchors. Any may be empty; all empty means free-text.
|
||||
ReleaseMBID string `json:"releaseMbid,omitempty"`
|
||||
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
|
||||
|
||||
// RecordingMBID anchors a single-track request. Its Expected holds
|
||||
// exactly that one track, which is what lets a track request be
|
||||
// RecordingMBID anchors a single-track download. Its Expected holds
|
||||
// exactly that one track, which is what lets a track download be
|
||||
// scored — and therefore auto-picked — on the same footing as an
|
||||
// album.
|
||||
RecordingMBID string `json:"recordingMbid,omitempty"`
|
||||
|
||||
// WantID links back to the wanted-list row this request was raised
|
||||
// for, or 0 for a request the user started by hand. The reconciler
|
||||
// writes the outcome back through it.
|
||||
WantID int64 `json:"wantId,omitempty"`
|
||||
// RequestID links back to the durable Request row this download was
|
||||
// raised for or attached to, or 0 for a free-text download with
|
||||
// nothing stable to attach to. The reconciler and manual anchored
|
||||
// downloads both write the outcome back through it.
|
||||
RequestID int64 `json:"requestId,omitempty"`
|
||||
|
||||
// Source records where the request came from, for the downloads
|
||||
// Source records where the download came from, for the downloads
|
||||
// list. Empty means "manual".
|
||||
Source string `json:"source,omitempty"`
|
||||
|
||||
@@ -116,7 +123,7 @@ type Request struct {
|
||||
|
||||
// Expected is the tracklist the anchor resolves to, used for
|
||||
// completeness scoring and for the autotag match at import. Empty
|
||||
// for free-text requests.
|
||||
// for free-text downloads.
|
||||
Expected []ExpectedTrack `json:"expected,omitempty"`
|
||||
|
||||
// LibraryID is the library imported files belong to.
|
||||
@@ -125,12 +132,12 @@ type Request struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Anchored reports whether the request carries a MusicBrainz ID. Only
|
||||
// anchored requests are eligible for auto-pick.
|
||||
func (r Request) Anchored() bool {
|
||||
return r.ReleaseMBID != "" ||
|
||||
r.ReleaseGroupMBID != "" ||
|
||||
r.RecordingMBID != ""
|
||||
// Anchored reports whether the download carries a MusicBrainz ID. Only
|
||||
// anchored downloads are eligible for auto-pick.
|
||||
func (d Download) Anchored() bool {
|
||||
return d.ReleaseMBID != "" ||
|
||||
d.ReleaseGroupMBID != "" ||
|
||||
d.RecordingMBID != ""
|
||||
}
|
||||
|
||||
// SearchText returns the string to hand a provider's search endpoint.
|
||||
@@ -142,16 +149,16 @@ func (r Request) Anchored() bool {
|
||||
// return zero results on providers that expect every term to appear
|
||||
// in a match (Soulseek in particular), so the artist is dropped when
|
||||
// the album title already leads with it.
|
||||
func (r Request) SearchText() string {
|
||||
if r.Query != "" {
|
||||
return r.Query
|
||||
func (d Download) SearchText() string {
|
||||
if d.Query != "" {
|
||||
return d.Query
|
||||
}
|
||||
|
||||
if r.Artist != "" && albumLeadsWithArtist(r.Artist, r.Album) {
|
||||
return strings.TrimSpace(r.Album)
|
||||
if d.Artist != "" && albumLeadsWithArtist(d.Artist, d.Album) {
|
||||
return strings.TrimSpace(d.Album)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(r.Artist + " " + r.Album)
|
||||
return strings.TrimSpace(d.Artist + " " + d.Album)
|
||||
}
|
||||
|
||||
// albumLeadsWithArtist reports whether album starts with artist as a
|
||||
@@ -295,6 +302,7 @@ type QualityScore struct {
|
||||
Bitrate float64 `json:"bitrate"`
|
||||
Health float64 `json:"health"` // seeders, free slots
|
||||
Priority float64 `json:"priority"` // user's per-provider preference
|
||||
SizeFit float64 `json:"sizeFit"` // closeness to the preferred download size
|
||||
|
||||
// Mixed marks a candidate whose files are not all the same format,
|
||||
// which usually means a hand-assembled folder rather than a rip.
|
||||
|
||||
@@ -2,52 +2,52 @@ package download
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRequest_SearchText(t *testing.T) {
|
||||
func TestDownload_SearchText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req Request
|
||||
dl Download
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "query overrides everything",
|
||||
req: Request{Artist: "Blank Banshee", Album: "0", Query: "raw text"},
|
||||
dl: Download{Artist: "Blank Banshee", Album: "0", Query: "raw text"},
|
||||
want: "raw text",
|
||||
},
|
||||
{
|
||||
name: "ordinary album keeps artist and album",
|
||||
req: Request{Artist: "Pink Floyd", Album: "The Wall"},
|
||||
dl: Download{Artist: "Pink Floyd", Album: "The Wall"},
|
||||
want: "Pink Floyd The Wall",
|
||||
},
|
||||
{
|
||||
name: "album title leads with artist name",
|
||||
req: Request{Artist: "Blank Banshee", Album: "Blank Banshee 0"},
|
||||
dl: Download{Artist: "Blank Banshee", Album: "Blank Banshee 0"},
|
||||
want: "Blank Banshee 0",
|
||||
},
|
||||
{
|
||||
name: "self-titled album",
|
||||
req: Request{Artist: "Boston", Album: "Boston"},
|
||||
dl: Download{Artist: "Boston", Album: "Boston"},
|
||||
want: "Boston",
|
||||
},
|
||||
{
|
||||
name: "artist name as a substring, not a word prefix",
|
||||
req: Request{Artist: "Air", Album: "Repair"},
|
||||
dl: Download{Artist: "Air", Album: "Repair"},
|
||||
want: "Air Repair",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive match",
|
||||
req: Request{Artist: "blank banshee", Album: "BLANK BANSHEE 0"},
|
||||
dl: Download{Artist: "blank banshee", Album: "BLANK BANSHEE 0"},
|
||||
want: "BLANK BANSHEE 0",
|
||||
},
|
||||
{
|
||||
name: "no artist",
|
||||
req: Request{Album: "Compilation"},
|
||||
dl: Download{Album: "Compilation"},
|
||||
want: "Compilation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.req.SearchText(); got != tt.want {
|
||||
if got := tt.dl.SearchText(); got != tt.want {
|
||||
t.Errorf("SearchText() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -123,15 +123,15 @@ const (
|
||||
// open download picker re-read the provider list.
|
||||
DownloadProvidersChanged = "DownloadProvidersChanged"
|
||||
|
||||
// DownloadsChanged fires when the set of download requests changes
|
||||
// (started, picked, cancelled, cleared). Per-transfer progress does
|
||||
// not use this — it flows through the jobs registry's JobsChanged,
|
||||
// which already coalesces high-frequency updates.
|
||||
// DownloadsChanged fires when the set of downloads changes (started,
|
||||
// picked, cancelled, cleared). Per-transfer progress does not use
|
||||
// this — it flows through the jobs registry's JobsChanged, which
|
||||
// already coalesces high-frequency updates.
|
||||
DownloadsChanged = "DownloadsChanged"
|
||||
|
||||
// WantedListChanged fires when the wanted list gains, loses or
|
||||
// RequestsChanged fires when the request list gains, loses or
|
||||
// retires an entry — including from a background reconcile pass,
|
||||
// which is why the list is event-driven rather than fetched once on
|
||||
// mount.
|
||||
WantedListChanged = "WantedListChanged"
|
||||
RequestsChanged = "RequestsChanged"
|
||||
)
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ import '@components/autotag-view/autotag-view.ts';
|
||||
import '@components/first-run-wizard/first-run-wizard.ts';
|
||||
import '@components/jobs/job-indicator.ts';
|
||||
import '@components/jobs/jobs-view.ts';
|
||||
import '@components/wanted-view/wanted-view.ts';
|
||||
import '@components/downloads-view/downloads-view.ts';
|
||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||
@@ -64,7 +64,7 @@ const VIEW_TAGS: Record<string, string> = {
|
||||
playlists: 'playlist-view',
|
||||
explore: 'explore-view',
|
||||
autotag: 'autotag-view',
|
||||
wanted: 'wanted-view',
|
||||
downloads: 'downloads-view',
|
||||
jobs: 'jobs-view',
|
||||
settings: 'config-page',
|
||||
};
|
||||
|
||||
@@ -15,8 +15,28 @@ import type {
|
||||
} from '@store/download-store';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/Config';
|
||||
import { SetPreferences } from '@go/download/Service';
|
||||
import type { download } from '@go/models';
|
||||
import './config-section';
|
||||
|
||||
/**
|
||||
* Allowed audio formats for auto-download, mirrored from
|
||||
* backend/download/types.go's `Format` constants. `FormatUnknown` is
|
||||
* deliberately excluded — it names "no format detected", not a format a
|
||||
* user could opt into.
|
||||
*/
|
||||
const AUTO_DOWNLOAD_FORMATS: { value: string; label: string }[] = [
|
||||
{ value: 'flac', label: 'FLAC' },
|
||||
{ value: 'alac', label: 'ALAC' },
|
||||
{ value: 'wav', label: 'WAV' },
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
{ value: 'aac', label: 'AAC' },
|
||||
{ value: 'ogg', label: 'OGG' },
|
||||
{ value: 'opus', label: 'Opus' },
|
||||
{ value: 'wma', label: 'WMA' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Download client configuration.
|
||||
*
|
||||
@@ -59,6 +79,24 @@ export class DownloadClients extends LitElement {
|
||||
@state()
|
||||
private errorMessage = '';
|
||||
|
||||
/** Working copy of the auto-download guardrails. */
|
||||
@state()
|
||||
private prefs: download.AutoDownloadPrefs = {
|
||||
minSizeMb: 0,
|
||||
maxSizeMb: 0,
|
||||
preferredSizeMb: 0,
|
||||
allowedFormats: [],
|
||||
} as download.AutoDownloadPrefs;
|
||||
|
||||
@state()
|
||||
private prefsSaving = false;
|
||||
|
||||
@state()
|
||||
private prefsError = '';
|
||||
|
||||
@state()
|
||||
private prefsSaved = false;
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
@@ -67,6 +105,7 @@ export class DownloadClients extends LitElement {
|
||||
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
|
||||
|
||||
void downloadStore.init().then(() => this.syncFromStore());
|
||||
void this.loadPreferences();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -81,6 +120,14 @@ export class DownloadClients extends LitElement {
|
||||
this.descriptors = downloadStore.descriptors;
|
||||
}
|
||||
|
||||
private async loadPreferences(): Promise<void> {
|
||||
try {
|
||||
this.prefs = await GetDownloadPreferences();
|
||||
} catch (err) {
|
||||
console.error('Failed to load auto-download preferences:', err);
|
||||
}
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
@@ -178,6 +225,21 @@ export class DownloadClients extends LitElement {
|
||||
.field-row .browse-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em 1em;
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
.format-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -208,6 +270,104 @@ export class DownloadClients extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
</config-section>
|
||||
|
||||
<config-section
|
||||
heading="Auto-download preferences"
|
||||
description="Guardrails on what the pipeline may grab without asking — a manual pick is never restricted by these, only automatic ones."
|
||||
>
|
||||
${this.prefsError
|
||||
? html`<wa-callout variant="danger">${this.prefsError}</wa-callout>`
|
||||
: nothing}
|
||||
|
||||
<div class="form">
|
||||
<div class="field-row">
|
||||
<wa-input
|
||||
label="Minimum size (MB)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No minimum"
|
||||
.value=${this.prefs.minSizeMb ? String(this.prefs.minSizeMb) : ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
minSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
<wa-input
|
||||
label="Maximum size (MB)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No maximum"
|
||||
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
maxSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
<wa-input
|
||||
label="Preferred size (MB)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No preference"
|
||||
.value=${this.prefs.preferredSizeMb
|
||||
? String(this.prefs.preferredSizeMb)
|
||||
: ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
preferredSizeMb:
|
||||
Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="requires">
|
||||
Allowed formats — leave all unchecked to allow any format.
|
||||
</div>
|
||||
<div class="format-options">
|
||||
${AUTO_DOWNLOAD_FORMATS.map(
|
||||
(format) => html`
|
||||
<label class="format-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${(this.prefs.allowedFormats ?? []).includes(
|
||||
format.value,
|
||||
)}
|
||||
@change=${(e: Event) =>
|
||||
this.toggleFormat(
|
||||
format.value,
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)}
|
||||
/>
|
||||
${format.label}
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
${this.prefsSaved
|
||||
? html`<span class="test-result ok">Saved.</span>`
|
||||
: nothing}
|
||||
<wa-button
|
||||
size="small"
|
||||
variant="brand"
|
||||
?disabled=${this.prefsSaving}
|
||||
@click=${this.savePreferences}
|
||||
>
|
||||
${this.prefsSaving
|
||||
? html`<wa-spinner></wa-spinner>`
|
||||
: 'Save preferences'}
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
</config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -554,6 +714,37 @@ export class DownloadClients extends LitElement {
|
||||
this.testing = null;
|
||||
}
|
||||
}
|
||||
|
||||
private toggleFormat(format: string, checked: boolean): void {
|
||||
const current = this.prefs.allowedFormats ?? [];
|
||||
const allowedFormats = checked
|
||||
? [...current, format]
|
||||
: current.filter((f) => f !== format);
|
||||
|
||||
this.prefs = { ...this.prefs, allowedFormats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the guardrails both to disk and to the running download
|
||||
* manager in one action — persistence alone would leave the setting
|
||||
* inert until restart, which is exactly the bug this mirrors away
|
||||
* from (see `config.Library`'s prior persist-without-apply gap).
|
||||
*/
|
||||
private savePreferences = async () => {
|
||||
this.prefsSaving = true;
|
||||
this.prefsError = '';
|
||||
this.prefsSaved = false;
|
||||
|
||||
try {
|
||||
await SetDownloadPreferences(this.prefs);
|
||||
await SetPreferences(this.prefs);
|
||||
this.prefsSaved = true;
|
||||
} catch (err) {
|
||||
this.prefsError = String(err);
|
||||
} finally {
|
||||
this.prefsSaving = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -57,7 +57,7 @@ export class DownloadPicker extends LitElement {
|
||||
private candidates: DownloadCandidate[] = [];
|
||||
|
||||
@state()
|
||||
private requestId = '';
|
||||
private downloadId = '';
|
||||
|
||||
@state()
|
||||
private autoPicked = false;
|
||||
@@ -141,7 +141,7 @@ export class DownloadPicker extends LitElement {
|
||||
expected: this.expected ?? [],
|
||||
} as download.SearchRequest);
|
||||
|
||||
this.requestId = result.requestId;
|
||||
this.downloadId = result.downloadId;
|
||||
this.candidates = result.candidates ?? [];
|
||||
this.autoPicked = result.autoPicked;
|
||||
} catch (err) {
|
||||
@@ -158,7 +158,7 @@ export class DownloadPicker extends LitElement {
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
await downloadStore.pick(this.requestId, event.detail.candidateId);
|
||||
await downloadStore.pick(this.downloadId, event.detail.candidateId);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { downloadStore, stateLabel } from '@store/download-store';
|
||||
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
|
||||
type Tab = 'requests' | 'downloads';
|
||||
|
||||
/**
|
||||
* The downloads page: what music the user has asked for, and what has
|
||||
* actually been attempted.
|
||||
*
|
||||
* These are two different lists on purpose. A Request is durable — "get
|
||||
* this whenever available" — and stays around, retried on a backoff,
|
||||
* until it is satisfied or removed. A Download is one search-and-grab
|
||||
* attempt; it can fail or complete and that is the end of its story. The
|
||||
* Requests tab is the list the durable, not-a-failure-just-because-it's-
|
||||
* still-here content the wanted list used to be; the Downloads tab is the
|
||||
* attempt history nothing rendered before this page existed.
|
||||
*/
|
||||
@customElement('downloads-view')
|
||||
export class DownloadsView extends LitElement {
|
||||
@state() private tab: Tab = 'requests';
|
||||
|
||||
@state() private requests: Request[] = [];
|
||||
|
||||
@state() private downloads: DownloadRecord[] = [];
|
||||
|
||||
@state() private checking = false;
|
||||
|
||||
@state() private lastSummary: RequestSummary | null = null;
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
border-bottom-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 24px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--yj-bg-surface, #181818);
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 12px;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail.error {
|
||||
color: var(--wa-color-danger-fill-loud, #c65f5f);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.unsubscribe = downloadStore.subscribe(() => {
|
||||
this.requests = downloadStore.requests;
|
||||
this.downloads = downloadStore.downloads;
|
||||
});
|
||||
|
||||
void downloadStore.init().then(() => {
|
||||
this.requests = downloadStore.requests;
|
||||
this.downloads = downloadStore.downloads;
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<header>
|
||||
<h1>Downloads</h1>
|
||||
${this.tab === 'requests'
|
||||
? html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
?disabled=${this.checking}
|
||||
@click=${() => void this.checkNow()}
|
||||
>
|
||||
<wa-icon slot="start" name="rotate"></wa-icon>
|
||||
${this.checking ? 'Checking…' : 'Check now'}
|
||||
</wa-button>
|
||||
`
|
||||
: nothing}
|
||||
</header>
|
||||
|
||||
<p class="subtitle">
|
||||
Music you have requested, and the download attempts that
|
||||
have run for it. A request that cannot be found today stays
|
||||
on the list and is looked for again later.
|
||||
</p>
|
||||
|
||||
<div class="tabs">
|
||||
<div
|
||||
class="tab ${this.tab === 'requests' ? 'active' : ''}"
|
||||
@click=${() => (this.tab = 'requests')}
|
||||
>
|
||||
Requests
|
||||
</div>
|
||||
<div
|
||||
class="tab ${this.tab === 'downloads' ? 'active' : ''}"
|
||||
@click=${() => (this.tab = 'downloads')}
|
||||
>
|
||||
Downloads
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.tab === 'requests' ? this.renderRequests() : this.renderDownloads()}
|
||||
`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Requests tab
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private renderRequests() {
|
||||
const subscriptions = this.requests.filter((r) => r.entity === 'artist');
|
||||
const wanted = this.requests.filter(
|
||||
(r) => r.entity !== 'artist' && r.state === 'wanted',
|
||||
);
|
||||
const paused = this.requests.filter((r) => r.state === 'paused');
|
||||
const satisfied = this.requests.filter((r) => r.state === 'satisfied');
|
||||
|
||||
return html`
|
||||
${this.renderSummary()}
|
||||
${satisfied.length > 0
|
||||
? html`
|
||||
<div class="actions">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() =>
|
||||
void downloadStore.clearSatisfiedRequests()}
|
||||
>
|
||||
Clear found
|
||||
</wa-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${this.requests.length === 0 ? this.renderEmptyRequests() : nothing}
|
||||
${this.renderRequestSection(
|
||||
'Following',
|
||||
subscriptions,
|
||||
(r) => this.renderSubscription(r),
|
||||
)}
|
||||
${this.renderRequestSection('Looking for', wanted, (r) => this.renderRequest(r))}
|
||||
${this.renderRequestSection('Paused', paused, (r) => this.renderRequest(r))}
|
||||
${this.renderRequestSection('Found', satisfied, (r) => this.renderRequest(r))}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmptyRequests() {
|
||||
return html`
|
||||
<div class="empty">
|
||||
Nothing requested yet. Use “Want this” on an album or artist
|
||||
to add it here.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSummary() {
|
||||
if (!this.lastSummary) return nothing;
|
||||
|
||||
const s = this.lastSummary;
|
||||
|
||||
const parts = [
|
||||
s.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
|
||||
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
|
||||
s.started > 0 ? `${s.started} downloading` : '',
|
||||
s.attempted > 0 ? `${s.attempted} searched for` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return html`
|
||||
<p class="summary">
|
||||
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRequestSection(
|
||||
title: string,
|
||||
items: Request[],
|
||||
renderer: (request: Request) => unknown,
|
||||
) {
|
||||
if (items.length === 0) return nothing;
|
||||
|
||||
return html`
|
||||
<h2>${title}</h2>
|
||||
${items.map((request) => renderer(request))}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An artist row is a subscription, not a queued download, so it
|
||||
* shows what it covers rather than a retry count — the albums it
|
||||
* produced appear in their own section.
|
||||
*/
|
||||
private renderSubscription(request: Request) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon name="user-group"></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">
|
||||
${request.artist || request.title || request.mbid}
|
||||
</div>
|
||||
<div class="detail">
|
||||
${request.scope === 'all'
|
||||
? 'Whole discography, plus new releases'
|
||||
: 'New releases only'}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge">Following</span>
|
||||
<div class="actions">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() => void this.toggleScope(request)}
|
||||
>
|
||||
${request.scope === 'all' ? 'New only' : 'Everything'}
|
||||
</wa-button>
|
||||
${this.renderRemove(request)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRequest(request: Request) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon
|
||||
name=${request.entity === 'recording' ? 'music' : 'compact-disc'}
|
||||
></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">
|
||||
${request.artist ? `${request.artist} — ` : ''}${request.title ||
|
||||
request.mbid}
|
||||
</div>
|
||||
<div class="detail">${requestDetail(request)}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
${request.state === 'satisfied'
|
||||
? nothing
|
||||
: html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() =>
|
||||
void downloadStore.pauseRequest(
|
||||
request.id,
|
||||
request.state !== 'paused',
|
||||
)}
|
||||
>
|
||||
${request.state === 'paused' ? 'Resume' : 'Pause'}
|
||||
</wa-button>
|
||||
`}
|
||||
${this.renderRemove(request)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRemove(request: Request) {
|
||||
return html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() => void downloadStore.removeRequest(request.id)}
|
||||
>
|
||||
<wa-icon name="xmark"></wa-icon>
|
||||
</wa-button>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Widens or narrows what an artist subscription covers. */
|
||||
private async toggleScope(request: Request): Promise<void> {
|
||||
try {
|
||||
const libraryId =
|
||||
request.libraryId || (await libraryStore.getDefaultLibraryId());
|
||||
if (!libraryId) {
|
||||
console.error(
|
||||
'Could not change what this subscription covers: no library available',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addRequest({
|
||||
mbid: request.mbid,
|
||||
entity: 'artist',
|
||||
libraryId,
|
||||
artist: request.artist,
|
||||
title: request.title,
|
||||
scope: request.scope === 'all' ? 'future' : 'all',
|
||||
secondary: request.secondary,
|
||||
} as never);
|
||||
} catch (err) {
|
||||
console.error('Could not change what this subscription covers:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkNow(): Promise<void> {
|
||||
this.checking = true;
|
||||
|
||||
try {
|
||||
this.lastSummary = await downloadStore.reconcileRequests();
|
||||
} catch (err) {
|
||||
console.error('Could not check the requests list:', err);
|
||||
} finally {
|
||||
this.checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Downloads tab
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private renderDownloads() {
|
||||
if (this.downloads.length === 0) {
|
||||
return html`
|
||||
<div class="empty">
|
||||
No downloads yet. Attempts made by "Download now" or the
|
||||
background reconciler show up here.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return this.downloads.map((view) => this.renderDownload(view));
|
||||
}
|
||||
|
||||
private renderDownload(view: DownloadRecord) {
|
||||
const title = view.artist
|
||||
? `${view.artist}${view.album ? ` — ${view.album}` : ''}`
|
||||
: view.query || view.album || 'Untitled download';
|
||||
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon name="compact-disc"></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">${title}</div>
|
||||
<div class="detail">${this.downloadDetail(view)}</div>
|
||||
${view.error
|
||||
? html`<div class="detail error">${view.error}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<span class="badge">${stateLabel(view.state)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Provider/progress summary for a download's second line. */
|
||||
private downloadDetail(view: DownloadRecord): string {
|
||||
const providers = [
|
||||
...new Set(view.items.map((item) => item.candidate?.origin).filter(Boolean)),
|
||||
];
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (providers.length > 0) parts.push(providers.join(', '));
|
||||
if (view.source) parts.push(view.source);
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : 'No provider info';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of a request row: what is happening, in the user's
|
||||
* terms.
|
||||
*
|
||||
* A request that has been tried and not found is reported as still being
|
||||
* looked for rather than as an error, because that is what it is — the
|
||||
* retry is already scheduled and there is nothing for the user to do.
|
||||
*/
|
||||
function requestDetail(request: Request): string {
|
||||
if (request.state === 'satisfied') return 'In your library';
|
||||
if (request.state === 'paused') return 'Paused';
|
||||
|
||||
if (request.attempts === 0) return 'Not looked for yet';
|
||||
|
||||
const reason = request.lastError ? ` — ${request.lastError}` : '';
|
||||
|
||||
return `Looked for ${request.attempts} time${request.attempts === 1 ? '' : 's'}${reason}`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'downloads-view': DownloadsView;
|
||||
}
|
||||
}
|
||||
@@ -120,11 +120,11 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
/** True once a download client is configured and enabled. */
|
||||
@state() private canDownload = false;
|
||||
|
||||
/** True when this album is already on the wanted list. */
|
||||
@state() private isWanted = false;
|
||||
/** True when this album already has a request. */
|
||||
@state() private isRequested = false;
|
||||
|
||||
/**
|
||||
* Library to attach downloads/wants to. The library-filter UI that
|
||||
* Library to attach downloads/requests to. The library-filter UI that
|
||||
* would normally set libraryStore's selection isn't mounted anywhere
|
||||
* currently, so that selection is always null here — falling back to
|
||||
* `?? 0` would send a library id that doesn't exist and fail the
|
||||
@@ -496,12 +496,12 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
// so this tracks the provider list rather than assuming.
|
||||
this.downloadUnsub = downloadStore.subscribe(() => {
|
||||
this.canDownload = downloadStore.available;
|
||||
this.syncWanted();
|
||||
this.syncRequested();
|
||||
});
|
||||
|
||||
void downloadStore.init().then(() => {
|
||||
this.canDownload = downloadStore.available;
|
||||
this.syncWanted();
|
||||
this.syncRequested();
|
||||
});
|
||||
|
||||
void this.resolveTargetLibraryId();
|
||||
@@ -1537,62 +1537,62 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the album to the wanted list, which is the answer to "look
|
||||
* Adds the album to the requests list, which is the answer to "look
|
||||
* for it, but not right now".
|
||||
*
|
||||
* Unlike the download button this shows whether or not a client is
|
||||
* connected: wanting something is a durable statement about the
|
||||
* connected: requesting something is a durable statement about the
|
||||
* library, and it stays true — and stays queued — until a client
|
||||
* exists to act on it.
|
||||
*/
|
||||
private renderWantAction() {
|
||||
if (!this.releaseGroupMBID) return nothing;
|
||||
|
||||
const want = downloadStore.wantFor(this.releaseGroupMBID);
|
||||
const request = downloadStore.requestFor(this.releaseGroupMBID);
|
||||
|
||||
return html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance=${this.isWanted ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleWanted(want?.id)}
|
||||
appearance=${this.isRequested ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleRequested(request?.id)}
|
||||
>
|
||||
<wa-icon
|
||||
slot="start"
|
||||
name=${this.isWanted ? 'bookmark-check' : 'bookmark'}
|
||||
name=${this.isRequested ? 'bookmark-check' : 'bookmark'}
|
||||
></wa-icon>
|
||||
${this.isWanted ? 'Wanted' : 'Want this'}
|
||||
${this.isRequested ? 'Wanted' : 'Want this'}
|
||||
</wa-button>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Resolves the library to attach downloads/wants to. */
|
||||
/** Resolves the library to attach downloads/requests to. */
|
||||
private async resolveTargetLibraryId(): Promise<void> {
|
||||
this.targetLibraryId = await libraryStore.getDefaultLibraryId();
|
||||
}
|
||||
|
||||
/** Reflects the store's view of whether this album is wanted. */
|
||||
private syncWanted(): void {
|
||||
this.isWanted = this.releaseGroupMBID
|
||||
? downloadStore.isWanted(this.releaseGroupMBID)
|
||||
/** Reflects the store's view of whether this album is requested. */
|
||||
private syncRequested(): void {
|
||||
this.isRequested = this.releaseGroupMBID
|
||||
? downloadStore.isRequested(this.releaseGroupMBID)
|
||||
: false;
|
||||
}
|
||||
|
||||
private async toggleWanted(wantId: number | undefined): Promise<void> {
|
||||
private async toggleRequested(requestId: number | undefined): Promise<void> {
|
||||
if (!this.releaseGroupMBID) return;
|
||||
|
||||
try {
|
||||
if (wantId) {
|
||||
await downloadStore.removeWant(wantId);
|
||||
if (requestId) {
|
||||
await downloadStore.removeRequest(requestId);
|
||||
} else {
|
||||
if (!this.targetLibraryId) {
|
||||
await this.resolveTargetLibraryId();
|
||||
}
|
||||
if (!this.targetLibraryId) {
|
||||
console.error('Could not update the wanted list: no library available');
|
||||
console.error('Could not update the requests list: no library available');
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addWant({
|
||||
await downloadStore.addRequest({
|
||||
mbid: this.releaseGroupMBID,
|
||||
entity: 'release-group',
|
||||
libraryId: this.targetLibraryId,
|
||||
@@ -1600,13 +1600,13 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
title: this.albumName,
|
||||
scope: 'future',
|
||||
secondary: false,
|
||||
} as download.WantRequest);
|
||||
} as download.RequestInput);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Could not update the wanted list:', err);
|
||||
console.error('Could not update the requests list:', err);
|
||||
}
|
||||
|
||||
this.syncWanted();
|
||||
this.syncRequested();
|
||||
}
|
||||
|
||||
private async openPicker(): Promise<void> {
|
||||
|
||||
@@ -846,8 +846,8 @@ export class ExploreArtistDetails extends LitElement {
|
||||
* background discography fetch never signals readiness. */
|
||||
private discogFallbackTimer?: number;
|
||||
|
||||
/** Unsubscribe handle for the wanted list. */
|
||||
private unsubWanted: (() => void) | null = null;
|
||||
/** Unsubscribe handle for the requests list. */
|
||||
private unsubRequests: (() => void) | null = null;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -855,10 +855,10 @@ export class ExploreArtistDetails extends LitElement {
|
||||
void this.loadAllData();
|
||||
}
|
||||
|
||||
// Keep the follow button in step with the wanted list, which a
|
||||
// Keep the follow button in step with the requests list, which a
|
||||
// background reconcile pass can change without this page doing
|
||||
// anything.
|
||||
this.unsubWanted = downloadStore.subscribe(() => this.requestUpdate());
|
||||
this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate());
|
||||
void downloadStore.init().then(() => this.requestUpdate());
|
||||
|
||||
// A background discography fetch (top tracks / top releases for an
|
||||
@@ -897,8 +897,8 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.unsubWanted?.();
|
||||
this.unsubWanted = null;
|
||||
this.unsubRequests?.();
|
||||
this.unsubRequests = null;
|
||||
this.unsubDiscogReady?.();
|
||||
this.unsubSimilarReady?.();
|
||||
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
||||
@@ -1896,50 +1896,50 @@ export class ExploreArtistDetails extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to an artist: their new releases go on the wanted list
|
||||
* as they come out.
|
||||
* Subscribes to an artist: their new releases go on the requests
|
||||
* list as they come out.
|
||||
*
|
||||
* The default is new releases only. Following an artist should not
|
||||
* silently queue forty albums — someone who wants the back
|
||||
* catalogue can widen it from the wanted list, and will not be
|
||||
* catalogue can widen it from the requests list, and will not be
|
||||
* surprised by having done so.
|
||||
*/
|
||||
private renderFollowAction() {
|
||||
if (!this.artistMBID) return nothing;
|
||||
|
||||
const want = downloadStore.wantFor(this.artistMBID);
|
||||
const request = downloadStore.requestFor(this.artistMBID);
|
||||
|
||||
return html`
|
||||
<div class="artist-follow">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance=${want ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleFollow(want?.id)}
|
||||
appearance=${request ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleFollow(request?.id)}
|
||||
>
|
||||
<wa-icon
|
||||
slot="start"
|
||||
name=${want ? 'bookmark-check' : 'bookmark'}
|
||||
name=${request ? 'bookmark-check' : 'bookmark'}
|
||||
></wa-icon>
|
||||
${want ? 'Following' : 'Follow for new releases'}
|
||||
${request ? 'Following' : 'Follow for new releases'}
|
||||
</wa-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async toggleFollow(wantId: number | undefined): Promise<void> {
|
||||
private async toggleFollow(requestId: number | undefined): Promise<void> {
|
||||
if (!this.artistMBID) return;
|
||||
|
||||
try {
|
||||
if (wantId) {
|
||||
await downloadStore.removeWant(wantId);
|
||||
if (requestId) {
|
||||
await downloadStore.removeRequest(requestId);
|
||||
} else {
|
||||
const libraryId = await libraryStore.getDefaultLibraryId();
|
||||
if (!libraryId) {
|
||||
console.error('Could not update the wanted list: no library available');
|
||||
console.error('Could not update the requests list: no library available');
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addWant({
|
||||
await downloadStore.addRequest({
|
||||
mbid: this.artistMBID,
|
||||
entity: 'artist',
|
||||
libraryId,
|
||||
@@ -1950,7 +1950,7 @@ export class ExploreArtistDetails extends LitElement {
|
||||
} as never);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Could not update the wanted list:', err);
|
||||
console.error('Could not update the requests list:', err);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||
|
||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'wanted' | 'autotag' | 'jobs' | 'settings';
|
||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
|
||||
|
||||
interface NavItem {
|
||||
id: View;
|
||||
@@ -149,7 +149,7 @@ export class AppSidebar extends LitElement {
|
||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||
{ id: 'wanted', label: 'Wanted', icon: 'bookmark' },
|
||||
{ id: 'downloads', label: 'Downloads', icon: 'bookmark' },
|
||||
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import type { Want, WantSummary } from '@store/download-store';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
|
||||
/**
|
||||
* The wanted list: music the user has said they want but does not have.
|
||||
*
|
||||
* The list is the durable thing here, not the downloads it produces.
|
||||
* Something unfindable today stays on the list and is retried on a
|
||||
* backoff, so this view is mostly about making the waiting legible —
|
||||
* what is being looked for, when it was last tried, and why it has not
|
||||
* turned up. A row is not a failure just because it is still here.
|
||||
*/
|
||||
@customElement('wanted-view')
|
||||
export class WantedView extends LitElement {
|
||||
@state() private wants: Want[] = [];
|
||||
|
||||
@state() private checking = false;
|
||||
|
||||
@state() private lastSummary: WantSummary | null = null;
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 24px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--yj-bg-surface, #181818);
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 12px;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.unsubscribe = downloadStore.subscribe(() => {
|
||||
this.wants = downloadStore.wants;
|
||||
});
|
||||
|
||||
void downloadStore.init().then(() => {
|
||||
this.wants = downloadStore.wants;
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const subscriptions = this.wants.filter((w) => w.entity === 'artist');
|
||||
const wanted = this.wants.filter(
|
||||
(w) => w.entity !== 'artist' && w.state === 'wanted',
|
||||
);
|
||||
const paused = this.wants.filter((w) => w.state === 'paused');
|
||||
const satisfied = this.wants.filter((w) => w.state === 'satisfied');
|
||||
|
||||
return html`
|
||||
<header>
|
||||
<h1>Wanted</h1>
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
?disabled=${this.checking}
|
||||
@click=${() => void this.checkNow()}
|
||||
>
|
||||
<wa-icon slot="start" name="rotate"></wa-icon>
|
||||
${this.checking ? 'Checking…' : 'Check now'}
|
||||
</wa-button>
|
||||
${satisfied.length > 0
|
||||
? html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() =>
|
||||
void downloadStore.clearSatisfiedWants()}
|
||||
>
|
||||
Clear found
|
||||
</wa-button>
|
||||
`
|
||||
: nothing}
|
||||
</header>
|
||||
|
||||
<p class="subtitle">
|
||||
Music you want but do not have. Anything that cannot be found
|
||||
stays here and is looked for again later.
|
||||
</p>
|
||||
|
||||
${this.renderSummary()}
|
||||
${this.wants.length === 0 ? this.renderEmpty() : nothing}
|
||||
${this.renderSection(
|
||||
'Following',
|
||||
subscriptions,
|
||||
(w) => this.renderSubscription(w),
|
||||
)}
|
||||
${this.renderSection('Looking for', wanted, (w) => this.renderWant(w))}
|
||||
${this.renderSection('Paused', paused, (w) => this.renderWant(w))}
|
||||
${this.renderSection('Found', satisfied, (w) => this.renderWant(w))}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmpty() {
|
||||
return html`
|
||||
<div class="empty">
|
||||
Nothing wanted yet. Use “Want this” on an album or artist to
|
||||
add it here.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSummary() {
|
||||
if (!this.lastSummary) return nothing;
|
||||
|
||||
const s = this.lastSummary;
|
||||
|
||||
const parts = [
|
||||
s.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
|
||||
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
|
||||
s.started > 0 ? `${s.started} downloading` : '',
|
||||
s.attempted > 0 ? `${s.attempted} searched for` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
return html`
|
||||
<p class="summary">
|
||||
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSection(
|
||||
title: string,
|
||||
items: Want[],
|
||||
renderer: (want: Want) => unknown,
|
||||
) {
|
||||
if (items.length === 0) return nothing;
|
||||
|
||||
return html`
|
||||
<h2>${title}</h2>
|
||||
${items.map((want) => renderer(want))}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An artist row is a subscription, not a queued download, so it
|
||||
* shows what it covers rather than a retry count — the albums it
|
||||
* produced appear in their own section.
|
||||
*/
|
||||
private renderSubscription(want: Want) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon name="user-group"></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">${want.artist || want.title || want.mbid}</div>
|
||||
<div class="detail">
|
||||
${want.scope === 'all'
|
||||
? 'Whole discography, plus new releases'
|
||||
: 'New releases only'}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge">Following</span>
|
||||
<div class="actions">
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() => void this.toggleScope(want)}
|
||||
>
|
||||
${want.scope === 'all' ? 'New only' : 'Everything'}
|
||||
</wa-button>
|
||||
${this.renderRemove(want)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWant(want: Want) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<wa-icon
|
||||
name=${want.entity === 'recording' ? 'music' : 'compact-disc'}
|
||||
></wa-icon>
|
||||
<div class="row-main">
|
||||
<div class="title">
|
||||
${want.artist ? `${want.artist} — ` : ''}${want.title ||
|
||||
want.mbid}
|
||||
</div>
|
||||
<div class="detail">${wantDetail(want)}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
${want.state === 'satisfied'
|
||||
? nothing
|
||||
: html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() =>
|
||||
void downloadStore.pauseWant(
|
||||
want.id,
|
||||
want.state !== 'paused',
|
||||
)}
|
||||
>
|
||||
${want.state === 'paused' ? 'Resume' : 'Pause'}
|
||||
</wa-button>
|
||||
`}
|
||||
${this.renderRemove(want)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRemove(want: Want) {
|
||||
return html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${() => void downloadStore.removeWant(want.id)}
|
||||
>
|
||||
<wa-icon name="xmark"></wa-icon>
|
||||
</wa-button>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Widens or narrows what an artist subscription covers. */
|
||||
private async toggleScope(want: Want): Promise<void> {
|
||||
try {
|
||||
const libraryId =
|
||||
want.libraryId || (await libraryStore.getDefaultLibraryId());
|
||||
if (!libraryId) {
|
||||
console.error(
|
||||
'Could not change what this subscription covers: no library available',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addWant({
|
||||
mbid: want.mbid,
|
||||
entity: 'artist',
|
||||
libraryId,
|
||||
artist: want.artist,
|
||||
title: want.title,
|
||||
scope: want.scope === 'all' ? 'future' : 'all',
|
||||
secondary: want.secondary,
|
||||
} as never);
|
||||
} catch (err) {
|
||||
console.error('Could not change what this subscription covers:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkNow(): Promise<void> {
|
||||
this.checking = true;
|
||||
|
||||
try {
|
||||
this.lastSummary = await downloadStore.reconcileWanted();
|
||||
} catch (err) {
|
||||
console.error('Could not check the wanted list:', err);
|
||||
} finally {
|
||||
this.checking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of a want row: what is happening, in the user's terms.
|
||||
*
|
||||
* A want that has been tried and not found is reported as still being
|
||||
* looked for rather than as an error, because that is what it is — the
|
||||
* retry is already scheduled and there is nothing for the user to do.
|
||||
*/
|
||||
function wantDetail(want: Want): string {
|
||||
if (want.state === 'satisfied') return 'In your library';
|
||||
if (want.state === 'paused') return 'Paused';
|
||||
|
||||
if (want.attempts === 0) return 'Not looked for yet';
|
||||
|
||||
const reason = want.lastError ? ` — ${want.lastError}` : '';
|
||||
|
||||
return `Looked for ${want.attempts} time${want.attempts === 1 ? '' : 's'}${reason}`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'wanted-view': WantedView;
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export const Events = {
|
||||
AlbumReleasesReady: "AlbumReleasesReady",
|
||||
DownloadProvidersChanged: "DownloadProvidersChanged",
|
||||
DownloadsChanged: "DownloadsChanged",
|
||||
WantedListChanged: "WantedListChanged",
|
||||
RequestsChanged: "RequestsChanged",
|
||||
} as const;
|
||||
|
||||
export type EventName = (typeof Events)[keyof typeof Events];
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import {
|
||||
AddProvider,
|
||||
AddWant,
|
||||
AddRequest,
|
||||
Cancel,
|
||||
Candidates,
|
||||
ClearFinished,
|
||||
ClearSatisfiedWants,
|
||||
ClearSatisfiedRequests,
|
||||
DeleteProvider,
|
||||
ImportExternalWants,
|
||||
ImportExternalRequests,
|
||||
ListDownloads,
|
||||
ListProviders,
|
||||
ListRequests,
|
||||
ListWants,
|
||||
PauseWant,
|
||||
PauseRequest,
|
||||
Pick,
|
||||
ProviderKinds,
|
||||
ReconcileWanted,
|
||||
RemoveWant,
|
||||
Start,
|
||||
ReconcileRequests,
|
||||
RemoveRequest,
|
||||
StartDownload,
|
||||
TestProvider,
|
||||
UpdateProvider,
|
||||
} from '@go/download/Service';
|
||||
@@ -26,32 +26,32 @@ import { Events } from '../events';
|
||||
export type DownloadCandidate = download.Candidate;
|
||||
export type DownloadProvider = download.Config;
|
||||
export type DownloadDescriptor = download.Descriptor;
|
||||
export type DownloadRequest = download.RequestView;
|
||||
export type DownloadView = download.DownloadView;
|
||||
export type ProviderField = download.Field;
|
||||
export type Want = download.Want;
|
||||
export type WantSummary = download.Summary;
|
||||
export type Request = download.Request;
|
||||
export type RequestSummary = download.Summary;
|
||||
|
||||
/**
|
||||
* What a want's MBID names. Mirrors backend/download.Entity — the
|
||||
* wanted list makes no other type distinction, because an MBID plus
|
||||
* what it names is the whole of a want.
|
||||
* What a request's MBID names. Mirrors backend/download.Entity — the
|
||||
* request list makes no other type distinction, because an MBID plus
|
||||
* what it names is the whole of a request.
|
||||
*/
|
||||
export type WantEntity = 'artist' | 'release-group' | 'release' | 'recording';
|
||||
export type RequestEntity = 'artist' | 'release-group' | 'release' | 'recording';
|
||||
|
||||
/**
|
||||
* Where a want sits. There is deliberately no "failed": an attempt can
|
||||
* fail, a want cannot — something unfindable today is still wanted.
|
||||
* Where a request sits. There is deliberately no "failed": an attempt can
|
||||
* fail, a request cannot — something unfindable today is still requested.
|
||||
*/
|
||||
export type WantState = 'wanted' | 'satisfied' | 'paused';
|
||||
export type RequestState = 'wanted' | 'satisfied' | 'paused';
|
||||
|
||||
/**
|
||||
* How much of an artist's output a subscription covers. 'future' is the
|
||||
* default so subscribing does not silently queue a back catalogue.
|
||||
*/
|
||||
export type WantScope = 'future' | 'all';
|
||||
export type RequestScope = 'future' | 'all';
|
||||
|
||||
/** Lifecycle states a request can be in. Mirrors backend/download.State. */
|
||||
export type DownloadState =
|
||||
/** Lifecycle states a download can be in. Mirrors backend/download.State. */
|
||||
export type DownloadLifecycleState =
|
||||
| 'searching'
|
||||
| 'found'
|
||||
| 'queued'
|
||||
@@ -71,12 +71,12 @@ const TERMINAL_STATES: ReadonlySet<string> = new Set([
|
||||
'failed',
|
||||
]);
|
||||
|
||||
export function isRequestTerminal(request: DownloadRequest): boolean {
|
||||
return TERMINAL_STATES.has(request.state);
|
||||
export function isDownloadTerminal(view: DownloadView): boolean {
|
||||
return TERMINAL_STATES.has(view.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for a request state. Kept here rather than in the
|
||||
* Human-readable label for a download state. Kept here rather than in the
|
||||
* components so the downloads list and the picker never disagree about
|
||||
* what a state is called.
|
||||
*/
|
||||
@@ -171,7 +171,7 @@ export function formatBytes(bytes: number): string {
|
||||
* Per-transfer progress deliberately does not flow through here — that
|
||||
* lives in the jobs registry, which already coalesces high-frequency
|
||||
* updates into one event. This store handles the coarse changes: which
|
||||
* providers exist, which requests exist, and what the user is being
|
||||
* providers exist, which downloads exist, and what the user is being
|
||||
* asked to choose between.
|
||||
*/
|
||||
class DownloadStore {
|
||||
@@ -179,9 +179,9 @@ class DownloadStore {
|
||||
|
||||
private descriptorsValue: DownloadDescriptor[] = [];
|
||||
|
||||
private requestsValue: DownloadRequest[] = [];
|
||||
private downloadsValue: DownloadView[] = [];
|
||||
|
||||
private wantsValue: Want[] = [];
|
||||
private requestsValue: Request[] = [];
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
|
||||
@@ -195,21 +195,21 @@ class DownloadStore {
|
||||
});
|
||||
|
||||
EventsOn(Events.DownloadsChanged, () => {
|
||||
void this.refreshRequests();
|
||||
void this.refreshDownloads();
|
||||
});
|
||||
|
||||
// The wanted list changes on its own — a background reconcile
|
||||
// The request list changes on its own — a background reconcile
|
||||
// pass expands an artist, retires something the library gained,
|
||||
// or starts a download nobody asked for just now. So it is
|
||||
// event-driven rather than fetched once on mount.
|
||||
EventsOn(Events.WantedListChanged, () => {
|
||||
void this.refreshWants();
|
||||
EventsOn(Events.RequestsChanged, () => {
|
||||
void this.refreshRequests();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads providers and requests once. Safe to call from every
|
||||
* component's connectedCallback — subsequent calls are no-ops.
|
||||
* Loads providers, downloads and requests once. Safe to call from
|
||||
* every component's connectedCallback — subsequent calls are no-ops.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
@@ -219,8 +219,8 @@ class DownloadStore {
|
||||
await Promise.all([
|
||||
this.refreshDescriptors(),
|
||||
this.refreshProviders(),
|
||||
this.refreshDownloads(),
|
||||
this.refreshRequests(),
|
||||
this.refreshWants(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -238,12 +238,12 @@ class DownloadStore {
|
||||
return this.descriptorsValue;
|
||||
}
|
||||
|
||||
get requests(): DownloadRequest[] {
|
||||
return this.requestsValue;
|
||||
get downloads(): DownloadView[] {
|
||||
return this.downloadsValue;
|
||||
}
|
||||
|
||||
get activeRequests(): DownloadRequest[] {
|
||||
return this.requestsValue.filter((r) => !isRequestTerminal(r));
|
||||
get activeDownloads(): DownloadView[] {
|
||||
return this.downloadsValue.filter((d) => !isDownloadTerminal(d));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,9 +294,9 @@ class DownloadStore {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshRequests(): Promise<void> {
|
||||
async refreshDownloads(): Promise<void> {
|
||||
try {
|
||||
this.requestsValue = (await ListRequests(50)) ?? [];
|
||||
this.downloadsValue = (await ListDownloads(50)) ?? [];
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('Failed to load downloads:', err);
|
||||
@@ -346,7 +346,7 @@ class DownloadStore {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Requests
|
||||
// Downloads (one search+grab attempt)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -355,94 +355,102 @@ class DownloadStore {
|
||||
* the picker or just show progress.
|
||||
*/
|
||||
async start(request: download.SearchRequest): Promise<download.StartResult> {
|
||||
const result = await Start(request);
|
||||
const result = await StartDownload(request);
|
||||
|
||||
await this.refreshRequests();
|
||||
await this.refreshDownloads();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async pick(requestId: string, candidateId: string): Promise<void> {
|
||||
await Pick(requestId, candidateId);
|
||||
await this.refreshRequests();
|
||||
async pick(downloadId: string, candidateId: string): Promise<void> {
|
||||
await Pick(downloadId, candidateId);
|
||||
await this.refreshDownloads();
|
||||
}
|
||||
|
||||
async cancel(requestId: string): Promise<void> {
|
||||
await Cancel(requestId);
|
||||
await this.refreshRequests();
|
||||
async cancel(downloadId: string): Promise<void> {
|
||||
await Cancel(downloadId);
|
||||
await this.refreshDownloads();
|
||||
}
|
||||
|
||||
async candidates(requestId: string): Promise<DownloadCandidate[]> {
|
||||
return (await Candidates(requestId)) ?? [];
|
||||
async candidates(downloadId: string): Promise<DownloadCandidate[]> {
|
||||
return (await Candidates(downloadId)) ?? [];
|
||||
}
|
||||
|
||||
async clearFinished(): Promise<void> {
|
||||
await ClearFinished();
|
||||
await this.refreshRequests();
|
||||
await this.refreshDownloads();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Wanted list
|
||||
// Requests (durable "I asked for this")
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
get wants(): Want[] {
|
||||
return this.wantsValue;
|
||||
get requests(): Request[] {
|
||||
return this.requestsValue;
|
||||
}
|
||||
|
||||
/** Wants still being looked for. */
|
||||
get activeWants(): Want[] {
|
||||
return this.wantsValue.filter((w) => w.state === 'wanted');
|
||||
/** Requests still being looked for. */
|
||||
get activeRequests(): Request[] {
|
||||
return this.requestsValue.filter((r) => r.state === 'wanted');
|
||||
}
|
||||
|
||||
/** Artist subscriptions, which expand rather than download. */
|
||||
get subscriptions(): Want[] {
|
||||
return this.wantsValue.filter((w) => w.entity === 'artist');
|
||||
get subscriptions(): Request[] {
|
||||
return this.requestsValue.filter((r) => r.entity === 'artist');
|
||||
}
|
||||
|
||||
async refreshWants(): Promise<void> {
|
||||
async refreshRequests(): Promise<void> {
|
||||
try {
|
||||
this.wantsValue = (await ListWants()) ?? [];
|
||||
this.requestsValue = (await ListRequests()) ?? [];
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('Failed to load the wanted list:', err);
|
||||
console.error('Failed to load the requests list:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when this MBID is already on the list. */
|
||||
isWanted(mbid: string): boolean {
|
||||
/**
|
||||
* True when this MBID is already requested.
|
||||
*
|
||||
* Checked against the locally cached request list rather than the
|
||||
* `IsRequested` RPC: the list is already kept current via
|
||||
* `RequestsChanged`, and a local lookup keeps this usable
|
||||
* synchronously from render — the same shape callers relied on
|
||||
* before the rename.
|
||||
*/
|
||||
isRequested(mbid: string): boolean {
|
||||
const needle = mbid.trim().toLowerCase();
|
||||
|
||||
return this.wantsValue.some((w) => w.mbid === needle);
|
||||
return this.requestsValue.some((r) => r.mbid === needle);
|
||||
}
|
||||
|
||||
/** The want for an MBID, if it is on the list. */
|
||||
wantFor(mbid: string): Want | undefined {
|
||||
/** The request for an MBID, if one exists. */
|
||||
requestFor(mbid: string): Request | undefined {
|
||||
const needle = mbid.trim().toLowerCase();
|
||||
|
||||
return this.wantsValue.find((w) => w.mbid === needle);
|
||||
return this.requestsValue.find((r) => r.mbid === needle);
|
||||
}
|
||||
|
||||
async addWant(want: download.WantRequest): Promise<number> {
|
||||
const id = await AddWant(want);
|
||||
async addRequest(request: download.RequestInput): Promise<number> {
|
||||
const id = await AddRequest(request);
|
||||
|
||||
await this.refreshWants();
|
||||
await this.refreshRequests();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async removeWant(id: number): Promise<void> {
|
||||
await RemoveWant(id);
|
||||
await this.refreshWants();
|
||||
async removeRequest(id: number): Promise<void> {
|
||||
await RemoveRequest(id);
|
||||
await this.refreshRequests();
|
||||
}
|
||||
|
||||
async pauseWant(id: number, paused: boolean): Promise<void> {
|
||||
await PauseWant(id, paused);
|
||||
await this.refreshWants();
|
||||
async pauseRequest(id: number, paused: boolean): Promise<void> {
|
||||
await PauseRequest(id, paused);
|
||||
await this.refreshRequests();
|
||||
}
|
||||
|
||||
async clearSatisfiedWants(): Promise<void> {
|
||||
await ClearSatisfiedWants();
|
||||
await this.refreshWants();
|
||||
async clearSatisfiedRequests(): Promise<void> {
|
||||
await ClearSatisfiedRequests();
|
||||
await this.refreshRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,22 +458,22 @@ class DownloadStore {
|
||||
* with what the pass did so the UI can say something concrete
|
||||
* rather than just stopping its spinner.
|
||||
*/
|
||||
async reconcileWanted(): Promise<WantSummary> {
|
||||
const summary = await ReconcileWanted();
|
||||
async reconcileRequests(): Promise<RequestSummary> {
|
||||
const summary = await ReconcileRequests();
|
||||
|
||||
await Promise.all([this.refreshWants(), this.refreshRequests()]);
|
||||
await Promise.all([this.refreshRequests(), this.refreshDownloads()]);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Adopts a provider's own list, e.g. Lidarr's monitored artists. */
|
||||
async importExternalWants(
|
||||
async importExternalRequests(
|
||||
providerId: number,
|
||||
libraryId: number,
|
||||
): Promise<number> {
|
||||
const count = await ImportExternalWants(providerId, libraryId);
|
||||
const count = await ImportExternalRequests(providerId, libraryId);
|
||||
|
||||
await this.refreshWants();
|
||||
await this.refreshRequests();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
+5
@@ -1,8 +1,11 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {download} from '../models';
|
||||
import {tracklist} from '../models';
|
||||
import {context} from '../models';
|
||||
|
||||
export function GetDownloadPreferences():Promise<download.AutoDownloadPrefs>;
|
||||
|
||||
export function GetFavoritesIconStyle():Promise<string>;
|
||||
|
||||
export function GetFavoritesPlaylistID():Promise<number>;
|
||||
@@ -29,6 +32,8 @@ export function Save():Promise<void>;
|
||||
|
||||
export function SetContext(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function SetDownloadPreferences(arg1:download.AutoDownloadPrefs):Promise<void>;
|
||||
|
||||
export function SetFavoritesIconStyle(arg1:string):Promise<void>;
|
||||
|
||||
export function SetFavoritesPlaylistID(arg1:number):Promise<void>;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function GetDownloadPreferences() {
|
||||
return window['go']['config']['Config']['GetDownloadPreferences']();
|
||||
}
|
||||
|
||||
export function GetFavoritesIconStyle() {
|
||||
return window['go']['config']['Config']['GetFavoritesIconStyle']();
|
||||
}
|
||||
@@ -54,6 +58,10 @@ export function SetContext(arg1) {
|
||||
return window['go']['config']['Config']['SetContext'](arg1);
|
||||
}
|
||||
|
||||
export function SetDownloadPreferences(arg1) {
|
||||
return window['go']['config']['Config']['SetDownloadPreferences'](arg1);
|
||||
}
|
||||
|
||||
export function SetFavoritesIconStyle(arg1) {
|
||||
return window['go']['config']['Config']['SetFavoritesIconStyle'](arg1);
|
||||
}
|
||||
|
||||
+13
-11
@@ -5,7 +5,7 @@ import {context} from '../models';
|
||||
|
||||
export function AddProvider(arg1:string,arg2:string,arg3:Record<string, string>):Promise<number>;
|
||||
|
||||
export function AddWant(arg1:download.WantRequest):Promise<number>;
|
||||
export function AddRequest(arg1:download.RequestInput):Promise<number>;
|
||||
|
||||
export function Cancel(arg1:string):Promise<void>;
|
||||
|
||||
@@ -13,35 +13,37 @@ export function Candidates(arg1:string):Promise<Array<download.Candidate>>;
|
||||
|
||||
export function ClearFinished():Promise<void>;
|
||||
|
||||
export function ClearSatisfiedWants():Promise<void>;
|
||||
export function ClearSatisfiedRequests():Promise<void>;
|
||||
|
||||
export function DeleteProvider(arg1:number):Promise<void>;
|
||||
|
||||
export function ImportExternalWants(arg1:number,arg2:number):Promise<number>;
|
||||
export function ImportExternalRequests(arg1:number,arg2:number):Promise<number>;
|
||||
|
||||
export function IsWanted(arg1:string,arg2:number):Promise<boolean>;
|
||||
export function IsRequested(arg1:string,arg2:number):Promise<boolean>;
|
||||
|
||||
export function ListDownloads(arg1:number):Promise<Array<download.DownloadView>>;
|
||||
|
||||
export function ListProviders():Promise<Array<download.Config>>;
|
||||
|
||||
export function ListRequests(arg1:number):Promise<Array<download.RequestView>>;
|
||||
export function ListRequests():Promise<Array<download.Request>>;
|
||||
|
||||
export function ListWants():Promise<Array<download.Want>>;
|
||||
|
||||
export function PauseWant(arg1:number,arg2:boolean):Promise<void>;
|
||||
export function PauseRequest(arg1:number,arg2:boolean):Promise<void>;
|
||||
|
||||
export function Pick(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function ProviderKinds():Promise<Array<download.Descriptor>>;
|
||||
|
||||
export function ReconcileWanted():Promise<download.Summary>;
|
||||
export function ReconcileRequests():Promise<download.Summary>;
|
||||
|
||||
export function RemoveWant(arg1:number):Promise<void>;
|
||||
export function RemoveRequest(arg1:number):Promise<void>;
|
||||
|
||||
export function SetContext(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function SetPreferences(arg1:download.AutoDownloadPrefs):Promise<void>;
|
||||
|
||||
export function SetReconciler(arg1:download.Reconciler):Promise<void>;
|
||||
|
||||
export function Start(arg1:download.SearchRequest):Promise<download.StartResult>;
|
||||
export function StartDownload(arg1:download.SearchRequest):Promise<download.StartResult>;
|
||||
|
||||
export function TestProvider(arg1:number):Promise<void>;
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ export function AddProvider(arg1, arg2, arg3) {
|
||||
return window['go']['download']['Service']['AddProvider'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function AddWant(arg1) {
|
||||
return window['go']['download']['Service']['AddWant'](arg1);
|
||||
export function AddRequest(arg1) {
|
||||
return window['go']['download']['Service']['AddRequest'](arg1);
|
||||
}
|
||||
|
||||
export function Cancel(arg1) {
|
||||
@@ -22,36 +22,36 @@ export function ClearFinished() {
|
||||
return window['go']['download']['Service']['ClearFinished']();
|
||||
}
|
||||
|
||||
export function ClearSatisfiedWants() {
|
||||
return window['go']['download']['Service']['ClearSatisfiedWants']();
|
||||
export function ClearSatisfiedRequests() {
|
||||
return window['go']['download']['Service']['ClearSatisfiedRequests']();
|
||||
}
|
||||
|
||||
export function DeleteProvider(arg1) {
|
||||
return window['go']['download']['Service']['DeleteProvider'](arg1);
|
||||
}
|
||||
|
||||
export function ImportExternalWants(arg1, arg2) {
|
||||
return window['go']['download']['Service']['ImportExternalWants'](arg1, arg2);
|
||||
export function ImportExternalRequests(arg1, arg2) {
|
||||
return window['go']['download']['Service']['ImportExternalRequests'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function IsWanted(arg1, arg2) {
|
||||
return window['go']['download']['Service']['IsWanted'](arg1, arg2);
|
||||
export function IsRequested(arg1, arg2) {
|
||||
return window['go']['download']['Service']['IsRequested'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ListDownloads(arg1) {
|
||||
return window['go']['download']['Service']['ListDownloads'](arg1);
|
||||
}
|
||||
|
||||
export function ListProviders() {
|
||||
return window['go']['download']['Service']['ListProviders']();
|
||||
}
|
||||
|
||||
export function ListRequests(arg1) {
|
||||
return window['go']['download']['Service']['ListRequests'](arg1);
|
||||
export function ListRequests() {
|
||||
return window['go']['download']['Service']['ListRequests']();
|
||||
}
|
||||
|
||||
export function ListWants() {
|
||||
return window['go']['download']['Service']['ListWants']();
|
||||
}
|
||||
|
||||
export function PauseWant(arg1, arg2) {
|
||||
return window['go']['download']['Service']['PauseWant'](arg1, arg2);
|
||||
export function PauseRequest(arg1, arg2) {
|
||||
return window['go']['download']['Service']['PauseRequest'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function Pick(arg1, arg2) {
|
||||
@@ -62,24 +62,28 @@ export function ProviderKinds() {
|
||||
return window['go']['download']['Service']['ProviderKinds']();
|
||||
}
|
||||
|
||||
export function ReconcileWanted() {
|
||||
return window['go']['download']['Service']['ReconcileWanted']();
|
||||
export function ReconcileRequests() {
|
||||
return window['go']['download']['Service']['ReconcileRequests']();
|
||||
}
|
||||
|
||||
export function RemoveWant(arg1) {
|
||||
return window['go']['download']['Service']['RemoveWant'](arg1);
|
||||
export function RemoveRequest(arg1) {
|
||||
return window['go']['download']['Service']['RemoveRequest'](arg1);
|
||||
}
|
||||
|
||||
export function SetContext(arg1) {
|
||||
return window['go']['download']['Service']['SetContext'](arg1);
|
||||
}
|
||||
|
||||
export function SetPreferences(arg1) {
|
||||
return window['go']['download']['Service']['SetPreferences'](arg1);
|
||||
}
|
||||
|
||||
export function SetReconciler(arg1) {
|
||||
return window['go']['download']['Service']['SetReconciler'](arg1);
|
||||
}
|
||||
|
||||
export function Start(arg1) {
|
||||
return window['go']['download']['Service']['Start'](arg1);
|
||||
export function StartDownload(arg1) {
|
||||
return window['go']['download']['Service']['StartDownload'](arg1);
|
||||
}
|
||||
|
||||
export function TestProvider(arg1) {
|
||||
|
||||
+146
-125
@@ -297,12 +297,31 @@ export namespace autotagservice {
|
||||
|
||||
export namespace download {
|
||||
|
||||
export class AutoDownloadPrefs {
|
||||
minSizeMb: number;
|
||||
maxSizeMb: number;
|
||||
preferredSizeMb: number;
|
||||
allowedFormats: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AutoDownloadPrefs(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.minSizeMb = source["minSizeMb"];
|
||||
this.maxSizeMb = source["maxSizeMb"];
|
||||
this.preferredSizeMb = source["preferredSizeMb"];
|
||||
this.allowedFormats = source["allowedFormats"];
|
||||
}
|
||||
}
|
||||
export class QualityScore {
|
||||
overall: number;
|
||||
formatRank: number;
|
||||
bitrate: number;
|
||||
health: number;
|
||||
priority: number;
|
||||
sizeFit: number;
|
||||
mixed: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -316,6 +335,7 @@ export namespace download {
|
||||
this.bitrate = source["bitrate"];
|
||||
this.health = source["health"];
|
||||
this.priority = source["priority"];
|
||||
this.sizeFit = source["sizeFit"];
|
||||
this.mixed = source["mixed"];
|
||||
}
|
||||
}
|
||||
@@ -534,30 +554,9 @@ export namespace download {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ExpectedTrack {
|
||||
position: number;
|
||||
discNumber: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
lengthMillis: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExpectedTrack(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.position = source["position"];
|
||||
this.discNumber = source["discNumber"];
|
||||
this.title = source["title"];
|
||||
this.artist = source["artist"];
|
||||
this.lengthMillis = source["lengthMillis"];
|
||||
}
|
||||
}
|
||||
|
||||
export class Item {
|
||||
export class DownloadItem {
|
||||
id: string;
|
||||
requestId: string;
|
||||
downloadId: string;
|
||||
providerId: number;
|
||||
transportId?: number;
|
||||
externalId?: string;
|
||||
@@ -570,13 +569,13 @@ export namespace download {
|
||||
updatedAt: time.Time;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Item(source);
|
||||
return new DownloadItem(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.requestId = source["requestId"];
|
||||
this.downloadId = source["downloadId"];
|
||||
this.providerId = source["providerId"];
|
||||
this.transportId = source["transportId"];
|
||||
this.externalId = source["externalId"];
|
||||
@@ -607,26 +606,32 @@ export namespace download {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class Reconciler {
|
||||
|
||||
export class ExpectedTrack {
|
||||
position: number;
|
||||
discNumber: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
lengthMillis: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Reconciler(source);
|
||||
return new ExpectedTrack(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
|
||||
this.position = source["position"];
|
||||
this.discNumber = source["discNumber"];
|
||||
this.title = source["title"];
|
||||
this.artist = source["artist"];
|
||||
this.lengthMillis = source["lengthMillis"];
|
||||
}
|
||||
}
|
||||
export class RequestView {
|
||||
export class DownloadView {
|
||||
id: string;
|
||||
releaseMbid?: string;
|
||||
releaseGroupMbid?: string;
|
||||
recordingMbid?: string;
|
||||
wantId?: number;
|
||||
requestId?: number;
|
||||
source?: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
@@ -636,10 +641,10 @@ export namespace download {
|
||||
createdAt: time.Time;
|
||||
state: string;
|
||||
error?: string;
|
||||
items: Item[];
|
||||
items: DownloadItem[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RequestView(source);
|
||||
return new DownloadView(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
@@ -648,7 +653,7 @@ export namespace download {
|
||||
this.releaseMbid = source["releaseMbid"];
|
||||
this.releaseGroupMbid = source["releaseGroupMbid"];
|
||||
this.recordingMbid = source["recordingMbid"];
|
||||
this.wantId = source["wantId"];
|
||||
this.requestId = source["requestId"];
|
||||
this.source = source["source"];
|
||||
this.artist = source["artist"];
|
||||
this.album = source["album"];
|
||||
@@ -658,7 +663,7 @@ export namespace download {
|
||||
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
||||
this.state = source["state"];
|
||||
this.error = source["error"];
|
||||
this.items = this.convertValues(source["items"], Item);
|
||||
this.items = this.convertValues(source["items"], DownloadItem);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -679,6 +684,108 @@ export namespace download {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export class Reconciler {
|
||||
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Reconciler(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
|
||||
}
|
||||
}
|
||||
export class Request {
|
||||
id: number;
|
||||
mbid: string;
|
||||
entity: string;
|
||||
libraryId: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
scope: string;
|
||||
secondary: boolean;
|
||||
state: string;
|
||||
parentId?: number;
|
||||
attempts: number;
|
||||
lastError?: string;
|
||||
lastTriedAt?: time.Time;
|
||||
nextTryAt?: time.Time;
|
||||
externalIds?: Record<string, string>;
|
||||
createdAt: time.Time;
|
||||
updatedAt: time.Time;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Request(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.mbid = source["mbid"];
|
||||
this.entity = source["entity"];
|
||||
this.libraryId = source["libraryId"];
|
||||
this.artist = source["artist"];
|
||||
this.title = source["title"];
|
||||
this.scope = source["scope"];
|
||||
this.secondary = source["secondary"];
|
||||
this.state = source["state"];
|
||||
this.parentId = source["parentId"];
|
||||
this.attempts = source["attempts"];
|
||||
this.lastError = source["lastError"];
|
||||
this.lastTriedAt = this.convertValues(source["lastTriedAt"], time.Time);
|
||||
this.nextTryAt = this.convertValues(source["nextTryAt"], time.Time);
|
||||
this.externalIds = source["externalIds"];
|
||||
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
||||
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class RequestInput {
|
||||
mbid: string;
|
||||
entity: string;
|
||||
libraryId: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
scope: string;
|
||||
secondary: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RequestInput(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.mbid = source["mbid"];
|
||||
this.entity = source["entity"];
|
||||
this.libraryId = source["libraryId"];
|
||||
this.artist = source["artist"];
|
||||
this.title = source["title"];
|
||||
this.scope = source["scope"];
|
||||
this.secondary = source["secondary"];
|
||||
}
|
||||
}
|
||||
export class SearchRequest {
|
||||
libraryId: number;
|
||||
releaseMbid: string;
|
||||
@@ -722,7 +829,7 @@ export namespace download {
|
||||
}
|
||||
}
|
||||
export class StartResult {
|
||||
requestId: string;
|
||||
downloadId: string;
|
||||
candidates: Candidate[];
|
||||
autoPicked: boolean;
|
||||
|
||||
@@ -732,7 +839,7 @@ export namespace download {
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.requestId = source["requestId"];
|
||||
this.downloadId = source["downloadId"];
|
||||
this.candidates = this.convertValues(source["candidates"], Candidate);
|
||||
this.autoPicked = source["autoPicked"];
|
||||
}
|
||||
@@ -775,92 +882,6 @@ export namespace download {
|
||||
this.synced = source["synced"];
|
||||
}
|
||||
}
|
||||
export class Want {
|
||||
id: number;
|
||||
mbid: string;
|
||||
entity: string;
|
||||
libraryId: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
scope: string;
|
||||
secondary: boolean;
|
||||
state: string;
|
||||
parentId?: number;
|
||||
attempts: number;
|
||||
lastError?: string;
|
||||
lastTriedAt?: time.Time;
|
||||
nextTryAt?: time.Time;
|
||||
externalIds?: Record<string, string>;
|
||||
createdAt: time.Time;
|
||||
updatedAt: time.Time;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Want(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.mbid = source["mbid"];
|
||||
this.entity = source["entity"];
|
||||
this.libraryId = source["libraryId"];
|
||||
this.artist = source["artist"];
|
||||
this.title = source["title"];
|
||||
this.scope = source["scope"];
|
||||
this.secondary = source["secondary"];
|
||||
this.state = source["state"];
|
||||
this.parentId = source["parentId"];
|
||||
this.attempts = source["attempts"];
|
||||
this.lastError = source["lastError"];
|
||||
this.lastTriedAt = this.convertValues(source["lastTriedAt"], time.Time);
|
||||
this.nextTryAt = this.convertValues(source["nextTryAt"], time.Time);
|
||||
this.externalIds = source["externalIds"];
|
||||
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
||||
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class WantRequest {
|
||||
mbid: string;
|
||||
entity: string;
|
||||
libraryId: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
scope: string;
|
||||
secondary: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new WantRequest(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.mbid = source["mbid"];
|
||||
this.entity = source["entity"];
|
||||
this.libraryId = source["libraryId"];
|
||||
this.artist = source["artist"];
|
||||
this.title = source["title"];
|
||||
this.scope = source["scope"];
|
||||
this.secondary = source["secondary"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user