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,
|
PathTemplate: cfg.PathTemplate,
|
||||||
})
|
})
|
||||||
yj.downloads.SetMaxConcurrent(cfg.MaxConcurrent)
|
yj.downloads.SetMaxConcurrent(cfg.MaxConcurrent)
|
||||||
|
yj.downloads.SetPreferences(cfg.AutoDownloadPrefs())
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := yj.downloads.Reload(ctx); err != nil {
|
if err := yj.downloads.Reload(ctx); err != nil {
|
||||||
@@ -289,7 +290,7 @@ func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
|
|||||||
yj.wanted.SetInterval(cfg.WantedInterval())
|
yj.wanted.SetInterval(cfg.WantedInterval())
|
||||||
yj.wanted.SetBatch(cfg.WantedBatch)
|
yj.wanted.SetBatch(cfg.WantedBatch)
|
||||||
yj.wanted.SetOnChange(func() {
|
yj.wanted.SetOnChange(func() {
|
||||||
wailsruntime.EventsEmit(ctx, events.WantedListChanged)
|
wailsruntime.EventsEmit(ctx, events.RequestsChanged)
|
||||||
})
|
})
|
||||||
yj.wanted.Start(ctx)
|
yj.wanted.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,6 +364,50 @@ func (c *Config) SetScanConcurrency(mode string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDownloadPreferences returns the configured auto-download
|
||||||
|
// guardrails.
|
||||||
|
func (c *Config) GetDownloadPreferences() download.AutoDownloadPrefs {
|
||||||
|
if c.Downloads == nil {
|
||||||
|
return download.AutoDownloadPrefs{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Downloads.AutoDownloadPrefs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDownloadPreferences saves new auto-download guardrails. This only
|
||||||
|
// persists them; the download package cannot depend on config (config
|
||||||
|
// already depends on download for UserConfig), so making the change
|
||||||
|
// live without a restart is the caller's job — the frontend settings
|
||||||
|
// save calls this and download.Service.SetPreferences in the same
|
||||||
|
// action, and app.go's initDownloadRuntime applies the saved value to
|
||||||
|
// the running Manager at startup.
|
||||||
|
func (c *Config) SetDownloadPreferences(prefs download.AutoDownloadPrefs) error {
|
||||||
|
if c.Downloads == nil {
|
||||||
|
c.Downloads = &download.UserConfig{}
|
||||||
|
c.Downloads.ApplyDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
formats := make([]string, 0, len(prefs.AllowedFormats))
|
||||||
|
for _, f := range prefs.AllowedFormats {
|
||||||
|
formats = append(formats, string(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Downloads.MinFileSizeMB = prefs.MinSizeMB
|
||||||
|
c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB
|
||||||
|
c.Downloads.PreferredFileSizeMB = prefs.PreferredSizeMB
|
||||||
|
c.Downloads.AllowedFormats = formats
|
||||||
|
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not save config: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("download auto-pick preferences updated")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetThemeAccentColor returns the configured accent colour.
|
// GetThemeAccentColor returns the configured accent colour.
|
||||||
func (c *Config) GetThemeAccentColor() string {
|
func (c *Config) GetThemeAccentColor() string {
|
||||||
if c.Theme == nil {
|
if c.Theme == nil {
|
||||||
|
|||||||
@@ -306,6 +306,14 @@ func applySchema(ctx context.Context, db *sql.DB) error {
|
|||||||
return fmt.Errorf("could not apply migrations: %w", err)
|
return fmt.Errorf("could not apply migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The download subsystem's Want/Request rename reuses table names
|
||||||
|
// (download_requests names a different table before and after), so
|
||||||
|
// it cannot be a plain sql/migrations file the way an ADD COLUMN
|
||||||
|
// migration can; see download_rename_migration.go for why.
|
||||||
|
if err := migrateDownloadRename(ctx, db); err != nil {
|
||||||
|
return fmt.Errorf("could not migrate download rename: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateDownloadRename performs the download subsystem's table rename
|
||||||
|
// for existing databases that still carry the old table names: the
|
||||||
|
// durable "I asked for this" record moved from download_wants to
|
||||||
|
// download_requests, and the one-shot search-and-grab attempt moved
|
||||||
|
// from download_requests to download_downloads (see CLAUDE.md and
|
||||||
|
// .planning/NOTES.md for the full Want->Request / Request->Download
|
||||||
|
// rename).
|
||||||
|
//
|
||||||
|
// This cannot be a plain sql/migrations file the way an ADD COLUMN
|
||||||
|
// migration is. That pattern's tolerance for "duplicate column name"
|
||||||
|
// works because a fresh database's sql/schemas pass already produces
|
||||||
|
// the identical target shape under the identical table name, so
|
||||||
|
// replaying the ALTER TABLE against it is a safe no-op. Here the name
|
||||||
|
// "download_requests" is reused for a different table before and after
|
||||||
|
// the rename, so a fresh database's schema pass creates a real, empty,
|
||||||
|
// correctly-shaped download_downloads AND a real, empty,
|
||||||
|
// correctly-shaped (new) download_requests before this ever runs.
|
||||||
|
// Blindly replaying "ALTER TABLE download_requests RENAME TO
|
||||||
|
// download_downloads" against that fresh database would rename the new,
|
||||||
|
// empty Request table into Download's place, destroying the fresh
|
||||||
|
// install rather than no-opping. Gating on whether the OLD
|
||||||
|
// download_wants table still exists — a name nothing creates or
|
||||||
|
// references once this has run — is what tells an old database and a
|
||||||
|
// fresh (or already migrated) one apart without executing anything
|
||||||
|
// destructive on the fresh path.
|
||||||
|
func migrateDownloadRename(ctx context.Context, db *sql.DB) error {
|
||||||
|
var name string
|
||||||
|
|
||||||
|
err := db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
||||||
|
).Scan(&name)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
// Nothing to migrate: either a fresh install (sql/schemas
|
||||||
|
// already produced the target shape) or a database this has
|
||||||
|
// already run against.
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("check for download_wants table: %w", err)
|
||||||
|
default:
|
||||||
|
if err := runDownloadRename(ctx, db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureDownloadIndexes(ctx, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDownloadRename performs the actual rename dance against a
|
||||||
|
// database confirmed to still have the old download_wants table.
|
||||||
|
func runDownloadRename(ctx context.Context, db *sql.DB) error {
|
||||||
|
stmts := []string{
|
||||||
|
// The schema pass already created an empty, correctly-shaped
|
||||||
|
// download_downloads placeholder under this name (it never
|
||||||
|
// existed under the old naming), which would otherwise collide
|
||||||
|
// with the rename below.
|
||||||
|
`DROP TABLE IF EXISTS download_downloads`,
|
||||||
|
|
||||||
|
// 1. Free the "download_requests" name: the old one-shot
|
||||||
|
// attempt table becomes download_downloads.
|
||||||
|
`ALTER TABLE download_requests RENAME TO download_downloads`,
|
||||||
|
`ALTER TABLE download_downloads RENAME COLUMN want_id TO request_id`,
|
||||||
|
|
||||||
|
// 2. Claim the now-free "download_requests" name for the
|
||||||
|
// durable-intent table.
|
||||||
|
`ALTER TABLE download_wants RENAME TO download_requests`,
|
||||||
|
|
||||||
|
// 3. The transfer table's FK now points at download_downloads.
|
||||||
|
`ALTER TABLE download_items RENAME COLUMN request_id TO download_id`,
|
||||||
|
|
||||||
|
// Named indexes survive a table/column rename attached to their
|
||||||
|
// old name, so drop them here; ensureDownloadIndexes recreates
|
||||||
|
// them under the names sql/schemas' comments describe.
|
||||||
|
`DROP INDEX IF EXISTS idx_download_requests_created`,
|
||||||
|
`DROP INDEX IF EXISTS idx_download_requests_state`,
|
||||||
|
`DROP INDEX IF EXISTS idx_download_wants_due`,
|
||||||
|
`DROP INDEX IF EXISTS idx_download_wants_entity`,
|
||||||
|
`DROP INDEX IF EXISTS idx_download_wants_parent`,
|
||||||
|
`DROP INDEX IF EXISTS idx_download_items_request`,
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin download rename migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("download rename migration %q: %w", stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit download rename migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDownloadIndexes creates the indexes sql/schemas deliberately
|
||||||
|
// omits inline for the renamed table/columns (see
|
||||||
|
// migrateDownloadRename), under their final names. Safe to call
|
||||||
|
// unconditionally: IF NOT EXISTS makes it a no-op once created, and by
|
||||||
|
// the time this runs every column/table involved is guaranteed to be
|
||||||
|
// in its final shape on both a fresh and a migrated database.
|
||||||
|
func ensureDownloadIndexes(ctx context.Context, db *sql.DB) error {
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_download_downloads_created
|
||||||
|
ON download_downloads(created_at DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_download_downloads_state
|
||||||
|
ON download_downloads(state)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_download_requests_due
|
||||||
|
ON download_requests(next_try_at) WHERE state = 'wanted'`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_download_requests_entity
|
||||||
|
ON download_requests(entity, state)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_download_requests_parent
|
||||||
|
ON download_requests(parent_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_download_items_download
|
||||||
|
ON download_items(download_id)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("ensure download index: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// oldDownloadRequestsDDL, oldDownloadWantsDDL and oldDownloadItemsDDL
|
||||||
|
// are frozen snapshots of the download subsystem's tables exactly as
|
||||||
|
// they read before the Want/Request rename (see
|
||||||
|
// download_rename_migration.go) — i.e. what a real user's existing
|
||||||
|
// database looks like today, before upgrading to a build that includes
|
||||||
|
// this migration.
|
||||||
|
const oldDownloadRequestsDDL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS download_requests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
library_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
|
||||||
|
release_mbid TEXT,
|
||||||
|
release_group_mbid TEXT,
|
||||||
|
recording_mbid TEXT,
|
||||||
|
artist TEXT NOT NULL DEFAULT '',
|
||||||
|
album TEXT NOT NULL DEFAULT '',
|
||||||
|
query TEXT NOT NULL DEFAULT '',
|
||||||
|
expected TEXT NOT NULL DEFAULT '[]',
|
||||||
|
state TEXT NOT NULL DEFAULT 'searching'
|
||||||
|
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
||||||
|
'verifying', 'tagging', 'importing',
|
||||||
|
'complete', 'cancelled', 'failed')),
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_requests_created
|
||||||
|
ON download_requests(created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_requests_state
|
||||||
|
ON download_requests(state);
|
||||||
|
`
|
||||||
|
|
||||||
|
const oldDownloadWantsDDL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS download_wants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mbid TEXT NOT NULL,
|
||||||
|
entity TEXT NOT NULL
|
||||||
|
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
|
||||||
|
library_id INTEGER NOT NULL,
|
||||||
|
artist TEXT NOT NULL DEFAULT '',
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
scope TEXT NOT NULL DEFAULT 'future'
|
||||||
|
CHECK(scope IN ('future', 'all')),
|
||||||
|
secondary INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state TEXT NOT NULL DEFAULT 'wanted'
|
||||||
|
CHECK(state IN ('wanted', 'satisfied', 'paused')),
|
||||||
|
parent_id INTEGER,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
last_tried_at DATETIME,
|
||||||
|
next_try_at DATETIME,
|
||||||
|
external_ids TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(mbid, library_id),
|
||||||
|
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_wants_due
|
||||||
|
ON download_wants(next_try_at)
|
||||||
|
WHERE state = 'wanted';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
|
||||||
|
ON download_wants(entity, state);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
|
||||||
|
ON download_wants(parent_id);
|
||||||
|
`
|
||||||
|
|
||||||
|
const oldDownloadItemsDDL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS download_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
request_id TEXT NOT NULL,
|
||||||
|
provider_id INTEGER NOT NULL,
|
||||||
|
transport_id INTEGER,
|
||||||
|
external_id TEXT NOT NULL DEFAULT '',
|
||||||
|
candidate TEXT NOT NULL DEFAULT '{}',
|
||||||
|
state TEXT NOT NULL DEFAULT 'queued'
|
||||||
|
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
||||||
|
'verifying', 'tagging', 'importing',
|
||||||
|
'complete', 'cancelled', 'failed')),
|
||||||
|
staging_dir TEXT NOT NULL DEFAULT '',
|
||||||
|
bytes_done INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
imported_paths TEXT NOT NULL DEFAULT '[]',
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_items_live
|
||||||
|
ON download_items(state)
|
||||||
|
WHERE state NOT IN ('complete', 'cancelled', 'failed');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_items_request
|
||||||
|
ON download_items(request_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
||||||
|
ON download_items(state);
|
||||||
|
`
|
||||||
|
|
||||||
|
// seedOldDownloadSchema builds the pre-rename download tables and
|
||||||
|
// inserts one row of real data into each, standing in for a real
|
||||||
|
// user's database at the moment it upgrades.
|
||||||
|
func seedOldDownloadSchema(t *testing.T, db *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, ddl := range []string{
|
||||||
|
oldDownloadWantsDDL, oldDownloadRequestsDDL, oldDownloadItemsDDL,
|
||||||
|
} {
|
||||||
|
if _, err := db.ExecContext(t.Context(), ddl); err != nil {
|
||||||
|
t.Fatalf("create old download schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
t.Context(),
|
||||||
|
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed library: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
t.Context(),
|
||||||
|
`INSERT INTO download_wants
|
||||||
|
(id, mbid, entity, library_id, artist, title, state)
|
||||||
|
VALUES (1, 'artist-mbid', 'artist', 1, 'Radiohead', 'Radiohead', 'wanted')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed download_wants: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
t.Context(),
|
||||||
|
`INSERT INTO download_requests
|
||||||
|
(id, library_id, source, want_id, release_group_mbid, artist, album, state)
|
||||||
|
VALUES ('dl-1', 1, 'wanted', 1, 'rg-mbid', 'Radiohead', 'OK Computer', 'complete')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed download_requests: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
t.Context(),
|
||||||
|
`INSERT INTO download_items
|
||||||
|
(id, request_id, provider_id, state)
|
||||||
|
VALUES ('item-1', 'dl-1', 1, 'complete')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed download_items: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownloadRename_FreshInstallUntouched confirms applySchema on a
|
||||||
|
// brand-new database produces the target shape directly and that
|
||||||
|
// migrateDownloadRename's gate (checking for the old download_wants
|
||||||
|
// table) is a no-op there — the destructive path this test guards
|
||||||
|
// against is exactly the one described in download_rename_migration.go:
|
||||||
|
// blindly replaying the rename against a fresh database's already-
|
||||||
|
// correct, empty download_requests/download_downloads tables.
|
||||||
|
func TestDownloadRename_FreshInstallUntouched(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openMemDB(t)
|
||||||
|
|
||||||
|
if err := applySchema(t.Context(), db); err != nil {
|
||||||
|
t.Fatalf("apply schema (fresh): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
||||||
|
var name string
|
||||||
|
|
||||||
|
err := db.QueryRowContext(
|
||||||
|
t.Context(),
|
||||||
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
||||||
|
table,
|
||||||
|
).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected table %q to exist on a fresh install: %v", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stray string
|
||||||
|
|
||||||
|
err := db.QueryRowContext(
|
||||||
|
t.Context(),
|
||||||
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
||||||
|
).Scan(&stray)
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
t.Errorf("old download_wants table should not exist on a fresh install, err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both auto-download guardrail indexes sql/schemas deliberately
|
||||||
|
// omits (see ensureDownloadIndexes) must still exist.
|
||||||
|
for _, idx := range []string{
|
||||||
|
"idx_download_requests_due",
|
||||||
|
"idx_download_requests_entity",
|
||||||
|
"idx_download_requests_parent",
|
||||||
|
"idx_download_items_download",
|
||||||
|
} {
|
||||||
|
var name string
|
||||||
|
|
||||||
|
err := db.QueryRowContext(
|
||||||
|
t.Context(),
|
||||||
|
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`,
|
||||||
|
idx,
|
||||||
|
).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected index %q to exist on a fresh install: %v", idx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownloadRename_UpgradesExistingDatabase is the regression test
|
||||||
|
// for the rename itself: an old-shaped database (download_wants +
|
||||||
|
// old-style download_requests, both with real rows) must end up with
|
||||||
|
// the same table names, column names, and data a fresh install would
|
||||||
|
// have — nothing dropped, nothing silently emptied.
|
||||||
|
func TestDownloadRename_UpgradesExistingDatabase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fresh := openMemDB(t)
|
||||||
|
if err := applySchema(t.Context(), fresh); err != nil {
|
||||||
|
t.Fatalf("apply schema (fresh): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
upgraded := openMemDB(t)
|
||||||
|
|
||||||
|
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read libraries schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
|
||||||
|
t.Fatalf("create libraries table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedOldDownloadSchema(t, upgraded)
|
||||||
|
|
||||||
|
if err := applySchema(t.Context(), upgraded); err != nil {
|
||||||
|
t.Fatalf("apply schema (upgrade path): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column order must match a fresh install's, for the same reason
|
||||||
|
// TestMigrations_ColumnOrderMatchesFreshInstall checks tagging_items:
|
||||||
|
// sqlc's `SELECT *` binds positionally.
|
||||||
|
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
||||||
|
freshCols := tableColumns(t, fresh, table)
|
||||||
|
upgradedCols := tableColumns(t, upgraded, table)
|
||||||
|
|
||||||
|
if len(freshCols) != len(upgradedCols) {
|
||||||
|
t.Fatalf(
|
||||||
|
"%s: column count mismatch: fresh has %d (%v), upgraded has %d (%v)",
|
||||||
|
table, len(freshCols), freshCols, len(upgradedCols), upgradedCols,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range freshCols {
|
||||||
|
if freshCols[i] != upgradedCols[i] {
|
||||||
|
t.Errorf(
|
||||||
|
"%s: column order mismatch at %d: fresh %q, upgraded %q\nfresh: %v\nupgraded: %v",
|
||||||
|
table,
|
||||||
|
i,
|
||||||
|
freshCols[i],
|
||||||
|
upgradedCols[i],
|
||||||
|
freshCols,
|
||||||
|
upgradedCols,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seeded rows survived the rename under their new names.
|
||||||
|
var (
|
||||||
|
requestMBID string
|
||||||
|
requestEntity string
|
||||||
|
)
|
||||||
|
|
||||||
|
err = upgraded.QueryRowContext(
|
||||||
|
t.Context(), `SELECT mbid, entity FROM download_requests WHERE id = 1`,
|
||||||
|
).Scan(&requestMBID, &requestEntity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seeded request row missing after rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestMBID != "artist-mbid" || requestEntity != "artist" {
|
||||||
|
t.Errorf("request row corrupted: mbid=%q entity=%q", requestMBID, requestEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
downloadRequestID sql.NullInt64
|
||||||
|
downloadAlbum string
|
||||||
|
)
|
||||||
|
|
||||||
|
err = upgraded.QueryRowContext(
|
||||||
|
t.Context(),
|
||||||
|
`SELECT request_id, album FROM download_downloads WHERE id = 'dl-1'`,
|
||||||
|
).Scan(&downloadRequestID, &downloadAlbum)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seeded download row missing after rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !downloadRequestID.Valid || downloadRequestID.Int64 != 1 {
|
||||||
|
t.Errorf("download.request_id = %v, want 1 (renamed from want_id)", downloadRequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if downloadAlbum != "OK Computer" {
|
||||||
|
t.Errorf("download.album = %q, want OK Computer", downloadAlbum)
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemDownloadID string
|
||||||
|
|
||||||
|
err = upgraded.QueryRowContext(
|
||||||
|
t.Context(),
|
||||||
|
`SELECT download_id FROM download_items WHERE id = 'item-1'`,
|
||||||
|
).Scan(&itemDownloadID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seeded item row missing after rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemDownloadID != "dl-1" {
|
||||||
|
t.Errorf("item.download_id = %q, want dl-1 (renamed from request_id)", itemDownloadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The old table is gone, not just emptied.
|
||||||
|
var stray string
|
||||||
|
|
||||||
|
err = upgraded.QueryRowContext(
|
||||||
|
t.Context(),
|
||||||
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
||||||
|
).Scan(&stray)
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
t.Errorf("old download_wants table should be gone after migration, err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running the whole thing again (as a second app startup would) is
|
||||||
|
// a no-op: the gate sees no download_wants table and does nothing
|
||||||
|
// further, so this must not error or duplicate anything.
|
||||||
|
if err := applySchema(t.Context(), upgraded); err != nil {
|
||||||
|
t.Fatalf("apply schema a second time: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
|
||||||
|
if err := upgraded.QueryRowContext(
|
||||||
|
t.Context(), `SELECT COUNT(*) FROM download_requests`,
|
||||||
|
).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("count download_requests: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("download_requests has %d rows after a second migration pass, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,69 +22,81 @@ WHERE id = ?;
|
|||||||
DELETE FROM download_providers
|
DELETE FROM download_providers
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: CreateDownloadRequest :exec
|
-- ---------------------------------------------------------------------
|
||||||
INSERT INTO download_requests (
|
-- Downloads (one-shot search+grab attempts)
|
||||||
id, library_id, source, want_id, release_mbid, release_group_mbid,
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: CreateDownload :exec
|
||||||
|
INSERT INTO download_downloads (
|
||||||
|
id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||||
recording_mbid, artist, album, query, expected, state
|
recording_mbid, artist, album, query, expected, state
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
|
|
||||||
-- name: GetDownloadRequest :one
|
-- name: GetDownload :one
|
||||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||||
recording_mbid, artist, album, query, expected, state, error,
|
recording_mbid, artist, album, query, expected, state, error,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM download_requests
|
FROM download_downloads
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: ListDownloadRequests :many
|
-- name: ListDownloads :many
|
||||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||||
recording_mbid, artist, album, query, expected, state, error,
|
recording_mbid, artist, album, query, expected, state, error,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM download_requests
|
FROM download_downloads
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
|
|
||||||
-- name: ListLiveDownloadRequests :many
|
-- name: ListLiveDownloads :many
|
||||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||||
recording_mbid, artist, album, query, expected, state, error,
|
recording_mbid, artist, album, query, expected, state, error,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM download_requests
|
FROM download_downloads
|
||||||
WHERE state NOT IN ('complete', 'cancelled', 'failed')
|
WHERE state NOT IN ('complete', 'cancelled', 'failed')
|
||||||
ORDER BY created_at;
|
ORDER BY created_at;
|
||||||
|
|
||||||
-- name: SetDownloadRequestState :exec
|
-- name: SetDownloadState :exec
|
||||||
UPDATE download_requests
|
UPDATE download_downloads
|
||||||
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
|
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteDownloadRequest :exec
|
-- name: DeleteDownload :exec
|
||||||
DELETE FROM download_requests
|
DELETE FROM download_downloads
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteFinishedDownloads :exec
|
||||||
|
DELETE FROM download_downloads
|
||||||
|
WHERE state IN ('complete', 'cancelled', 'failed');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Items (transfer records within a download)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
-- name: CreateDownloadItem :exec
|
-- name: CreateDownloadItem :exec
|
||||||
INSERT INTO download_items (
|
INSERT INTO download_items (
|
||||||
id, request_id, provider_id, transport_id, external_id,
|
id, download_id, provider_id, transport_id, external_id,
|
||||||
candidate, state, staging_dir, bytes_total
|
candidate, state, staging_dir, bytes_total
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
|
|
||||||
-- name: GetDownloadItem :one
|
-- name: GetDownloadItem :one
|
||||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||||
error, created_at, updated_at
|
error, created_at, updated_at
|
||||||
FROM download_items
|
FROM download_items
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: ListDownloadItemsForRequest :many
|
-- name: ListDownloadItemsForDownload :many
|
||||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||||
error, created_at, updated_at
|
error, created_at, updated_at
|
||||||
FROM download_items
|
FROM download_items
|
||||||
WHERE request_id = ?
|
WHERE download_id = ?
|
||||||
ORDER BY created_at;
|
ORDER BY created_at;
|
||||||
|
|
||||||
-- name: ListLiveDownloadItems :many
|
-- name: ListLiveDownloadItems :many
|
||||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||||
error, created_at, updated_at
|
error, created_at, updated_at
|
||||||
FROM download_items
|
FROM download_items
|
||||||
@@ -112,72 +124,68 @@ SET imported_paths = ?, state = 'complete', error = '',
|
|||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteFinishedDownloadRequests :exec
|
|
||||||
DELETE FROM download_requests
|
|
||||||
WHERE state IN ('complete', 'cancelled', 'failed');
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
-- Wants
|
-- Requests (durable "I asked for this" records)
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
-- name: UpsertDownloadWant :one
|
-- name: UpsertDownloadRequest :one
|
||||||
-- Adding something already wanted is not an error and must not reset
|
-- Adding something already requested is not an error and must not
|
||||||
-- the retry clock, so the conflict path only refreshes display text and
|
-- reset the retry clock, so the conflict path only refreshes display
|
||||||
-- un-pauses nothing. scope and secondary are updated because asking
|
-- text and un-pauses nothing. scope and secondary are updated because
|
||||||
-- again with a wider scope is a real change of intent.
|
-- asking again with a wider scope is a real change of intent.
|
||||||
INSERT INTO download_wants (
|
INSERT INTO download_requests (
|
||||||
mbid, entity, library_id, artist, title, scope, secondary,
|
mbid, entity, library_id, artist, title, scope, secondary,
|
||||||
parent_id, next_try_at
|
parent_id, next_try_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(mbid, library_id) DO UPDATE SET
|
ON CONFLICT(mbid, library_id) DO UPDATE SET
|
||||||
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
|
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
|
||||||
ELSE download_wants.artist END,
|
ELSE download_requests.artist END,
|
||||||
title = CASE WHEN excluded.title <> '' THEN excluded.title
|
title = CASE WHEN excluded.title <> '' THEN excluded.title
|
||||||
ELSE download_wants.title END,
|
ELSE download_requests.title END,
|
||||||
scope = excluded.scope,
|
scope = excluded.scope,
|
||||||
secondary = excluded.secondary,
|
secondary = excluded.secondary,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
|
|
||||||
-- name: GetDownloadWant :one
|
-- name: GetDownloadRequest :one
|
||||||
SELECT * FROM download_wants WHERE id = ?;
|
SELECT * FROM download_requests WHERE id = ?;
|
||||||
|
|
||||||
-- name: GetDownloadWantByMBID :one
|
-- name: GetDownloadRequestByMBID :one
|
||||||
SELECT * FROM download_wants WHERE mbid = ? AND library_id = ?;
|
SELECT * FROM download_requests WHERE mbid = ? AND library_id = ?;
|
||||||
|
|
||||||
-- name: ListDownloadWants :many
|
-- name: ListDownloadRequests :many
|
||||||
SELECT * FROM download_wants
|
SELECT * FROM download_requests
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
|
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
|
||||||
artist, title;
|
artist, title;
|
||||||
|
|
||||||
-- name: ListDownloadWantsByEntity :many
|
-- name: ListDownloadRequestsByEntity :many
|
||||||
SELECT * FROM download_wants
|
SELECT * FROM download_requests
|
||||||
WHERE entity = ? AND state = ?
|
WHERE entity = ? AND state = ?
|
||||||
ORDER BY id;
|
ORDER BY id;
|
||||||
|
|
||||||
-- name: ListDueDownloadWants :many
|
-- name: ListDueDownloadRequests :many
|
||||||
-- Everything the reconciler should act on this pass: wanted, not an
|
-- Everything the reconciler should act on this pass: wanted, not an
|
||||||
-- artist subscription (those expand rather than download), and either
|
-- artist subscription (those expand rather than download), and either
|
||||||
-- never tried or past its backoff.
|
-- never tried or past its backoff.
|
||||||
SELECT * FROM download_wants
|
SELECT * FROM download_requests
|
||||||
WHERE state = 'wanted'
|
WHERE state = 'wanted'
|
||||||
AND entity <> 'artist'
|
AND entity <> 'artist'
|
||||||
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
|
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
|
||||||
ORDER BY attempts, created_at
|
ORDER BY attempts, created_at
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
|
|
||||||
-- name: ListChildDownloadWants :many
|
-- name: ListChildDownloadRequests :many
|
||||||
SELECT * FROM download_wants WHERE parent_id = ? ORDER BY id;
|
SELECT * FROM download_requests WHERE parent_id = ? ORDER BY id;
|
||||||
|
|
||||||
-- name: SetDownloadWantState :exec
|
-- name: SetDownloadRequestState :exec
|
||||||
UPDATE download_wants
|
UPDATE download_requests
|
||||||
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
|
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: RecordDownloadWantAttempt :exec
|
-- name: RecordDownloadRequestAttempt :exec
|
||||||
UPDATE download_wants
|
UPDATE download_requests
|
||||||
SET attempts = attempts + 1,
|
SET attempts = attempts + 1,
|
||||||
last_error = ?,
|
last_error = ?,
|
||||||
last_tried_at = CURRENT_TIMESTAMP,
|
last_tried_at = CURRENT_TIMESTAMP,
|
||||||
@@ -185,19 +193,19 @@ SET attempts = attempts + 1,
|
|||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: SatisfyDownloadWant :exec
|
-- name: SatisfyDownloadRequest :exec
|
||||||
UPDATE download_wants
|
UPDATE download_requests
|
||||||
SET state = 'satisfied', last_error = '', next_try_at = NULL,
|
SET state = 'satisfied', last_error = '', next_try_at = NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: SetDownloadWantExternalIDs :exec
|
-- name: SetDownloadRequestExternalIDs :exec
|
||||||
UPDATE download_wants
|
UPDATE download_requests
|
||||||
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
|
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteDownloadWant :exec
|
-- name: DeleteDownloadRequest :exec
|
||||||
DELETE FROM download_wants WHERE id = ?;
|
DELETE FROM download_requests WHERE id = ?;
|
||||||
|
|
||||||
-- name: DeleteSatisfiedDownloadWants :exec
|
-- name: DeleteSatisfiedDownloadRequests :exec
|
||||||
DELETE FROM download_wants WHERE state = 'satisfied';
|
DELETE FROM download_requests WHERE state = 'satisfied';
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- One row per "go find me this", from the moment a search is fired
|
||||||
|
-- until the files are in the library or the attempt is abandoned. A
|
||||||
|
-- Download is one attempt: it searches, it grabs, it succeeds or
|
||||||
|
-- fails, and then it is history. See download_requests.sql for the
|
||||||
|
-- durable record a Download may be attached to.
|
||||||
|
--
|
||||||
|
-- release_mbid / release_group_mbid are the anchor: a download that
|
||||||
|
-- carries one can be matched against a known tracklist at import time,
|
||||||
|
-- which is what makes unattended completion safe. Free-text downloads
|
||||||
|
-- (both NULL) are always presented to the user for confirmation.
|
||||||
|
--
|
||||||
|
-- `expected` caches the anchor's tracklist as JSON so ranking and
|
||||||
|
-- import do not have to re-resolve it, and so a download survives the
|
||||||
|
-- explore index being rebuilt underneath it.
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS download_downloads (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
library_id INTEGER NOT NULL,
|
||||||
|
-- source records where the download came from: 'explore-album',
|
||||||
|
-- 'explore-artist', 'missing-album', 'wanted', 'manual'.
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
-- request_id is set when this download is attached to a durable
|
||||||
|
-- Request (see download_requests.sql), whether raised by the
|
||||||
|
-- reconciler or attached to a manual anchored download. NULL for
|
||||||
|
-- a free-text download with nothing stable to attach to.
|
||||||
|
-- Requests are durable and downloads are disposable, so the delete
|
||||||
|
-- is a SET NULL rather than a cascade in either direction.
|
||||||
|
request_id INTEGER REFERENCES download_requests(id) ON DELETE SET NULL,
|
||||||
|
release_mbid TEXT,
|
||||||
|
release_group_mbid TEXT,
|
||||||
|
-- recording_mbid anchors a single-track download raised from a
|
||||||
|
-- track-level request.
|
||||||
|
recording_mbid TEXT,
|
||||||
|
artist TEXT NOT NULL DEFAULT '',
|
||||||
|
album TEXT NOT NULL DEFAULT '',
|
||||||
|
query TEXT NOT NULL DEFAULT '',
|
||||||
|
expected TEXT NOT NULL DEFAULT '[]',
|
||||||
|
state TEXT NOT NULL DEFAULT 'searching'
|
||||||
|
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
||||||
|
'verifying', 'tagging', 'importing',
|
||||||
|
'complete', 'cancelled', 'failed')),
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_downloads_created
|
||||||
|
ON download_downloads(created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_downloads_state
|
||||||
|
ON download_downloads(state);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
-- One row per grab attempt against one candidate. A request can have
|
-- One row per grab attempt against one candidate. A download can have
|
||||||
-- several: the first pick stalls, the user picks another, or a
|
-- several: the first pick stalls, the user picks another, or a
|
||||||
-- search-only provider's candidate is fetched by a separate transport
|
-- search-only provider's candidate is fetched by a separate transport
|
||||||
-- (in which case provider_id is the searcher and transport_id is the
|
-- (in which case provider_id is the searcher and transport_id is the
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS download_items (
|
CREATE TABLE IF NOT EXISTS download_items (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
request_id TEXT NOT NULL,
|
download_id TEXT NOT NULL,
|
||||||
provider_id INTEGER NOT NULL,
|
provider_id INTEGER NOT NULL,
|
||||||
transport_id INTEGER,
|
transport_id INTEGER,
|
||||||
external_id TEXT NOT NULL DEFAULT '',
|
external_id TEXT NOT NULL DEFAULT '',
|
||||||
@@ -35,15 +35,18 @@ CREATE TABLE IF NOT EXISTS download_items (
|
|||||||
error TEXT NOT NULL DEFAULT '',
|
error TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
FOREIGN KEY(download_id) REFERENCES download_downloads(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_items_live
|
CREATE INDEX IF NOT EXISTS idx_download_items_live
|
||||||
ON download_items(state)
|
ON download_items(state)
|
||||||
WHERE state NOT IN ('complete', 'cancelled', 'failed');
|
WHERE state NOT IN ('complete', 'cancelled', 'failed');
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_items_request
|
|
||||||
ON download_items(request_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
||||||
ON download_items(state);
|
ON download_items(state);
|
||||||
|
|
||||||
|
-- idx_download_items_download is deliberately NOT declared here: on an
|
||||||
|
-- existing database this table already exists at schema-pass time with
|
||||||
|
-- its old column still named request_id, so an inline CREATE INDEX on
|
||||||
|
-- download_id would fail outright. See ensureDownloadIndexes in
|
||||||
|
-- backend/database/download_rename_migration.go.
|
||||||
|
|||||||
@@ -1,49 +1,79 @@
|
|||||||
-- One row per "go find me this", from the moment the user asks until
|
-- A Request is a persistent "I want this", stored as a MusicBrainz ID
|
||||||
-- the files are in the library or the attempt is abandoned.
|
-- and almost nothing else. It outlives every download attempt made on
|
||||||
|
-- its behalf: nothing being findable today is the normal case for
|
||||||
|
-- obscure music, and the correct response is to try again next week,
|
||||||
|
-- not to show the user a failed row they have to remember to retry.
|
||||||
--
|
--
|
||||||
-- release_mbid / release_group_mbid are the anchor: a request that
|
-- Because a Request is only an MBID, it stays true when everything
|
||||||
-- carries one can be matched against a known tracklist at import time,
|
-- around it changes: the explore index is rebuilt, a provider is
|
||||||
-- which is what makes unattended completion safe. Free-text requests
|
-- swapped out, the release the user originally saw is superseded by a
|
||||||
-- (both NULL) are always presented to the user for confirmation.
|
-- remaster. The display fields are a cache for the list view and are
|
||||||
--
|
-- never consulted for matching.
|
||||||
-- `expected` caches the anchor's tracklist as JSON so ranking and
|
|
||||||
-- import do not have to re-resolve it, and so a request survives the
|
|
||||||
-- explore index being rebuilt underneath it.
|
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS download_requests (
|
CREATE TABLE IF NOT EXISTS download_requests (
|
||||||
id TEXT PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
library_id INTEGER NOT NULL,
|
mbid TEXT NOT NULL,
|
||||||
-- source records where the request came from: 'explore-album',
|
entity TEXT NOT NULL
|
||||||
-- 'explore-artist', 'missing-album', 'wanted', 'manual'.
|
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
|
||||||
source TEXT NOT NULL DEFAULT 'manual',
|
library_id INTEGER NOT NULL,
|
||||||
-- want_id is set when the reconciler raised this request from the
|
|
||||||
-- wanted list, so the outcome can be written back to the want.
|
-- Display text, cached so the list renders without touching the
|
||||||
-- NULL for one-off requests the user started by hand. Requests are
|
-- explore index. Neither is authoritative; the MBID is.
|
||||||
-- disposable and wants are not, so the delete is a SET NULL rather
|
artist TEXT NOT NULL DEFAULT '',
|
||||||
-- than a cascade in either direction.
|
title TEXT NOT NULL DEFAULT '',
|
||||||
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
|
|
||||||
release_mbid TEXT,
|
scope TEXT NOT NULL DEFAULT 'future'
|
||||||
release_group_mbid TEXT,
|
CHECK(scope IN ('future', 'all')),
|
||||||
-- recording_mbid anchors a single-track request raised from a
|
|
||||||
-- track-level want.
|
-- secondary controls whether an artist request's expansion includes
|
||||||
recording_mbid TEXT,
|
-- compilations, live albums and remixes. Off by default: someone
|
||||||
artist TEXT NOT NULL DEFAULT '',
|
-- subscribing to an artist wants the albums, not six versions of
|
||||||
album TEXT NOT NULL DEFAULT '',
|
-- the same greatest-hits package.
|
||||||
query TEXT NOT NULL DEFAULT '',
|
secondary INTEGER NOT NULL DEFAULT 0,
|
||||||
expected TEXT NOT NULL DEFAULT '[]',
|
|
||||||
state TEXT NOT NULL DEFAULT 'searching'
|
state TEXT NOT NULL DEFAULT 'wanted'
|
||||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
CHECK(state IN ('wanted', 'satisfied', 'paused')),
|
||||||
'verifying', 'tagging', 'importing',
|
|
||||||
'complete', 'cancelled', 'failed')),
|
-- parent_id links a request the reconciler derived from an artist
|
||||||
error TEXT NOT NULL DEFAULT '',
|
-- request. Deleting the artist takes its derived children with
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
-- it, but children the user pinned themselves have no parent and
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
-- stay.
|
||||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
|
parent_id INTEGER,
|
||||||
|
|
||||||
|
-- Retry bookkeeping. attempts drives the backoff; last_error is
|
||||||
|
-- the most recent reason it did not work out, which for a request
|
||||||
|
-- is information rather than a failure.
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
last_tried_at DATETIME,
|
||||||
|
next_try_at DATETIME,
|
||||||
|
|
||||||
|
-- external_ids maps provider row ID to that provider's own
|
||||||
|
-- identifier for this request, for clients that keep their own
|
||||||
|
-- persistent list (a Lidarr artist ID). JSON object.
|
||||||
|
external_ids TEXT NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- One request per thing per library. Asking twice is not two
|
||||||
|
-- requests, and this is what lets an artist expansion re-run every
|
||||||
|
-- reconcile without accumulating duplicates.
|
||||||
|
UNIQUE(mbid, library_id),
|
||||||
|
|
||||||
|
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(parent_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_requests_created
|
-- idx_download_requests_{due,entity,parent} are deliberately NOT
|
||||||
ON download_requests(created_at DESC);
|
-- declared here. This table name is reused from the old one-shot
|
||||||
|
-- attempt table (also called download_requests before the Want/Request
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_requests_state
|
-- rename), so on an existing database this CREATE TABLE is a no-op
|
||||||
ON download_requests(state);
|
-- against a table that, at schema-pass time, is still shaped like the
|
||||||
|
-- OLD attempts table and lacks these columns entirely — an inline
|
||||||
|
-- CREATE INDEX here would fail outright rather than just no-op. See
|
||||||
|
-- migrateDownloadRename/ensureDownloadIndexes in
|
||||||
|
-- backend/database/download_rename_migration.go, which create these
|
||||||
|
-- once the rename has actually happened (or immediately, on a fresh
|
||||||
|
-- database where the columns exist from the start).
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
-- One row per "go find me this", from the moment the user asks until
|
|
||||||
-- the files are in the library or the attempt is abandoned.
|
|
||||||
--
|
|
||||||
-- release_mbid / release_group_mbid are the anchor: a request that
|
|
||||||
-- carries one can be matched against a known tracklist at import time,
|
|
||||||
-- which is what makes unattended completion safe. Free-text requests
|
|
||||||
-- (both NULL) are always presented to the user for confirmation.
|
|
||||||
--
|
|
||||||
-- `expected` caches the anchor's tracklist as JSON so ranking and
|
|
||||||
-- import do not have to re-resolve it, and so a request survives the
|
|
||||||
-- explore index being rebuilt underneath it.
|
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS download_wants (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
mbid TEXT NOT NULL,
|
|
||||||
entity TEXT NOT NULL
|
|
||||||
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
|
|
||||||
library_id INTEGER NOT NULL,
|
|
||||||
|
|
||||||
-- Display text, cached so the wanted list renders without touching
|
|
||||||
-- the explore index. Neither is authoritative; the MBID is.
|
|
||||||
artist TEXT NOT NULL DEFAULT '',
|
|
||||||
title TEXT NOT NULL DEFAULT '',
|
|
||||||
|
|
||||||
scope TEXT NOT NULL DEFAULT 'future'
|
|
||||||
CHECK(scope IN ('future', 'all')),
|
|
||||||
|
|
||||||
-- secondary controls whether an artist want's expansion includes
|
|
||||||
-- compilations, live albums and remixes. Off by default: someone
|
|
||||||
-- subscribing to an artist wants the albums, not six versions of
|
|
||||||
-- the same greatest-hits package.
|
|
||||||
secondary INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
state TEXT NOT NULL DEFAULT 'wanted'
|
|
||||||
CHECK(state IN ('wanted', 'satisfied', 'paused')),
|
|
||||||
|
|
||||||
-- parent_id links a want the reconciler derived from an artist
|
|
||||||
-- want. Deleting the artist takes its derived children with it,
|
|
||||||
-- but children the user pinned themselves have no parent and stay.
|
|
||||||
parent_id INTEGER,
|
|
||||||
|
|
||||||
-- Retry bookkeeping. attempts drives the backoff; last_error is
|
|
||||||
-- the most recent reason it did not work out, which for a wanted
|
|
||||||
-- item is information rather than a failure.
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_error TEXT NOT NULL DEFAULT '',
|
|
||||||
last_tried_at DATETIME,
|
|
||||||
next_try_at DATETIME,
|
|
||||||
|
|
||||||
-- external_ids maps provider row ID to that provider's own
|
|
||||||
-- identifier for this want, for clients that keep their own
|
|
||||||
-- persistent list (a Lidarr artist ID). JSON object.
|
|
||||||
external_ids TEXT NOT NULL DEFAULT '{}',
|
|
||||||
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
-- One want per thing per library. Asking twice is not two wants,
|
|
||||||
-- and this is what lets an artist expansion re-run every reconcile
|
|
||||||
-- without accumulating duplicates.
|
|
||||||
UNIQUE(mbid, library_id),
|
|
||||||
|
|
||||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_wants_due
|
|
||||||
ON download_wants(next_try_at)
|
|
||||||
WHERE state = 'wanted';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
|
|
||||||
ON download_wants(entity, state);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
|
|
||||||
ON download_wants(parent_id);
|
|
||||||
@@ -10,9 +10,55 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const createDownload = `-- name: CreateDownload :exec
|
||||||
|
|
||||||
|
INSERT INTO download_downloads (
|
||||||
|
id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||||
|
recording_mbid, artist, album, query, expected, state
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateDownloadParams struct {
|
||||||
|
ID string
|
||||||
|
LibraryID int64
|
||||||
|
Source string
|
||||||
|
RequestID sql.NullInt64
|
||||||
|
ReleaseMbid sql.NullString
|
||||||
|
ReleaseGroupMbid sql.NullString
|
||||||
|
RecordingMbid sql.NullString
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
Query string
|
||||||
|
Expected string
|
||||||
|
State string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Downloads (one-shot search+grab attempts)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
func (q *Queries) CreateDownload(ctx context.Context, arg CreateDownloadParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, createDownload,
|
||||||
|
arg.ID,
|
||||||
|
arg.LibraryID,
|
||||||
|
arg.Source,
|
||||||
|
arg.RequestID,
|
||||||
|
arg.ReleaseMbid,
|
||||||
|
arg.ReleaseGroupMbid,
|
||||||
|
arg.RecordingMbid,
|
||||||
|
arg.Artist,
|
||||||
|
arg.Album,
|
||||||
|
arg.Query,
|
||||||
|
arg.Expected,
|
||||||
|
arg.State,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const createDownloadItem = `-- name: CreateDownloadItem :exec
|
const createDownloadItem = `-- name: CreateDownloadItem :exec
|
||||||
|
|
||||||
INSERT INTO download_items (
|
INSERT INTO download_items (
|
||||||
id, request_id, provider_id, transport_id, external_id,
|
id, download_id, provider_id, transport_id, external_id,
|
||||||
candidate, state, staging_dir, bytes_total
|
candidate, state, staging_dir, bytes_total
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -20,7 +66,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|||||||
|
|
||||||
type CreateDownloadItemParams struct {
|
type CreateDownloadItemParams struct {
|
||||||
ID string
|
ID string
|
||||||
RequestID string
|
DownloadID string
|
||||||
ProviderID int64
|
ProviderID int64
|
||||||
TransportID sql.NullInt64
|
TransportID sql.NullInt64
|
||||||
ExternalID string
|
ExternalID string
|
||||||
@@ -30,10 +76,13 @@ type CreateDownloadItemParams struct {
|
|||||||
BytesTotal int64
|
BytesTotal int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Items (transfer records within a download)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
func (q *Queries) CreateDownloadItem(ctx context.Context, arg CreateDownloadItemParams) error {
|
func (q *Queries) CreateDownloadItem(ctx context.Context, arg CreateDownloadItemParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, createDownloadItem,
|
_, err := q.db.ExecContext(ctx, createDownloadItem,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
arg.RequestID,
|
arg.DownloadID,
|
||||||
arg.ProviderID,
|
arg.ProviderID,
|
||||||
arg.TransportID,
|
arg.TransportID,
|
||||||
arg.ExternalID,
|
arg.ExternalID,
|
||||||
@@ -72,44 +121,13 @@ func (q *Queries) CreateDownloadProvider(ctx context.Context, arg CreateDownload
|
|||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const createDownloadRequest = `-- name: CreateDownloadRequest :exec
|
const deleteDownload = `-- name: DeleteDownload :exec
|
||||||
INSERT INTO download_requests (
|
DELETE FROM download_downloads
|
||||||
id, library_id, source, want_id, release_mbid, release_group_mbid,
|
WHERE id = ?
|
||||||
recording_mbid, artist, album, query, expected, state
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateDownloadRequestParams struct {
|
func (q *Queries) DeleteDownload(ctx context.Context, id string) error {
|
||||||
ID string
|
_, err := q.db.ExecContext(ctx, deleteDownload, id)
|
||||||
LibraryID int64
|
|
||||||
Source string
|
|
||||||
WantID sql.NullInt64
|
|
||||||
ReleaseMbid sql.NullString
|
|
||||||
ReleaseGroupMbid sql.NullString
|
|
||||||
RecordingMbid sql.NullString
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
Query string
|
|
||||||
Expected string
|
|
||||||
State string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateDownloadRequest(ctx context.Context, arg CreateDownloadRequestParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, createDownloadRequest,
|
|
||||||
arg.ID,
|
|
||||||
arg.LibraryID,
|
|
||||||
arg.Source,
|
|
||||||
arg.WantID,
|
|
||||||
arg.ReleaseMbid,
|
|
||||||
arg.ReleaseGroupMbid,
|
|
||||||
arg.RecordingMbid,
|
|
||||||
arg.Artist,
|
|
||||||
arg.Album,
|
|
||||||
arg.Query,
|
|
||||||
arg.Expected,
|
|
||||||
arg.State,
|
|
||||||
)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,45 +142,66 @@ func (q *Queries) DeleteDownloadProvider(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteDownloadRequest = `-- name: DeleteDownloadRequest :exec
|
const deleteDownloadRequest = `-- name: DeleteDownloadRequest :exec
|
||||||
DELETE FROM download_requests
|
DELETE FROM download_requests WHERE id = ?
|
||||||
WHERE id = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteDownloadRequest(ctx context.Context, id string) error {
|
func (q *Queries) DeleteDownloadRequest(ctx context.Context, id int64) error {
|
||||||
_, err := q.db.ExecContext(ctx, deleteDownloadRequest, id)
|
_, err := q.db.ExecContext(ctx, deleteDownloadRequest, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteDownloadWant = `-- name: DeleteDownloadWant :exec
|
const deleteFinishedDownloads = `-- name: DeleteFinishedDownloads :exec
|
||||||
DELETE FROM download_wants WHERE id = ?
|
DELETE FROM download_downloads
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteDownloadWant(ctx context.Context, id int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteDownloadWant, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteFinishedDownloadRequests = `-- name: DeleteFinishedDownloadRequests :exec
|
|
||||||
DELETE FROM download_requests
|
|
||||||
WHERE state IN ('complete', 'cancelled', 'failed')
|
WHERE state IN ('complete', 'cancelled', 'failed')
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteFinishedDownloadRequests(ctx context.Context) error {
|
func (q *Queries) DeleteFinishedDownloads(ctx context.Context) error {
|
||||||
_, err := q.db.ExecContext(ctx, deleteFinishedDownloadRequests)
|
_, err := q.db.ExecContext(ctx, deleteFinishedDownloads)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSatisfiedDownloadWants = `-- name: DeleteSatisfiedDownloadWants :exec
|
const deleteSatisfiedDownloadRequests = `-- name: DeleteSatisfiedDownloadRequests :exec
|
||||||
DELETE FROM download_wants WHERE state = 'satisfied'
|
DELETE FROM download_requests WHERE state = 'satisfied'
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteSatisfiedDownloadWants(ctx context.Context) error {
|
func (q *Queries) DeleteSatisfiedDownloadRequests(ctx context.Context) error {
|
||||||
_, err := q.db.ExecContext(ctx, deleteSatisfiedDownloadWants)
|
_, err := q.db.ExecContext(ctx, deleteSatisfiedDownloadRequests)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDownload = `-- name: GetDownload :one
|
||||||
|
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||||
|
recording_mbid, artist, album, query, expected, state, error,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM download_downloads
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetDownload(ctx context.Context, id string) (DownloadDownload, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getDownload, id)
|
||||||
|
var i DownloadDownload
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.Source,
|
||||||
|
&i.RequestID,
|
||||||
|
&i.ReleaseMbid,
|
||||||
|
&i.ReleaseGroupMbid,
|
||||||
|
&i.RecordingMbid,
|
||||||
|
&i.Artist,
|
||||||
|
&i.Album,
|
||||||
|
&i.Query,
|
||||||
|
&i.Expected,
|
||||||
|
&i.State,
|
||||||
|
&i.Error,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getDownloadItem = `-- name: GetDownloadItem :one
|
const getDownloadItem = `-- name: GetDownloadItem :one
|
||||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||||
error, created_at, updated_at
|
error, created_at, updated_at
|
||||||
FROM download_items
|
FROM download_items
|
||||||
@@ -174,7 +213,7 @@ func (q *Queries) GetDownloadItem(ctx context.Context, id string) (DownloadItem,
|
|||||||
var i DownloadItem
|
var i DownloadItem
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.RequestID,
|
&i.DownloadID,
|
||||||
&i.ProviderID,
|
&i.ProviderID,
|
||||||
&i.TransportID,
|
&i.TransportID,
|
||||||
&i.ExternalID,
|
&i.ExternalID,
|
||||||
@@ -213,43 +252,12 @@ func (q *Queries) GetDownloadProvider(ctx context.Context, id int64) (DownloadPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDownloadRequest = `-- name: GetDownloadRequest :one
|
const getDownloadRequest = `-- name: GetDownloadRequest :one
|
||||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests WHERE id = ?
|
||||||
recording_mbid, artist, album, query, expected, state, error,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM download_requests
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetDownloadRequest(ctx context.Context, id string) (DownloadRequest, error) {
|
func (q *Queries) GetDownloadRequest(ctx context.Context, id int64) (DownloadRequest, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getDownloadRequest, id)
|
row := q.db.QueryRowContext(ctx, getDownloadRequest, id)
|
||||||
var i DownloadRequest
|
var i DownloadRequest
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.LibraryID,
|
|
||||||
&i.Source,
|
|
||||||
&i.WantID,
|
|
||||||
&i.ReleaseMbid,
|
|
||||||
&i.ReleaseGroupMbid,
|
|
||||||
&i.RecordingMbid,
|
|
||||||
&i.Artist,
|
|
||||||
&i.Album,
|
|
||||||
&i.Query,
|
|
||||||
&i.Expected,
|
|
||||||
&i.State,
|
|
||||||
&i.Error,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDownloadWant = `-- name: GetDownloadWant :one
|
|
||||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetDownloadWant(ctx context.Context, id int64) (DownloadWant, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getDownloadWant, id)
|
|
||||||
var i DownloadWant
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Mbid,
|
&i.Mbid,
|
||||||
@@ -272,18 +280,18 @@ func (q *Queries) GetDownloadWant(ctx context.Context, id int64) (DownloadWant,
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDownloadWantByMBID = `-- name: GetDownloadWantByMBID :one
|
const getDownloadRequestByMBID = `-- name: GetDownloadRequestByMBID :one
|
||||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE mbid = ? AND library_id = ?
|
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests WHERE mbid = ? AND library_id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetDownloadWantByMBIDParams struct {
|
type GetDownloadRequestByMBIDParams struct {
|
||||||
Mbid string
|
Mbid string
|
||||||
LibraryID int64
|
LibraryID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetDownloadWantByMBID(ctx context.Context, arg GetDownloadWantByMBIDParams) (DownloadWant, error) {
|
func (q *Queries) GetDownloadRequestByMBID(ctx context.Context, arg GetDownloadRequestByMBIDParams) (DownloadRequest, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getDownloadWantByMBID, arg.Mbid, arg.LibraryID)
|
row := q.db.QueryRowContext(ctx, getDownloadRequestByMBID, arg.Mbid, arg.LibraryID)
|
||||||
var i DownloadWant
|
var i DownloadRequest
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Mbid,
|
&i.Mbid,
|
||||||
@@ -306,19 +314,19 @@ func (q *Queries) GetDownloadWantByMBID(ctx context.Context, arg GetDownloadWant
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const listChildDownloadWants = `-- name: ListChildDownloadWants :many
|
const listChildDownloadRequests = `-- name: ListChildDownloadRequests :many
|
||||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE parent_id = ? ORDER BY id
|
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests WHERE parent_id = ? ORDER BY id
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListChildDownloadWants(ctx context.Context, parentID sql.NullInt64) ([]DownloadWant, error) {
|
func (q *Queries) ListChildDownloadRequests(ctx context.Context, parentID sql.NullInt64) ([]DownloadRequest, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listChildDownloadWants, parentID)
|
rows, err := q.db.QueryContext(ctx, listChildDownloadRequests, parentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []DownloadWant
|
var items []DownloadRequest
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i DownloadWant
|
var i DownloadRequest
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Mbid,
|
&i.Mbid,
|
||||||
@@ -351,17 +359,17 @@ func (q *Queries) ListChildDownloadWants(ctx context.Context, parentID sql.NullI
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listDownloadItemsForRequest = `-- name: ListDownloadItemsForRequest :many
|
const listDownloadItemsForDownload = `-- name: ListDownloadItemsForDownload :many
|
||||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||||
error, created_at, updated_at
|
error, created_at, updated_at
|
||||||
FROM download_items
|
FROM download_items
|
||||||
WHERE request_id = ?
|
WHERE download_id = ?
|
||||||
ORDER BY created_at
|
ORDER BY created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListDownloadItemsForRequest(ctx context.Context, requestID string) ([]DownloadItem, error) {
|
func (q *Queries) ListDownloadItemsForDownload(ctx context.Context, downloadID string) ([]DownloadItem, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listDownloadItemsForRequest, requestID)
|
rows, err := q.db.QueryContext(ctx, listDownloadItemsForDownload, downloadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -371,7 +379,7 @@ func (q *Queries) ListDownloadItemsForRequest(ctx context.Context, requestID str
|
|||||||
var i DownloadItem
|
var i DownloadItem
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.RequestID,
|
&i.DownloadID,
|
||||||
&i.ProviderID,
|
&i.ProviderID,
|
||||||
&i.TransportID,
|
&i.TransportID,
|
||||||
&i.ExternalID,
|
&i.ExternalID,
|
||||||
@@ -436,16 +444,14 @@ func (q *Queries) ListDownloadProviders(ctx context.Context) ([]DownloadProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDownloadRequests = `-- name: ListDownloadRequests :many
|
const listDownloadRequests = `-- name: ListDownloadRequests :many
|
||||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
|
||||||
recording_mbid, artist, album, query, expected, state, error,
|
ORDER BY
|
||||||
created_at, updated_at
|
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
|
||||||
FROM download_requests
|
artist, title
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]DownloadRequest, error) {
|
func (q *Queries) ListDownloadRequests(ctx context.Context) ([]DownloadRequest, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listDownloadRequests, limit)
|
rows, err := q.db.QueryContext(ctx, listDownloadRequests)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -453,11 +459,113 @@ func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]Down
|
|||||||
var items []DownloadRequest
|
var items []DownloadRequest
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i DownloadRequest
|
var i DownloadRequest
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Mbid,
|
||||||
|
&i.Entity,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.Artist,
|
||||||
|
&i.Title,
|
||||||
|
&i.Scope,
|
||||||
|
&i.Secondary,
|
||||||
|
&i.State,
|
||||||
|
&i.ParentID,
|
||||||
|
&i.Attempts,
|
||||||
|
&i.LastError,
|
||||||
|
&i.LastTriedAt,
|
||||||
|
&i.NextTryAt,
|
||||||
|
&i.ExternalIds,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listDownloadRequestsByEntity = `-- name: ListDownloadRequestsByEntity :many
|
||||||
|
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
|
||||||
|
WHERE entity = ? AND state = ?
|
||||||
|
ORDER BY id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListDownloadRequestsByEntityParams struct {
|
||||||
|
Entity string
|
||||||
|
State string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListDownloadRequestsByEntity(ctx context.Context, arg ListDownloadRequestsByEntityParams) ([]DownloadRequest, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listDownloadRequestsByEntity, arg.Entity, arg.State)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []DownloadRequest
|
||||||
|
for rows.Next() {
|
||||||
|
var i DownloadRequest
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Mbid,
|
||||||
|
&i.Entity,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.Artist,
|
||||||
|
&i.Title,
|
||||||
|
&i.Scope,
|
||||||
|
&i.Secondary,
|
||||||
|
&i.State,
|
||||||
|
&i.ParentID,
|
||||||
|
&i.Attempts,
|
||||||
|
&i.LastError,
|
||||||
|
&i.LastTriedAt,
|
||||||
|
&i.NextTryAt,
|
||||||
|
&i.ExternalIds,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listDownloads = `-- name: ListDownloads :many
|
||||||
|
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||||
|
recording_mbid, artist, album, query, expected, state, error,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM download_downloads
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListDownloads(ctx context.Context, limit int64) ([]DownloadDownload, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listDownloads, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []DownloadDownload
|
||||||
|
for rows.Next() {
|
||||||
|
var i DownloadDownload
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.LibraryID,
|
&i.LibraryID,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.WantID,
|
&i.RequestID,
|
||||||
&i.ReleaseMbid,
|
&i.ReleaseMbid,
|
||||||
&i.ReleaseGroupMbid,
|
&i.ReleaseGroupMbid,
|
||||||
&i.RecordingMbid,
|
&i.RecordingMbid,
|
||||||
@@ -483,108 +591,8 @@ func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]Down
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listDownloadWants = `-- name: ListDownloadWants :many
|
const listDueDownloadRequests = `-- name: ListDueDownloadRequests :many
|
||||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
|
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
|
||||||
ORDER BY
|
|
||||||
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
|
|
||||||
artist, title
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) ListDownloadWants(ctx context.Context) ([]DownloadWant, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, listDownloadWants)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []DownloadWant
|
|
||||||
for rows.Next() {
|
|
||||||
var i DownloadWant
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Mbid,
|
|
||||||
&i.Entity,
|
|
||||||
&i.LibraryID,
|
|
||||||
&i.Artist,
|
|
||||||
&i.Title,
|
|
||||||
&i.Scope,
|
|
||||||
&i.Secondary,
|
|
||||||
&i.State,
|
|
||||||
&i.ParentID,
|
|
||||||
&i.Attempts,
|
|
||||||
&i.LastError,
|
|
||||||
&i.LastTriedAt,
|
|
||||||
&i.NextTryAt,
|
|
||||||
&i.ExternalIds,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const listDownloadWantsByEntity = `-- name: ListDownloadWantsByEntity :many
|
|
||||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
|
|
||||||
WHERE entity = ? AND state = ?
|
|
||||||
ORDER BY id
|
|
||||||
`
|
|
||||||
|
|
||||||
type ListDownloadWantsByEntityParams struct {
|
|
||||||
Entity string
|
|
||||||
State string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) ListDownloadWantsByEntity(ctx context.Context, arg ListDownloadWantsByEntityParams) ([]DownloadWant, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, listDownloadWantsByEntity, arg.Entity, arg.State)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []DownloadWant
|
|
||||||
for rows.Next() {
|
|
||||||
var i DownloadWant
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Mbid,
|
|
||||||
&i.Entity,
|
|
||||||
&i.LibraryID,
|
|
||||||
&i.Artist,
|
|
||||||
&i.Title,
|
|
||||||
&i.Scope,
|
|
||||||
&i.Secondary,
|
|
||||||
&i.State,
|
|
||||||
&i.ParentID,
|
|
||||||
&i.Attempts,
|
|
||||||
&i.LastError,
|
|
||||||
&i.LastTriedAt,
|
|
||||||
&i.NextTryAt,
|
|
||||||
&i.ExternalIds,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const listDueDownloadWants = `-- name: ListDueDownloadWants :many
|
|
||||||
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
|
|
||||||
WHERE state = 'wanted'
|
WHERE state = 'wanted'
|
||||||
AND entity <> 'artist'
|
AND entity <> 'artist'
|
||||||
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
|
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
|
||||||
@@ -595,15 +603,15 @@ LIMIT ?
|
|||||||
// Everything the reconciler should act on this pass: wanted, not an
|
// Everything the reconciler should act on this pass: wanted, not an
|
||||||
// artist subscription (those expand rather than download), and either
|
// artist subscription (those expand rather than download), and either
|
||||||
// never tried or past its backoff.
|
// never tried or past its backoff.
|
||||||
func (q *Queries) ListDueDownloadWants(ctx context.Context, limit int64) ([]DownloadWant, error) {
|
func (q *Queries) ListDueDownloadRequests(ctx context.Context, limit int64) ([]DownloadRequest, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listDueDownloadWants, limit)
|
rows, err := q.db.QueryContext(ctx, listDueDownloadRequests, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []DownloadWant
|
var items []DownloadRequest
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i DownloadWant
|
var i DownloadRequest
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Mbid,
|
&i.Mbid,
|
||||||
@@ -637,7 +645,7 @@ func (q *Queries) ListDueDownloadWants(ctx context.Context, limit int64) ([]Down
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLiveDownloadItems = `-- name: ListLiveDownloadItems :many
|
const listLiveDownloadItems = `-- name: ListLiveDownloadItems :many
|
||||||
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
|
SELECT id, download_id, provider_id, transport_id, external_id, candidate,
|
||||||
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
state, staging_dir, bytes_done, bytes_total, imported_paths,
|
||||||
error, created_at, updated_at
|
error, created_at, updated_at
|
||||||
FROM download_items
|
FROM download_items
|
||||||
@@ -656,7 +664,7 @@ func (q *Queries) ListLiveDownloadItems(ctx context.Context) ([]DownloadItem, er
|
|||||||
var i DownloadItem
|
var i DownloadItem
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.RequestID,
|
&i.DownloadID,
|
||||||
&i.ProviderID,
|
&i.ProviderID,
|
||||||
&i.TransportID,
|
&i.TransportID,
|
||||||
&i.ExternalID,
|
&i.ExternalID,
|
||||||
@@ -683,29 +691,29 @@ func (q *Queries) ListLiveDownloadItems(ctx context.Context) ([]DownloadItem, er
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listLiveDownloadRequests = `-- name: ListLiveDownloadRequests :many
|
const listLiveDownloads = `-- name: ListLiveDownloads :many
|
||||||
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
|
SELECT id, library_id, source, request_id, release_mbid, release_group_mbid,
|
||||||
recording_mbid, artist, album, query, expected, state, error,
|
recording_mbid, artist, album, query, expected, state, error,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM download_requests
|
FROM download_downloads
|
||||||
WHERE state NOT IN ('complete', 'cancelled', 'failed')
|
WHERE state NOT IN ('complete', 'cancelled', 'failed')
|
||||||
ORDER BY created_at
|
ORDER BY created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListLiveDownloadRequests(ctx context.Context) ([]DownloadRequest, error) {
|
func (q *Queries) ListLiveDownloads(ctx context.Context) ([]DownloadDownload, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listLiveDownloadRequests)
|
rows, err := q.db.QueryContext(ctx, listLiveDownloads)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []DownloadRequest
|
var items []DownloadDownload
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i DownloadRequest
|
var i DownloadDownload
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.LibraryID,
|
&i.LibraryID,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.WantID,
|
&i.RequestID,
|
||||||
&i.ReleaseMbid,
|
&i.ReleaseMbid,
|
||||||
&i.ReleaseGroupMbid,
|
&i.ReleaseGroupMbid,
|
||||||
&i.RecordingMbid,
|
&i.RecordingMbid,
|
||||||
@@ -731,8 +739,8 @@ func (q *Queries) ListLiveDownloadRequests(ctx context.Context) ([]DownloadReque
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordDownloadWantAttempt = `-- name: RecordDownloadWantAttempt :exec
|
const recordDownloadRequestAttempt = `-- name: RecordDownloadRequestAttempt :exec
|
||||||
UPDATE download_wants
|
UPDATE download_requests
|
||||||
SET attempts = attempts + 1,
|
SET attempts = attempts + 1,
|
||||||
last_error = ?,
|
last_error = ?,
|
||||||
last_tried_at = CURRENT_TIMESTAMP,
|
last_tried_at = CURRENT_TIMESTAMP,
|
||||||
@@ -741,26 +749,26 @@ SET attempts = attempts + 1,
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type RecordDownloadWantAttemptParams struct {
|
type RecordDownloadRequestAttemptParams struct {
|
||||||
LastError string
|
LastError string
|
||||||
NextTryAt sql.NullTime
|
NextTryAt sql.NullTime
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) RecordDownloadWantAttempt(ctx context.Context, arg RecordDownloadWantAttemptParams) error {
|
func (q *Queries) RecordDownloadRequestAttempt(ctx context.Context, arg RecordDownloadRequestAttemptParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, recordDownloadWantAttempt, arg.LastError, arg.NextTryAt, arg.ID)
|
_, err := q.db.ExecContext(ctx, recordDownloadRequestAttempt, arg.LastError, arg.NextTryAt, arg.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const satisfyDownloadWant = `-- name: SatisfyDownloadWant :exec
|
const satisfyDownloadRequest = `-- name: SatisfyDownloadRequest :exec
|
||||||
UPDATE download_wants
|
UPDATE download_requests
|
||||||
SET state = 'satisfied', last_error = '', next_try_at = NULL,
|
SET state = 'satisfied', last_error = '', next_try_at = NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SatisfyDownloadWant(ctx context.Context, id int64) error {
|
func (q *Queries) SatisfyDownloadRequest(ctx context.Context, id int64) error {
|
||||||
_, err := q.db.ExecContext(ctx, satisfyDownloadWant, id)
|
_, err := q.db.ExecContext(ctx, satisfyDownloadRequest, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,53 +839,53 @@ func (q *Queries) SetDownloadItemState(ctx context.Context, arg SetDownloadItemS
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const setDownloadRequestState = `-- name: SetDownloadRequestState :exec
|
const setDownloadRequestExternalIDs = `-- name: SetDownloadRequestExternalIDs :exec
|
||||||
UPDATE download_requests
|
UPDATE download_requests
|
||||||
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
type SetDownloadRequestStateParams struct {
|
|
||||||
State string
|
|
||||||
Error string
|
|
||||||
ID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) SetDownloadRequestState(ctx context.Context, arg SetDownloadRequestStateParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, setDownloadRequestState, arg.State, arg.Error, arg.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const setDownloadWantExternalIDs = `-- name: SetDownloadWantExternalIDs :exec
|
|
||||||
UPDATE download_wants
|
|
||||||
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
|
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetDownloadWantExternalIDsParams struct {
|
type SetDownloadRequestExternalIDsParams struct {
|
||||||
ExternalIds string
|
ExternalIds string
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SetDownloadWantExternalIDs(ctx context.Context, arg SetDownloadWantExternalIDsParams) error {
|
func (q *Queries) SetDownloadRequestExternalIDs(ctx context.Context, arg SetDownloadRequestExternalIDsParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, setDownloadWantExternalIDs, arg.ExternalIds, arg.ID)
|
_, err := q.db.ExecContext(ctx, setDownloadRequestExternalIDs, arg.ExternalIds, arg.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const setDownloadWantState = `-- name: SetDownloadWantState :exec
|
const setDownloadRequestState = `-- name: SetDownloadRequestState :exec
|
||||||
UPDATE download_wants
|
UPDATE download_requests
|
||||||
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
|
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetDownloadWantStateParams struct {
|
type SetDownloadRequestStateParams struct {
|
||||||
State string
|
State string
|
||||||
LastError string
|
LastError string
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SetDownloadWantState(ctx context.Context, arg SetDownloadWantStateParams) error {
|
func (q *Queries) SetDownloadRequestState(ctx context.Context, arg SetDownloadRequestStateParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, setDownloadWantState, arg.State, arg.LastError, arg.ID)
|
_, err := q.db.ExecContext(ctx, setDownloadRequestState, arg.State, arg.LastError, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDownloadState = `-- name: SetDownloadState :exec
|
||||||
|
UPDATE download_downloads
|
||||||
|
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDownloadStateParams struct {
|
||||||
|
State string
|
||||||
|
Error string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetDownloadState(ctx context.Context, arg SetDownloadStateParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setDownloadState, arg.State, arg.Error, arg.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,25 +914,25 @@ func (q *Queries) UpdateDownloadProvider(ctx context.Context, arg UpdateDownload
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsertDownloadWant = `-- name: UpsertDownloadWant :one
|
const upsertDownloadRequest = `-- name: UpsertDownloadRequest :one
|
||||||
|
|
||||||
INSERT INTO download_wants (
|
INSERT INTO download_requests (
|
||||||
mbid, entity, library_id, artist, title, scope, secondary,
|
mbid, entity, library_id, artist, title, scope, secondary,
|
||||||
parent_id, next_try_at
|
parent_id, next_try_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(mbid, library_id) DO UPDATE SET
|
ON CONFLICT(mbid, library_id) DO UPDATE SET
|
||||||
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
|
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
|
||||||
ELSE download_wants.artist END,
|
ELSE download_requests.artist END,
|
||||||
title = CASE WHEN excluded.title <> '' THEN excluded.title
|
title = CASE WHEN excluded.title <> '' THEN excluded.title
|
||||||
ELSE download_wants.title END,
|
ELSE download_requests.title END,
|
||||||
scope = excluded.scope,
|
scope = excluded.scope,
|
||||||
secondary = excluded.secondary,
|
secondary = excluded.secondary,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertDownloadWantParams struct {
|
type UpsertDownloadRequestParams struct {
|
||||||
Mbid string
|
Mbid string
|
||||||
Entity string
|
Entity string
|
||||||
LibraryID int64
|
LibraryID int64
|
||||||
@@ -936,14 +944,14 @@ type UpsertDownloadWantParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Wants
|
// Requests (durable "I asked for this" records)
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Adding something already wanted is not an error and must not reset
|
// Adding something already requested is not an error and must not
|
||||||
// the retry clock, so the conflict path only refreshes display text and
|
// reset the retry clock, so the conflict path only refreshes display
|
||||||
// un-pauses nothing. scope and secondary are updated because asking
|
// text and un-pauses nothing. scope and secondary are updated because
|
||||||
// again with a wider scope is a real change of intent.
|
// asking again with a wider scope is a real change of intent.
|
||||||
func (q *Queries) UpsertDownloadWant(ctx context.Context, arg UpsertDownloadWantParams) (int64, error) {
|
func (q *Queries) UpsertDownloadRequest(ctx context.Context, arg UpsertDownloadRequestParams) (int64, error) {
|
||||||
row := q.db.QueryRowContext(ctx, upsertDownloadWant,
|
row := q.db.QueryRowContext(ctx, upsertDownloadRequest,
|
||||||
arg.Mbid,
|
arg.Mbid,
|
||||||
arg.Entity,
|
arg.Entity,
|
||||||
arg.LibraryID,
|
arg.LibraryID,
|
||||||
|
|||||||
@@ -74,9 +74,27 @@ type CoverArt struct {
|
|||||||
MimeType string
|
MimeType string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DownloadDownload struct {
|
||||||
|
ID string
|
||||||
|
LibraryID int64
|
||||||
|
Source string
|
||||||
|
RequestID sql.NullInt64
|
||||||
|
ReleaseMbid sql.NullString
|
||||||
|
ReleaseGroupMbid sql.NullString
|
||||||
|
RecordingMbid sql.NullString
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
Query string
|
||||||
|
Expected string
|
||||||
|
State string
|
||||||
|
Error string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type DownloadItem struct {
|
type DownloadItem struct {
|
||||||
ID string
|
ID string
|
||||||
RequestID string
|
DownloadID string
|
||||||
ProviderID int64
|
ProviderID int64
|
||||||
TransportID sql.NullInt64
|
TransportID sql.NullInt64
|
||||||
ExternalID string
|
ExternalID string
|
||||||
@@ -102,24 +120,6 @@ type DownloadProvider struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DownloadRequest struct {
|
type DownloadRequest struct {
|
||||||
ID string
|
|
||||||
LibraryID int64
|
|
||||||
Source string
|
|
||||||
WantID sql.NullInt64
|
|
||||||
ReleaseMbid sql.NullString
|
|
||||||
ReleaseGroupMbid sql.NullString
|
|
||||||
RecordingMbid sql.NullString
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
Query string
|
|
||||||
Expected string
|
|
||||||
State string
|
|
||||||
Error string
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type DownloadWant struct {
|
|
||||||
ID int64
|
ID int64
|
||||||
Mbid string
|
Mbid string
|
||||||
Entity string
|
Entity string
|
||||||
|
|||||||
+15
-13
@@ -142,10 +142,17 @@ var tables = []Table{
|
|||||||
"only; the sized variants beside it are derived filenames and " +
|
"only; the sized variants beside it are derived filenames and " +
|
||||||
"must be expanded when deleting (see library.coverArtFileSet).",
|
"must be expanded when deleting (see library.coverArtFileSet).",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "download_downloads", Kind: Authored, Lifetime: Cascade,
|
||||||
|
Note: "One row per 'go find me this', i.e. one search-and-grab " +
|
||||||
|
"attempt. Cascades from libraries, and cascades onward to " +
|
||||||
|
"download_items. Terminal rows are history the user clears " +
|
||||||
|
"explicitly.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "download_items", Kind: Authored, Lifetime: Cascade,
|
Name: "download_items", Kind: Authored, Lifetime: Cascade,
|
||||||
Note: "One grab attempt per row, with the ranked candidate stored " +
|
Note: "One grab attempt per row, with the ranked candidate stored " +
|
||||||
"as JSON. Cascades from download_requests. The candidate blob " +
|
"as JSON. Cascades from download_downloads. The candidate blob " +
|
||||||
"is kept rather than re-derived because a provider's result " +
|
"is kept rather than re-derived because a provider's result " +
|
||||||
"set is ephemeral — the peer that had the files may be gone, " +
|
"set is ephemeral — the peer that had the files may be gone, " +
|
||||||
"and the row still has to explain why it was chosen.",
|
"and the row still has to explain why it was chosen.",
|
||||||
@@ -159,18 +166,13 @@ var tables = []Table{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "download_requests", Kind: Authored, Lifetime: Cascade,
|
Name: "download_requests", Kind: Authored, Lifetime: Cascade,
|
||||||
Note: "One row per 'go find me this'. Cascades from libraries, " +
|
Note: "The durable request list: one MBID per row, plus retry " +
|
||||||
"and cascades onward to download_items. Terminal rows are " +
|
"bookkeeping. Cascades from libraries, and from a parent " +
|
||||||
"history the user clears explicitly.",
|
"artist request to the album requests it derived. Unlike " +
|
||||||
},
|
"download_downloads these are not history — a request " +
|
||||||
{
|
"outlives every download attempt made on it and is only " +
|
||||||
Name: "download_wants", Kind: Authored, Lifetime: Cascade,
|
"removed by the user or by the library coming to own what it " +
|
||||||
Note: "The wanted list: one MBID per row, plus retry bookkeeping. " +
|
"names.",
|
||||||
"Cascades from libraries, and from a parent artist want to " +
|
|
||||||
"the album wants it derived. Unlike download_requests these " +
|
|
||||||
"are not history — a want outlives every attempt made on it " +
|
|
||||||
"and is only removed by the user or by the library coming " +
|
|
||||||
"to own what it names.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "explore_champion_fts", Kind: Cache, Lifetime: Retained, FTS: true,
|
Name: "explore_champion_fts", Kind: Cache, Lifetime: Retained, FTS: true,
|
||||||
|
|||||||
@@ -223,22 +223,23 @@ func TestAuthoredCascadesAreDeliberate(t *testing.T) {
|
|||||||
|
|
||||||
// Download history is scoped to the library it imported into.
|
// Download history is scoped to the library it imported into.
|
||||||
// When that library is removed the files it acquired go with
|
// When that library is removed the files it acquired go with
|
||||||
// it, so a request describing "fetch this into library 3" has
|
// it, so a download describing "fetch this into library 3" has
|
||||||
// nothing left to mean. Keeping the rows would leave history
|
// nothing left to mean. Keeping the rows would leave history
|
||||||
// pointing at a library the user deleted.
|
// pointing at a library the user deleted.
|
||||||
"download_requests": true,
|
"download_downloads": true,
|
||||||
|
|
||||||
// Items belong to their request and have no independent
|
// Items belong to their download and have no independent
|
||||||
// meaning; they cascade with it.
|
// meaning; they cascade with it.
|
||||||
"download_items": true,
|
"download_items": true,
|
||||||
|
|
||||||
// A want says "put this in library 3". Delete that library and
|
// A request says "put this in library 3". Delete that library
|
||||||
// there is no longer anywhere for it to go, so the want has
|
// and there is no longer anywhere for it to go, so the request
|
||||||
// nothing left to mean — the same reasoning as its requests.
|
// has nothing left to mean — the same reasoning as its
|
||||||
// The second cascade, artist want to derived album wants, is
|
// downloads. The second cascade, artist request to derived
|
||||||
// the point of the subscription: unsubscribing from an artist
|
// album requests, is the point of the subscription:
|
||||||
// must stop the albums it queued on the user's behalf.
|
// unsubscribing from an artist must stop the albums it queued
|
||||||
"download_wants": true,
|
// on the user's behalf.
|
||||||
|
"download_requests": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range datamap.ByKind(datamap.Authored) {
|
for _, entry := range datamap.ByKind(datamap.Authored) {
|
||||||
|
|||||||
@@ -84,17 +84,17 @@ func TestPerProviderCapSerializesTransfers(t *testing.T) {
|
|||||||
|
|
||||||
// Three requests against the same one-at-a-time provider.
|
// Three requests against the same one-at-a-time provider.
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
req.ID = "req-" + string(rune('a'+i))
|
dl.ID = "dl-" + string(rune('a'+i))
|
||||||
|
|
||||||
if err := f.store.CreateRequest(ctx, req); err != nil {
|
if err := f.store.CreateDownload(ctx, dl); err != nil {
|
||||||
t.Fatalf("CreateRequest: %v", err)
|
t.Fatalf("CreateDownload: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate := slow.Candidates[0]
|
candidate := slow.Candidates[0]
|
||||||
candidate.ProviderID = 1
|
candidate.ProviderID = 1
|
||||||
|
|
||||||
go f.manager.grab(ctx, req, candidate, nil)
|
go f.manager.grab(ctx, dl, candidate, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give all three a chance to reach the transport, then check how
|
// Give all three a chance to reach the transport, then check how
|
||||||
@@ -139,17 +139,17 @@ func TestPerProviderCapAllowsParallelWhereSafe(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
req.ID = "req-" + string(rune('a'+i))
|
dl.ID = "dl-" + string(rune('a'+i))
|
||||||
|
|
||||||
if err := f.store.CreateRequest(ctx, req); err != nil {
|
if err := f.store.CreateDownload(ctx, dl); err != nil {
|
||||||
t.Fatalf("CreateRequest: %v", err)
|
t.Fatalf("CreateDownload: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate := fast.Candidates[0]
|
candidate := fast.Candidates[0]
|
||||||
candidate.ProviderID = 1
|
candidate.ProviderID = 1
|
||||||
|
|
||||||
go f.manager.grab(ctx, req, candidate, nil)
|
go f.manager.grab(ctx, dl, candidate, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
waitFor(
|
waitFor(
|
||||||
|
|||||||
@@ -33,6 +33,34 @@ type UserConfig struct {
|
|||||||
// for. A large list should be worked through steadily rather than
|
// for. A large list should be worked through steadily rather than
|
||||||
// in one burst that every provider sees as a flood.
|
// in one burst that every provider sees as a flood.
|
||||||
WantedBatch int `toml:"WantedBatch"`
|
WantedBatch int `toml:"WantedBatch"`
|
||||||
|
|
||||||
|
// MinFileSizeMB, MaxFileSizeMB and PreferredFileSizeMB bound and
|
||||||
|
// nudge what auto-pick (interactive or via the request list) may
|
||||||
|
// grab without asking. Zero on any of them is permissive: see
|
||||||
|
// AutoDownloadPrefs.
|
||||||
|
MinFileSizeMB int `toml:"MinFileSizeMB"`
|
||||||
|
MaxFileSizeMB int `toml:"MaxFileSizeMB"`
|
||||||
|
PreferredFileSizeMB int `toml:"PreferredFileSizeMB"`
|
||||||
|
|
||||||
|
// AllowedFormats restricts auto-pick to these formats. Empty means
|
||||||
|
// no restriction. Values are Format strings ("flac", "mp3", ...).
|
||||||
|
AllowedFormats []string `toml:"AllowedFormats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoDownloadPrefs converts the persisted guardrail fields to the
|
||||||
|
// runtime type Manager and the ranker consume.
|
||||||
|
func (c *UserConfig) AutoDownloadPrefs() AutoDownloadPrefs {
|
||||||
|
formats := make([]Format, 0, len(c.AllowedFormats))
|
||||||
|
for _, f := range c.AllowedFormats {
|
||||||
|
formats = append(formats, Format(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
return AutoDownloadPrefs{
|
||||||
|
MinSizeMB: c.MinFileSizeMB,
|
||||||
|
MaxSizeMB: c.MaxFileSizeMB,
|
||||||
|
PreferredSizeMB: c.PreferredFileSizeMB,
|
||||||
|
AllowedFormats: formats,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults fills unset fields.
|
// ApplyDefaults fills unset fields.
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (f *FakeProvider) Close() error {
|
|||||||
// Search returns the configured candidates.
|
// Search returns the configured candidates.
|
||||||
func (f *FakeProvider) Search(
|
func (f *FakeProvider) Search(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
_ Request,
|
_ Download,
|
||||||
) ([]Candidate, error) {
|
) ([]Candidate, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.SearchCalls++
|
f.SearchCalls++
|
||||||
@@ -208,7 +208,7 @@ var errNoDelegateStatus = errors.New("fake: no delegate status configured")
|
|||||||
// Delegate records the call and returns a fixed external ID.
|
// Delegate records the call and returns a fixed external ID.
|
||||||
func (f *FakeProvider) Delegate(
|
func (f *FakeProvider) Delegate(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_ Request,
|
_ Download,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ type ImportResult struct {
|
|||||||
// retry or inspect it; only a fully successful import releases staging.
|
// retry or inspect it; only a fully successful import releases staging.
|
||||||
func (i *Importer) Import(
|
func (i *Importer) Import(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
result Result,
|
result Result,
|
||||||
opts ImportOptions,
|
opts ImportOptions,
|
||||||
) (ImportResult, error) {
|
) (ImportResult, error) {
|
||||||
@@ -139,13 +139,13 @@ func (i *Importer) Import(
|
|||||||
return ImportResult{}, ErrNoAudio
|
return ImportResult{}, ErrNoAudio
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := checkCompleteness(len(audio), req); err != nil {
|
if err := checkCompleteness(len(audio), dl); err != nil {
|
||||||
return ImportResult{}, err
|
return ImportResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Align staged files to the expected tracklist so tags and
|
// Align staged files to the expected tracklist so tags and
|
||||||
// filenames reflect the release, not the uploader's naming.
|
// filenames reflect the release, not the uploader's naming.
|
||||||
plan := i.planFiles(audio, req)
|
plan := i.planFiles(audio, dl)
|
||||||
|
|
||||||
out := ImportResult{
|
out := ImportResult{
|
||||||
Paths: make([]string, 0, len(plan)),
|
Paths: make([]string, 0, len(plan)),
|
||||||
@@ -158,7 +158,7 @@ func (i *Importer) Import(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.WriteTags {
|
if opts.WriteTags {
|
||||||
if err := i.tagFile(p, req); err != nil {
|
if err := i.tagFile(p, dl); err != nil {
|
||||||
// A file that cannot be tagged is still worth importing
|
// A file that cannot be tagged is still worth importing
|
||||||
// — the scanner will read whatever tags it has, and the
|
// — the scanner will read whatever tags it has, and the
|
||||||
// autotag queue can pick it up later. Losing the whole
|
// autotag queue can pick it up later. Losing the whole
|
||||||
@@ -173,7 +173,7 @@ func (i *Importer) Import(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dest, err := i.destinationFor(p, req, opts)
|
dest, err := i.destinationFor(p, dl, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ type plannedFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// planFiles aligns staged files to the expected tracklist.
|
// planFiles aligns staged files to the expected tracklist.
|
||||||
func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
|
func (i *Importer) planFiles(audio []string, dl Download) []plannedFile {
|
||||||
files := make([]CandidateFile, 0, len(audio))
|
files := make([]CandidateFile, 0, len(audio))
|
||||||
|
|
||||||
for _, a := range audio {
|
for _, a := range audio {
|
||||||
@@ -211,10 +211,10 @@ func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
matched, _ := matchFiles(files, req.Expected)
|
matched, _ := matchFiles(files, dl.Expected)
|
||||||
|
|
||||||
byPosition := make(map[int]ExpectedTrack, len(req.Expected))
|
byPosition := make(map[int]ExpectedTrack, len(dl.Expected))
|
||||||
for _, e := range req.Expected {
|
for _, e := range dl.Expected {
|
||||||
byPosition[e.Position] = e
|
byPosition[e.Position] = e
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,14 +253,14 @@ func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// tagFile writes the release's metadata onto a staged file.
|
// tagFile writes the release's metadata onto a staged file.
|
||||||
func (i *Importer) tagFile(p plannedFile, req Request) error {
|
func (i *Importer) tagFile(p plannedFile, dl Download) error {
|
||||||
if i.tags == nil || !p.Matched {
|
if i.tags == nil || !p.Matched {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
changes := tagwriter.TagChanges{
|
changes := tagwriter.TagChanges{
|
||||||
tagwriter.FieldAlbum: req.Album,
|
tagwriter.FieldAlbum: dl.Album,
|
||||||
tagwriter.FieldAlbumArtist: req.Artist,
|
tagwriter.FieldAlbumArtist: dl.Artist,
|
||||||
tagwriter.FieldTitle: p.Track.Title,
|
tagwriter.FieldTitle: p.Track.Title,
|
||||||
tagwriter.FieldTrackNumber: p.Track.Position,
|
tagwriter.FieldTrackNumber: p.Track.Position,
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ func (i *Importer) tagFile(p plannedFile, req Request) error {
|
|||||||
if p.Track.Artist != "" {
|
if p.Track.Artist != "" {
|
||||||
changes[tagwriter.FieldArtist] = p.Track.Artist
|
changes[tagwriter.FieldArtist] = p.Track.Artist
|
||||||
} else {
|
} else {
|
||||||
changes[tagwriter.FieldArtist] = req.Artist
|
changes[tagwriter.FieldArtist] = dl.Artist
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.Track.DiscNumber > 0 {
|
if p.Track.DiscNumber > 0 {
|
||||||
@@ -285,7 +285,7 @@ func (i *Importer) tagFile(p plannedFile, req Request) error {
|
|||||||
// destinationFor computes a file's library path from the template.
|
// destinationFor computes a file's library path from the template.
|
||||||
func (i *Importer) destinationFor(
|
func (i *Importer) destinationFor(
|
||||||
p plannedFile,
|
p plannedFile,
|
||||||
req Request,
|
dl Download,
|
||||||
opts ImportOptions,
|
opts ImportOptions,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
if opts.LibraryRoot == "" {
|
if opts.LibraryRoot == "" {
|
||||||
@@ -311,13 +311,13 @@ func (i *Importer) destinationFor(
|
|||||||
|
|
||||||
artist := p.Track.Artist
|
artist := p.Track.Artist
|
||||||
if artist == "" {
|
if artist == "" {
|
||||||
artist = req.Artist
|
artist = dl.Artist
|
||||||
}
|
}
|
||||||
|
|
||||||
repl := strings.NewReplacer(
|
repl := strings.NewReplacer(
|
||||||
"{albumartist}", sanitizePathPart(fallback(req.Artist, "Unknown Artist")),
|
"{albumartist}", sanitizePathPart(fallback(dl.Artist, "Unknown Artist")),
|
||||||
"{artist}", sanitizePathPart(fallback(artist, "Unknown Artist")),
|
"{artist}", sanitizePathPart(fallback(artist, "Unknown Artist")),
|
||||||
"{album}", sanitizePathPart(fallback(req.Album, "Unknown Album")),
|
"{album}", sanitizePathPart(fallback(dl.Album, "Unknown Album")),
|
||||||
"{title}", sanitizePathPart(title),
|
"{title}", sanitizePathPart(title),
|
||||||
"{track}", trackToken(p.Track.Position),
|
"{track}", trackToken(p.Track.Position),
|
||||||
"{disc}", strconv.Itoa(p.Track.DiscNumber),
|
"{disc}", strconv.Itoa(p.Track.DiscNumber),
|
||||||
@@ -443,16 +443,16 @@ func copyFile(src, dest string) error {
|
|||||||
|
|
||||||
// checkCompleteness rejects an anchored download that is missing too
|
// checkCompleteness rejects an anchored download that is missing too
|
||||||
// much of its tracklist.
|
// much of its tracklist.
|
||||||
func checkCompleteness(got int, req Request) error {
|
func checkCompleteness(got int, dl Download) error {
|
||||||
if len(req.Expected) == 0 {
|
if len(dl.Expected) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ratio := float64(got) / float64(len(req.Expected))
|
ratio := float64(got) / float64(len(dl.Expected))
|
||||||
if ratio < minCompleteness {
|
if ratio < minCompleteness {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: got %d of %d tracks",
|
"%w: got %d of %d tracks",
|
||||||
ErrTooIncomplete, got, len(req.Expected),
|
ErrTooIncomplete, got, len(dl.Expected),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ func newImportFixture(t *testing.T, names ...string) importFixture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fourTrackRequest() Request {
|
func fourTrackDownload() Download {
|
||||||
return Request{
|
return Download{
|
||||||
ID: "req-1",
|
ID: "dl-1",
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
ReleaseMBID: "mbid-1",
|
ReleaseMBID: "mbid-1",
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
@@ -145,7 +145,7 @@ func TestImportPlacesAndTagsFiles(t *testing.T) {
|
|||||||
|
|
||||||
got, err := f.importer.Import(
|
got, err := f.importer.Import(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
fourTrackRequest(),
|
fourTrackDownload(),
|
||||||
Result{Dir: f.dir, Files: f.files},
|
Result{Dir: f.dir, Files: f.files},
|
||||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||||
)
|
)
|
||||||
@@ -184,12 +184,12 @@ func TestImportTagsBeforeMoving(t *testing.T) {
|
|||||||
|
|
||||||
f := newImportFixture(t, "01 - Airbag.flac")
|
f := newImportFixture(t, "01 - Airbag.flac")
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
req.Expected = req.Expected[:1]
|
dl.Expected = dl.Expected[:1]
|
||||||
|
|
||||||
if _, err := f.importer.Import(
|
if _, err := f.importer.Import(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
req,
|
dl,
|
||||||
Result{Dir: f.dir, Files: f.files},
|
Result{Dir: f.dir, Files: f.files},
|
||||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -227,7 +227,7 @@ func TestImportContinuesWhenTaggingFails(t *testing.T) {
|
|||||||
|
|
||||||
got, err := f.importer.Import(
|
got, err := f.importer.Import(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
fourTrackRequest(),
|
fourTrackDownload(),
|
||||||
Result{Dir: f.dir, Files: f.files},
|
Result{Dir: f.dir, Files: f.files},
|
||||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||||
)
|
)
|
||||||
@@ -251,7 +251,7 @@ func TestImportRejectsTooIncomplete(t *testing.T) {
|
|||||||
|
|
||||||
_, err := f.importer.Import(
|
_, err := f.importer.Import(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
fourTrackRequest(),
|
fourTrackDownload(),
|
||||||
Result{Dir: f.dir, Files: f.files},
|
Result{Dir: f.dir, Files: f.files},
|
||||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||||
)
|
)
|
||||||
@@ -273,7 +273,7 @@ func TestImportRejectsNoAudio(t *testing.T) {
|
|||||||
|
|
||||||
_, err := f.importer.Import(
|
_, err := f.importer.Import(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
fourTrackRequest(),
|
fourTrackDownload(),
|
||||||
Result{Dir: f.dir, Files: f.files},
|
Result{Dir: f.dir, Files: f.files},
|
||||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||||
)
|
)
|
||||||
@@ -297,7 +297,7 @@ func TestImportSkipsNonAudioFiles(t *testing.T) {
|
|||||||
|
|
||||||
got, err := f.importer.Import(
|
got, err := f.importer.Import(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
fourTrackRequest(),
|
fourTrackDownload(),
|
||||||
Result{Dir: f.dir, Files: f.files},
|
Result{Dir: f.dir, Files: f.files},
|
||||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||||
)
|
)
|
||||||
@@ -322,8 +322,8 @@ func TestImportNeverOverwrites(t *testing.T) {
|
|||||||
|
|
||||||
f := newImportFixture(t, "01 - Airbag.flac")
|
f := newImportFixture(t, "01 - Airbag.flac")
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
req.Expected = req.Expected[:1]
|
dl.Expected = dl.Expected[:1]
|
||||||
|
|
||||||
existing := filepath.Join(
|
existing := filepath.Join(
|
||||||
f.root, "Radiohead", "OK Computer", "01 Airbag.flac",
|
f.root, "Radiohead", "OK Computer", "01 Airbag.flac",
|
||||||
@@ -339,7 +339,7 @@ func TestImportNeverOverwrites(t *testing.T) {
|
|||||||
|
|
||||||
got, err := f.importer.Import(
|
got, err := f.importer.Import(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
req,
|
dl,
|
||||||
Result{Dir: f.dir, Files: f.files},
|
Result{Dir: f.dir, Files: f.files},
|
||||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||||
)
|
)
|
||||||
@@ -366,12 +366,12 @@ func TestImportCustomPathTemplate(t *testing.T) {
|
|||||||
|
|
||||||
f := newImportFixture(t, "01 - Airbag.flac")
|
f := newImportFixture(t, "01 - Airbag.flac")
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
req.Expected = req.Expected[:1]
|
dl.Expected = dl.Expected[:1]
|
||||||
|
|
||||||
got, err := f.importer.Import(
|
got, err := f.importer.Import(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
req,
|
dl,
|
||||||
Result{Dir: f.dir, Files: f.files},
|
Result{Dir: f.dir, Files: f.files},
|
||||||
ImportOptions{
|
ImportOptions{
|
||||||
LibraryRoot: f.root,
|
LibraryRoot: f.root,
|
||||||
@@ -423,12 +423,12 @@ func TestImportRequiresLibraryRoot(t *testing.T) {
|
|||||||
|
|
||||||
f := newImportFixture(t, "01 - Airbag.flac")
|
f := newImportFixture(t, "01 - Airbag.flac")
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
req.Expected = req.Expected[:1]
|
dl.Expected = dl.Expected[:1]
|
||||||
|
|
||||||
_, err := f.importer.Import(
|
_, err := f.importer.Import(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
req,
|
dl,
|
||||||
Result{Dir: f.dir, Files: f.files},
|
Result{Dir: f.dir, Files: f.files},
|
||||||
ImportOptions{WriteTags: true},
|
ImportOptions{WriteTags: true},
|
||||||
)
|
)
|
||||||
|
|||||||
+132
-102
@@ -124,6 +124,10 @@ type Manager struct {
|
|||||||
optsMu sync.RWMutex
|
optsMu sync.RWMutex
|
||||||
opts ImportOptions
|
opts ImportOptions
|
||||||
|
|
||||||
|
// prefs gates and scores what auto-pick may grab without asking.
|
||||||
|
prefsMu sync.RWMutex
|
||||||
|
prefs AutoDownloadPrefs
|
||||||
|
|
||||||
// providers caches built provider instances by config ID. Rebuilt
|
// providers caches built provider instances by config ID. Rebuilt
|
||||||
// whenever config changes, so a settings edit takes effect without
|
// whenever config changes, so a settings edit takes effect without
|
||||||
// a restart.
|
// a restart.
|
||||||
@@ -206,6 +210,32 @@ func (m *Manager) importOptions() ImportOptions {
|
|||||||
return m.opts
|
return m.opts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPreferences configures the auto-download guardrails: the size
|
||||||
|
// window and format list AutoPickable is allowed to grab without
|
||||||
|
// asking. Live-settable so a settings change takes effect immediately,
|
||||||
|
// the same way SetImportOptions does.
|
||||||
|
func (m *Manager) SetPreferences(prefs AutoDownloadPrefs) {
|
||||||
|
m.prefsMu.Lock()
|
||||||
|
defer m.prefsMu.Unlock()
|
||||||
|
|
||||||
|
m.prefs = prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferences returns the current auto-download guardrails.
|
||||||
|
func (m *Manager) preferences() AutoDownloadPrefs {
|
||||||
|
m.prefsMu.RLock()
|
||||||
|
defer m.prefsMu.RUnlock()
|
||||||
|
|
||||||
|
return m.prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoPickable wraps the package function with this manager's current
|
||||||
|
// preferences, so callers do not need direct field access to apply the
|
||||||
|
// live guardrails.
|
||||||
|
func (m *Manager) AutoPickable(dl Download, ranked []Candidate) bool {
|
||||||
|
return AutoPickable(dl, ranked, m.preferences())
|
||||||
|
}
|
||||||
|
|
||||||
// Reload rebuilds every provider from stored config. Called at startup
|
// Reload rebuilds every provider from stored config. Called at startup
|
||||||
// and after any provider settings change.
|
// and after any provider settings change.
|
||||||
//
|
//
|
||||||
@@ -293,12 +323,12 @@ func (m *Manager) Sweep(ctx context.Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.store.SetRequestState(
|
if err := m.store.SetDownloadState(
|
||||||
ctx, item.RequestID, StateFailed, "interrupted by restart",
|
ctx, item.DownloadID, StateFailed, "interrupted by restart",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
m.logger.Warn(
|
m.logger.Warn(
|
||||||
"could not fail interrupted download request",
|
"could not fail interrupted download request",
|
||||||
"request", item.RequestID, "error", err,
|
"request", item.DownloadID, "error", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,7 +429,7 @@ func (m *Manager) syncSemaphores(configs map[int64]Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// listers returns every enabled provider that keeps a persistent wanted
|
// listers returns every enabled provider that keeps a persistent
|
||||||
// list of its own, keyed by provider ID.
|
// list of its own, keyed by provider ID.
|
||||||
func (m *Manager) listers() map[int64]Lister {
|
func (m *Manager) listers() map[int64]Lister {
|
||||||
m.provMu.RLock()
|
m.provMu.RLock()
|
||||||
@@ -434,7 +464,7 @@ func (m *Manager) priorityFor(id int64) int {
|
|||||||
// and skipped, because partial results beat no results.
|
// and skipped, because partial results beat no results.
|
||||||
func (m *Manager) Search(
|
func (m *Manager) Search(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
) ([]Candidate, error) {
|
) ([]Candidate, error) {
|
||||||
providers := m.enabledProviders()
|
providers := m.enabledProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
@@ -462,7 +492,7 @@ func (m *Manager) Search(
|
|||||||
sctx, cancel := context.WithTimeout(ctx, searchTimeout)
|
sctx, cancel := context.WithTimeout(ctx, searchTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
c, err := s.Search(sctx, req)
|
c, err := s.Search(sctx, dl)
|
||||||
results <- found{candidates: c, err: err, id: id}
|
results <- found{candidates: c, err: err, id: id}
|
||||||
}(id, s)
|
}(id, s)
|
||||||
}
|
}
|
||||||
@@ -497,7 +527,7 @@ func (m *Manager) Search(
|
|||||||
return nil, ErrNoCandidates
|
return nil, ErrNoCandidates
|
||||||
}
|
}
|
||||||
|
|
||||||
return Rank(req, all, m.priorityFor), nil
|
return Rank(dl, all, m.priorityFor, m.preferences()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start creates a request, searches for it, and either grabs the clear
|
// Start creates a request, searches for it, and either grabs the clear
|
||||||
@@ -506,31 +536,31 @@ func (m *Manager) Search(
|
|||||||
// in the background under a job.
|
// in the background under a job.
|
||||||
func (m *Manager) Start(
|
func (m *Manager) Start(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
) ([]Candidate, error) {
|
) ([]Candidate, error) {
|
||||||
if req.ID == "" {
|
if dl.ID == "" {
|
||||||
req.ID = newID()
|
dl.ID = newID()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.store.CreateRequest(ctx, req); err != nil {
|
if err := m.store.CreateDownload(ctx, dl); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
job := m.startJob(req)
|
job := m.startJob(dl)
|
||||||
|
|
||||||
ranked, err := m.Search(ctx, req)
|
ranked, err := m.Search(ctx, dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.failRequest(ctx, job, req.ID, err)
|
m.failDownload(ctx, job, dl.ID, err)
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.resMu.Lock()
|
m.resMu.Lock()
|
||||||
m.results[req.ID] = ranked
|
m.results[dl.ID] = ranked
|
||||||
m.resMu.Unlock()
|
m.resMu.Unlock()
|
||||||
|
|
||||||
if err := m.store.SetRequestState(
|
if err := m.store.SetDownloadState(
|
||||||
ctx, req.ID, StateFound, "",
|
ctx, dl.ID, StateFound, "",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
m.logger.Warn("could not record found state", "error", err)
|
m.logger.Warn("could not record found state", "error", err)
|
||||||
}
|
}
|
||||||
@@ -541,12 +571,12 @@ func (m *Manager) Start(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if AutoPickable(req, ranked) {
|
if m.AutoPickable(dl, ranked) {
|
||||||
if job != nil {
|
if job != nil {
|
||||||
job.Logf(jobs.LevelInfo, "Auto-selected best candidate")
|
job.Logf(jobs.LevelInfo, "Auto-selected best candidate")
|
||||||
}
|
}
|
||||||
|
|
||||||
go m.grab(context.WithoutCancel(ctx), req, ranked[0], job)
|
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
|
||||||
|
|
||||||
return ranked, nil
|
return ranked, nil
|
||||||
}
|
}
|
||||||
@@ -559,30 +589,30 @@ func (m *Manager) Start(
|
|||||||
return ranked, nil
|
return ranked, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt searches on behalf of the wanted list and starts a download
|
// Attempt searches on behalf of the request list and starts a download
|
||||||
// only if there is a clear winner. It returns whether it started and,
|
// only if there is a clear winner. It returns whether it started and,
|
||||||
// when it did not, a sentence the wanted list can show the user.
|
// when it did not, a sentence the request list can show the user.
|
||||||
//
|
//
|
||||||
// Unlike Start it persists nothing when it does not act. A want that
|
// Unlike Start it persists nothing when it does not act. A request that
|
||||||
// is retried weekly for a year would otherwise leave fifty failed
|
// is retried weekly for a year would otherwise leave fifty failed
|
||||||
// request rows behind it, all saying the same thing the want itself
|
// download rows behind it, all saying the same thing the request itself
|
||||||
// already says — and none of them anything the user can do something
|
// already says — and none of them anything the user can do something
|
||||||
// about. Nobody is watching a reconcile pass, so the only two honest
|
// about. Nobody is watching a reconcile pass, so the only two honest
|
||||||
// outcomes are "downloading it now" and "still looking".
|
// outcomes are "downloading it now" and "still looking".
|
||||||
func (m *Manager) Attempt(
|
func (m *Manager) Attempt(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
) (bool, string, error) {
|
) (bool, string, error) {
|
||||||
if req.ID == "" {
|
if dl.ID == "" {
|
||||||
req.ID = newID()
|
dl.ID = newID()
|
||||||
}
|
}
|
||||||
|
|
||||||
ranked, err := m.Search(ctx, req)
|
ranked, err := m.Search(ctx, dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "", err
|
return false, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !AutoPickable(req, ranked) {
|
if !m.AutoPickable(dl, ranked) {
|
||||||
best := ranked[0]
|
best := ranked[0]
|
||||||
|
|
||||||
return false, fmt.Sprintf(
|
return false, fmt.Sprintf(
|
||||||
@@ -594,28 +624,28 @@ func (m *Manager) Attempt(
|
|||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.store.CreateRequest(ctx, req); err != nil {
|
if err := m.store.CreateDownload(ctx, dl); err != nil {
|
||||||
return false, "", err
|
return false, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.resMu.Lock()
|
m.resMu.Lock()
|
||||||
m.results[req.ID] = ranked
|
m.results[dl.ID] = ranked
|
||||||
m.resMu.Unlock()
|
m.resMu.Unlock()
|
||||||
|
|
||||||
if err := m.store.SetRequestState(ctx, req.ID, StateFound, ""); err != nil {
|
if err := m.store.SetDownloadState(ctx, dl.ID, StateFound, ""); err != nil {
|
||||||
m.logger.Warn("could not record found state", "error", err)
|
m.logger.Warn("could not record found state", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
job := m.startJob(req)
|
job := m.startJob(dl)
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
job.Logf(jobs.LevelInfo, fmt.Sprintf(
|
job.Logf(jobs.LevelInfo, fmt.Sprintf(
|
||||||
"Wanted list: auto-selected the best of %d candidates",
|
"Request list: auto-selected the best of %d candidates",
|
||||||
len(ranked),
|
len(ranked),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
go m.grab(context.WithoutCancel(ctx), req, ranked[0], job)
|
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
|
||||||
|
|
||||||
return true, "", nil
|
return true, "", nil
|
||||||
}
|
}
|
||||||
@@ -623,15 +653,15 @@ func (m *Manager) Attempt(
|
|||||||
// Pick starts the transfer for a candidate the user chose.
|
// Pick starts the transfer for a candidate the user chose.
|
||||||
func (m *Manager) Pick(
|
func (m *Manager) Pick(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
requestID, candidateID string,
|
downloadID, candidateID string,
|
||||||
) error {
|
) error {
|
||||||
req, err := m.store.GetRequest(ctx, requestID)
|
dl, err := m.store.GetDownload(ctx, downloadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.resMu.RLock()
|
m.resMu.RLock()
|
||||||
ranked := m.results[requestID]
|
ranked := m.results[downloadID]
|
||||||
m.resMu.RUnlock()
|
m.resMu.RUnlock()
|
||||||
|
|
||||||
var chosen *Candidate
|
var chosen *Candidate
|
||||||
@@ -648,25 +678,25 @@ func (m *Manager) Pick(
|
|||||||
return fmt.Errorf("%w: %s", ErrCandidateGone, candidateID)
|
return fmt.Errorf("%w: %s", ErrCandidateGone, candidateID)
|
||||||
}
|
}
|
||||||
|
|
||||||
job := m.startJob(req)
|
job := m.startJob(dl)
|
||||||
|
|
||||||
go m.grab(context.WithoutCancel(ctx), req, *chosen, job)
|
go m.grab(context.WithoutCancel(ctx), dl, *chosen, job)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel aborts a live request.
|
// Cancel aborts a live request.
|
||||||
func (m *Manager) Cancel(ctx context.Context, requestID string) error {
|
func (m *Manager) Cancel(ctx context.Context, downloadID string) error {
|
||||||
m.actMu.Lock()
|
m.actMu.Lock()
|
||||||
cancel, ok := m.active[requestID]
|
cancel, ok := m.active[downloadID]
|
||||||
m.actMu.Unlock()
|
m.actMu.Unlock()
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.store.SetRequestState(
|
if err := m.store.SetDownloadState(
|
||||||
ctx, requestID, StateCancelled, "",
|
ctx, downloadID, StateCancelled, "",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -678,7 +708,7 @@ func (m *Manager) Cancel(ctx context.Context, requestID string) error {
|
|||||||
// own goroutine and owns the job from here on.
|
// own goroutine and owns the job from here on.
|
||||||
func (m *Manager) grab(
|
func (m *Manager) grab(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
c Candidate,
|
c Candidate,
|
||||||
job *jobs.Handle,
|
job *jobs.Handle,
|
||||||
) {
|
) {
|
||||||
@@ -686,12 +716,12 @@ func (m *Manager) grab(
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
m.actMu.Lock()
|
m.actMu.Lock()
|
||||||
m.active[req.ID] = cancel
|
m.active[dl.ID] = cancel
|
||||||
m.actMu.Unlock()
|
m.actMu.Unlock()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
m.actMu.Lock()
|
m.actMu.Lock()
|
||||||
delete(m.active, req.ID)
|
delete(m.active, dl.ID)
|
||||||
m.actMu.Unlock()
|
m.actMu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -701,9 +731,9 @@ func (m *Manager) grab(
|
|||||||
// happening inside another system, which is doing its own limiting,
|
// happening inside another system, which is doing its own limiting,
|
||||||
// and blocking a local slot on it would be counting someone else's
|
// and blocking a local slot on it would be counting someone else's
|
||||||
// work against our budget.
|
// work against our budget.
|
||||||
plan, err := m.planTransfer(req, c)
|
plan, err := m.planTransfer(dl, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.failRequest(ctx, job, req.ID, err)
|
m.failDownload(ctx, job, dl.ID, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -715,7 +745,7 @@ func (m *Manager) grab(
|
|||||||
case provSem <- struct{}{}:
|
case provSem <- struct{}{}:
|
||||||
defer func() { <-provSem }()
|
defer func() { <-provSem }()
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
m.failRequest(ctx, job, req.ID, ctx.Err())
|
m.failDownload(ctx, job, dl.ID, ctx.Err())
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -726,15 +756,15 @@ func (m *Manager) grab(
|
|||||||
case globalSem <- struct{}{}:
|
case globalSem <- struct{}{}:
|
||||||
defer func() { <-globalSem }()
|
defer func() { <-globalSem }()
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
m.failRequest(ctx, job, req.ID, ctx.Err())
|
m.failDownload(ctx, job, dl.ID, ctx.Err())
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item := Item{
|
item := DownloadItem{
|
||||||
ID: newID(),
|
ID: newID(),
|
||||||
RequestID: req.ID,
|
DownloadID: dl.ID,
|
||||||
ProviderID: c.ProviderID,
|
ProviderID: c.ProviderID,
|
||||||
Candidate: c,
|
Candidate: c,
|
||||||
State: StateQueued,
|
State: StateQueued,
|
||||||
@@ -743,7 +773,7 @@ func (m *Manager) grab(
|
|||||||
|
|
||||||
dir, err := m.staging.Reserve(item.ID)
|
dir, err := m.staging.Reserve(item.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.failRequest(ctx, job, req.ID, err)
|
m.failDownload(ctx, job, dl.ID, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -751,19 +781,19 @@ func (m *Manager) grab(
|
|||||||
item.StagingDir = dir
|
item.StagingDir = dir
|
||||||
|
|
||||||
if err := m.store.CreateItem(ctx, item); err != nil {
|
if err := m.store.CreateItem(ctx, item); err != nil {
|
||||||
m.failRequest(ctx, job, req.ID, err)
|
m.failDownload(ctx, job, dl.ID, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := m.transfer(ctx, req, item, plan, job)
|
result, err := m.transfer(ctx, dl, item, plan, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.failItem(ctx, job, item, req.ID, err)
|
m.failItem(ctx, job, item, dl.ID, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.setStates(ctx, req.ID, item.ID, StateImporting)
|
m.setStates(ctx, dl.ID, item.ID, StateImporting)
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
job.SetPhase("Importing")
|
job.SetPhase("Importing")
|
||||||
@@ -790,17 +820,17 @@ func (m *Manager) grab(
|
|||||||
opts := m.importOptions()
|
opts := m.importOptions()
|
||||||
opts.WriteTags = true
|
opts.WriteTags = true
|
||||||
|
|
||||||
opts.LibraryRoot, err = m.library.LibraryPath(req.LibraryID)
|
opts.LibraryRoot, err = m.library.LibraryPath(dl.LibraryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.failItem(ctx, job, item, req.ID,
|
m.failItem(ctx, job, item, dl.ID,
|
||||||
fmt.Errorf("resolve library root: %w", err))
|
fmt.Errorf("resolve library root: %w", err))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
imported, err = m.importer.Import(ctx, req, result, opts)
|
imported, err = m.importer.Import(ctx, dl, result, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.failItem(ctx, job, item, req.ID, err)
|
m.failItem(ctx, job, item, dl.ID, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -812,21 +842,21 @@ func (m *Manager) grab(
|
|||||||
m.logger.Warn("could not record imported paths", "error", err)
|
m.logger.Warn("could not record imported paths", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.store.SetRequestState(
|
if err := m.store.SetDownloadState(
|
||||||
ctx, req.ID, StateComplete, "",
|
ctx, dl.ID, StateComplete, "",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
m.logger.Warn("could not record complete state", "error", err)
|
m.logger.Warn("could not record complete state", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A request raised from the wanted list retires its want here
|
// A download raised from a durable Request retires it here
|
||||||
// rather than waiting for the next reconcile pass to notice the
|
// rather than waiting for the next reconcile pass to notice the
|
||||||
// files, so the wanted list is right the moment the download
|
// files, so the request list is right the moment the download
|
||||||
// finishes. The pass would reach the same conclusion by asking the
|
// finishes. The pass would reach the same conclusion by asking the
|
||||||
// library; this is the same answer, sooner.
|
// library; this is the same answer, sooner.
|
||||||
if req.WantID != 0 {
|
if dl.RequestID != 0 {
|
||||||
if err := m.store.SatisfyWant(ctx, req.WantID); err != nil {
|
if err := m.store.SatisfyRequest(ctx, dl.RequestID); err != nil {
|
||||||
m.logger.Warn(
|
m.logger.Warn(
|
||||||
"could not satisfy want", "want", req.WantID, "error", err,
|
"could not satisfy request", "request", dl.RequestID, "error", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -835,7 +865,7 @@ func (m *Manager) grab(
|
|||||||
// mid-flight. Holding it after the download completes would leak a
|
// mid-flight. Holding it after the download completes would leak a
|
||||||
// few hundred candidates per request for the life of the process.
|
// few hundred candidates per request for the life of the process.
|
||||||
m.resMu.Lock()
|
m.resMu.Lock()
|
||||||
delete(m.results, req.ID)
|
delete(m.results, dl.ID)
|
||||||
m.resMu.Unlock()
|
m.resMu.Unlock()
|
||||||
|
|
||||||
// Staging is only released on a fully successful import; a failure
|
// Staging is only released on a fully successful import; a failure
|
||||||
@@ -845,10 +875,10 @@ func (m *Manager) grab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.library != nil {
|
if m.library != nil {
|
||||||
if err := m.library.ScanLibrary(req.LibraryID); err != nil {
|
if err := m.library.ScanLibrary(dl.LibraryID); err != nil {
|
||||||
m.logger.Warn(
|
m.logger.Warn(
|
||||||
"could not trigger scan after import",
|
"could not trigger scan after import",
|
||||||
"library", req.LibraryID,
|
"library", dl.LibraryID,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -871,16 +901,16 @@ func (m *Manager) grab(
|
|||||||
// delegates the whole thing.
|
// delegates the whole thing.
|
||||||
func (m *Manager) transfer(
|
func (m *Manager) transfer(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
item Item,
|
item DownloadItem,
|
||||||
plan transferPlan,
|
plan transferPlan,
|
||||||
job *jobs.Handle,
|
job *jobs.Handle,
|
||||||
) (Result, error) {
|
) (Result, error) {
|
||||||
if plan.delegated() {
|
if plan.delegated() {
|
||||||
return m.delegate(ctx, req, item, plan.delegate, job)
|
return m.delegate(ctx, dl, item, plan.delegate, job)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.setStates(ctx, req.ID, item.ID, StateGrabbing)
|
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
job.SetPhase("Downloading")
|
job.SetPhase("Downloading")
|
||||||
@@ -896,7 +926,7 @@ func (m *Manager) transfer(
|
|||||||
return Result{}, fmt.Errorf("grab failed: %w", err)
|
return Result{}, fmt.Errorf("grab failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.setStates(ctx, req.ID, item.ID, StateVerifying)
|
m.setStates(ctx, dl.ID, item.ID, StateVerifying)
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
job.SetPhase("Verifying")
|
job.SetPhase("Verifying")
|
||||||
@@ -966,7 +996,7 @@ type transferPlan struct {
|
|||||||
func (p transferPlan) delegated() bool { return p.delegate != nil }
|
func (p transferPlan) delegated() bool { return p.delegate != nil }
|
||||||
|
|
||||||
// planTransfer decides how a candidate will be fetched.
|
// planTransfer decides how a candidate will be fetched.
|
||||||
func (m *Manager) planTransfer(_ Request, c Candidate) (transferPlan, error) {
|
func (m *Manager) planTransfer(_ Download, c Candidate) (transferPlan, error) {
|
||||||
providers := m.enabledProviders()
|
providers := m.enabledProviders()
|
||||||
|
|
||||||
source, ok := providers[c.ProviderID]
|
source, ok := providers[c.ProviderID]
|
||||||
@@ -992,12 +1022,12 @@ func (m *Manager) planTransfer(_ Request, c Candidate) (transferPlan, error) {
|
|||||||
// reports terminal state.
|
// reports terminal state.
|
||||||
func (m *Manager) delegate(
|
func (m *Manager) delegate(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
item Item,
|
item DownloadItem,
|
||||||
d Delegator,
|
d Delegator,
|
||||||
job *jobs.Handle,
|
job *jobs.Handle,
|
||||||
) (Result, error) {
|
) (Result, error) {
|
||||||
externalID, err := d.Delegate(ctx, req)
|
externalID, err := d.Delegate(ctx, dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("delegate request: %w", err)
|
return Result{}, fmt.Errorf("delegate request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1006,7 +1036,7 @@ func (m *Manager) delegate(
|
|||||||
m.logger.Warn("could not record external id", "error", err)
|
m.logger.Warn("could not record external id", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.setStates(ctx, req.ID, item.ID, StateGrabbing)
|
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
job.SetPhase("Waiting on external manager")
|
job.SetPhase("Waiting on external manager")
|
||||||
@@ -1104,12 +1134,12 @@ func (m *Manager) progressReporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Candidates returns the ranked candidates for a live request.
|
// Candidates returns the ranked candidates for a live request.
|
||||||
func (m *Manager) Candidates(requestID string) []Candidate {
|
func (m *Manager) Candidates(downloadID string) []Candidate {
|
||||||
m.resMu.RLock()
|
m.resMu.RLock()
|
||||||
defer m.resMu.RUnlock()
|
defer m.resMu.RUnlock()
|
||||||
|
|
||||||
out := make([]Candidate, len(m.results[requestID]))
|
out := make([]Candidate, len(m.results[downloadID]))
|
||||||
copy(out, m.results[requestID])
|
copy(out, m.results[downloadID])
|
||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -1117,10 +1147,10 @@ func (m *Manager) Candidates(requestID string) []Candidate {
|
|||||||
// setStates advances a request and its item together.
|
// setStates advances a request and its item together.
|
||||||
func (m *Manager) setStates(
|
func (m *Manager) setStates(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
requestID, itemID string,
|
downloadID, itemID string,
|
||||||
state State,
|
state State,
|
||||||
) {
|
) {
|
||||||
if err := m.store.SetRequestState(ctx, requestID, state, ""); err != nil {
|
if err := m.store.SetDownloadState(ctx, downloadID, state, ""); err != nil {
|
||||||
m.logger.Warn("could not set request state", "error", err)
|
m.logger.Warn("could not set request state", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1129,17 +1159,17 @@ func (m *Manager) setStates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// failRequest records a request-level failure.
|
// failDownload records a download-level failure.
|
||||||
func (m *Manager) failRequest(
|
func (m *Manager) failDownload(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
job *jobs.Handle,
|
job *jobs.Handle,
|
||||||
requestID string,
|
downloadID string,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
m.logger.Warn("download request failed", "request", requestID, "error", err)
|
m.logger.Warn("download failed", "download", downloadID, "error", err)
|
||||||
|
|
||||||
if serr := m.store.SetRequestState(
|
if serr := m.store.SetDownloadState(
|
||||||
ctx, requestID, StateFailed, err.Error(),
|
ctx, downloadID, StateFailed, err.Error(),
|
||||||
); serr != nil {
|
); serr != nil {
|
||||||
m.logger.Warn("could not record failure", "error", serr)
|
m.logger.Warn("could not record failure", "error", serr)
|
||||||
}
|
}
|
||||||
@@ -1149,12 +1179,12 @@ func (m *Manager) failRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// failItem records an item-level failure and fails its request.
|
// failItem records an item-level failure and fails its download.
|
||||||
func (m *Manager) failItem(
|
func (m *Manager) failItem(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
job *jobs.Handle,
|
job *jobs.Handle,
|
||||||
item Item,
|
item DownloadItem,
|
||||||
requestID string,
|
downloadID string,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if serr := m.store.SetItemState(
|
if serr := m.store.SetItemState(
|
||||||
@@ -1163,30 +1193,30 @@ func (m *Manager) failItem(
|
|||||||
m.logger.Warn("could not record item failure", "error", serr)
|
m.logger.Warn("could not record item failure", "error", serr)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.failRequest(ctx, job, requestID, err)
|
m.failDownload(ctx, job, downloadID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startJob registers the request in the background jobs panel.
|
// startJob registers the request in the background jobs panel.
|
||||||
func (m *Manager) startJob(req Request) *jobs.Handle {
|
func (m *Manager) startJob(dl Download) *jobs.Handle {
|
||||||
if m.jobsReg == nil {
|
if m.jobsReg == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
title := req.Album
|
title := dl.Album
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = req.SearchText()
|
title = dl.SearchText()
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.jobsReg.Start(jobs.Spec{
|
return m.jobsReg.Start(jobs.Spec{
|
||||||
ID: "download-" + req.ID,
|
ID: "download-" + dl.ID,
|
||||||
Kind: jobs.KindDownload,
|
Kind: jobs.KindDownload,
|
||||||
Title: "Downloading " + title,
|
Title: "Downloading " + title,
|
||||||
Subtitle: req.Artist,
|
Subtitle: dl.Artist,
|
||||||
State: jobs.StateRunning,
|
State: jobs.StateRunning,
|
||||||
Caps: jobs.Caps{Cancellable: true},
|
Caps: jobs.Caps{Cancellable: true},
|
||||||
Controls: jobs.Controls{
|
Controls: jobs.Controls{
|
||||||
Cancel: func() {
|
Cancel: func() {
|
||||||
if err := m.Cancel(context.Background(), req.ID); err != nil {
|
if err := m.Cancel(context.Background(), dl.ID); err != nil {
|
||||||
m.logger.Warn("cancel failed", "error", err)
|
m.logger.Warn("cancel failed", "error", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func TestManagerSearchRanksAcrossProviders(t *testing.T) {
|
|||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, mp3)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, mp3)
|
||||||
f.manager.installProvider(Config{ID: 2, Priority: 50}, flac)
|
f.manager.installProvider(Config{ID: 2, Priority: 50}, flac)
|
||||||
|
|
||||||
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
|
ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Search: %v", err)
|
t.Fatalf("Search: %v", err)
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@ func TestManagerSearchToleratesProviderFailure(t *testing.T) {
|
|||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, broken)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, broken)
|
||||||
f.manager.installProvider(Config{ID: 2, Priority: 50}, working)
|
f.manager.installProvider(Config{ID: 2, Priority: 50}, working)
|
||||||
|
|
||||||
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
|
ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Search: %v", err)
|
t.Fatalf("Search: %v", err)
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func TestManagerSearchNoProviders(t *testing.T) {
|
|||||||
|
|
||||||
f := newManagerFixture(t)
|
f := newManagerFixture(t)
|
||||||
|
|
||||||
_, err := f.manager.Search(context.Background(), fourTrackRequest())
|
_, err := f.manager.Search(context.Background(), fourTrackDownload())
|
||||||
if !errors.Is(err, ErrNoProviders) {
|
if !errors.Is(err, ErrNoProviders) {
|
||||||
t.Fatalf("error = %v, want ErrNoProviders", err)
|
t.Fatalf("error = %v, want ErrNoProviders", err)
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ func TestManagerSearchNoCandidates(t *testing.T) {
|
|||||||
empty := NewFakeProvider(1, "empty", Caps{CanSearch: true})
|
empty := NewFakeProvider(1, "empty", Caps{CanSearch: true})
|
||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, empty)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, empty)
|
||||||
|
|
||||||
_, err := f.manager.Search(context.Background(), fourTrackRequest())
|
_, err := f.manager.Search(context.Background(), fourTrackDownload())
|
||||||
if !errors.Is(err, ErrNoCandidates) {
|
if !errors.Is(err, ErrNoCandidates) {
|
||||||
t.Fatalf("error = %v, want ErrNoCandidates", err)
|
t.Fatalf("error = %v, want ErrNoCandidates", err)
|
||||||
}
|
}
|
||||||
@@ -173,21 +173,21 @@ func TestManagerEndToEndAutoPick(t *testing.T) {
|
|||||||
provider := fakeWithAlbum(1, "flac-source", ".flac")
|
provider := fakeWithAlbum(1, "flac-source", ".flac")
|
||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
ranked, err := f.manager.Start(context.Background(), req)
|
ranked, err := f.manager.Start(context.Background(), dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !AutoPickable(req, ranked) {
|
if !f.manager.AutoPickable(dl, ranked) {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected a clear winner to auto-pick; best match %f quality %f",
|
"expected a clear winner to auto-pick; best match %f quality %f",
|
||||||
ranked[0].Match.Overall, ranked[0].Quality.Overall,
|
ranked[0].Match.Overall, ranked[0].Quality.Overall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForRequestState(t, f.store, req.ID, StateComplete)
|
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||||
|
|
||||||
if provider.GrabCalls != 1 {
|
if provider.GrabCalls != 1 {
|
||||||
t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
|
t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
|
||||||
@@ -231,14 +231,14 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
|
|||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
|
||||||
f.manager.installProvider(Config{ID: 2, Priority: 50}, b)
|
f.manager.installProvider(Config{ID: 2, Priority: 50}, b)
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
ranked, err := f.manager.Start(context.Background(), req)
|
ranked, err := f.manager.Start(context.Background(), dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if AutoPickable(req, ranked) {
|
if f.manager.AutoPickable(dl, ranked) {
|
||||||
t.Fatal("two equivalent candidates must not auto-pick")
|
t.Fatal("two equivalent candidates must not auto-pick")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,23 +250,23 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := f.store.GetRequest(context.Background(), req.ID)
|
stored, err := f.store.GetDownload(context.Background(), dl.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetRequest: %v", err)
|
t.Fatalf("GetDownload: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stored.ID != req.ID {
|
if stored.ID != dl.ID {
|
||||||
t.Errorf("stored request id = %s, want %s", stored.ID, req.ID)
|
t.Errorf("stored request id = %s, want %s", stored.ID, dl.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user picks the second one explicitly.
|
// The user picks the second one explicitly.
|
||||||
if err := f.manager.Pick(
|
if err := f.manager.Pick(
|
||||||
context.Background(), req.ID, ranked[1].ID,
|
context.Background(), dl.ID, ranked[1].ID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("Pick: %v", err)
|
t.Fatalf("Pick: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForRequestState(t, f.store, req.ID, StateComplete)
|
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerPickUnknownCandidate(t *testing.T) {
|
func TestManagerPickUnknownCandidate(t *testing.T) {
|
||||||
@@ -277,14 +277,14 @@ func TestManagerPickUnknownCandidate(t *testing.T) {
|
|||||||
provider := fakeWithAlbum(1, "source", ".flac")
|
provider := fakeWithAlbum(1, "source", ".flac")
|
||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
req.Expected = nil // free text: never auto-picks
|
dl.Expected = nil // free text: never auto-picks
|
||||||
|
|
||||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := f.manager.Pick(context.Background(), req.ID, "no-such-candidate")
|
err := f.manager.Pick(context.Background(), dl.ID, "no-such-candidate")
|
||||||
if !errors.Is(err, ErrCandidateGone) {
|
if !errors.Is(err, ErrCandidateGone) {
|
||||||
t.Fatalf("error = %v, want ErrCandidateGone", err)
|
t.Fatalf("error = %v, want ErrCandidateGone", err)
|
||||||
}
|
}
|
||||||
@@ -302,13 +302,13 @@ func TestManagerFailedGrabLeavesLibraryClean(t *testing.T) {
|
|||||||
|
|
||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForRequestState(t, f.store, req.ID, StateFailed)
|
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
||||||
|
|
||||||
entries, err := os.ReadDir(f.root)
|
entries, err := os.ReadDir(f.root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -355,13 +355,13 @@ func TestManagerPairsSearcherWithTransport(t *testing.T) {
|
|||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
|
||||||
f.manager.installProvider(Config{ID: 2, Priority: 50}, transport)
|
f.manager.installProvider(Config{ID: 2, Priority: 50}, transport)
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForRequestState(t, f.store, req.ID, StateComplete)
|
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||||
|
|
||||||
if transport.GrabCalls != 1 {
|
if transport.GrabCalls != 1 {
|
||||||
t.Errorf("transport grabs = %d, want 1", transport.GrabCalls)
|
t.Errorf("transport grabs = %d, want 1", transport.GrabCalls)
|
||||||
@@ -387,22 +387,22 @@ func TestManagerNoTransportForProtocol(t *testing.T) {
|
|||||||
|
|
||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForRequestState(t, f.store, req.ID, StateFailed)
|
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForRequestState polls until a request reaches the wanted state.
|
// waitForDownloadState polls until a download reaches the wanted state.
|
||||||
// The pipeline runs on its own goroutine, so tests observe it through
|
// The pipeline runs on its own goroutine, so tests observe it through
|
||||||
// the store rather than by reaching into the manager.
|
// the store rather than by reaching into the manager.
|
||||||
func waitForRequestState(
|
func waitForDownloadState(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
store *Store,
|
store *Store,
|
||||||
requestID string,
|
downloadID string,
|
||||||
want State,
|
want State,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -412,7 +412,7 @@ func waitForRequestState(
|
|||||||
var last State
|
var last State
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
state, _, err := store.GetRequestState(context.Background(), requestID)
|
state, _, err := store.GetDownloadState(context.Background(), downloadID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
last = state
|
last = state
|
||||||
if last == want {
|
if last == want {
|
||||||
@@ -423,7 +423,7 @@ func waitForRequestState(
|
|||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Fatalf("request state = %q after 5s, want %q", last, want)
|
t.Fatalf("download state = %q after 5s, want %q", last, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A delegate's files are already in the external manager's library,
|
// A delegate's files are already in the external manager's library,
|
||||||
@@ -457,13 +457,13 @@ func TestManagerDelegateReconcilesInPlace(t *testing.T) {
|
|||||||
|
|
||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, delegate)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, delegate)
|
||||||
|
|
||||||
req := fourTrackRequest()
|
dl := fourTrackDownload()
|
||||||
|
|
||||||
if _, err := f.manager.Start(context.Background(), req); err != nil {
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForRequestState(t, f.store, req.ID, StateComplete)
|
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
||||||
|
|
||||||
if delegate.DelegateCalls != 1 {
|
if delegate.DelegateCalls != 1 {
|
||||||
t.Errorf("delegate calls = %d, want 1", delegate.DelegateCalls)
|
t.Errorf("delegate calls = %d, want 1", delegate.DelegateCalls)
|
||||||
@@ -489,9 +489,9 @@ func TestManagerDelegateReconcilesInPlace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The external paths were recorded against the item.
|
// The external paths were recorded against the item.
|
||||||
items, err := f.store.ListItemsForRequest(context.Background(), req.ID)
|
items, err := f.store.ListItemsForDownload(context.Background(), dl.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListItemsForRequest: %v", err)
|
t.Fatalf("ListItemsForDownload: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(items) != 1 {
|
if len(items) != 1 {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type Provider interface {
|
|||||||
// respect ctx deadlines: the pipeline searches providers concurrently
|
// respect ctx deadlines: the pipeline searches providers concurrently
|
||||||
// with a per-provider timeout and takes whatever came back in time.
|
// with a per-provider timeout and takes whatever came back in time.
|
||||||
type Searcher interface {
|
type Searcher interface {
|
||||||
Search(ctx context.Context, req Request) ([]Candidate, error)
|
Search(ctx context.Context, dl Download) ([]Candidate, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transporter moves a candidate's bytes into dst, which the pipeline
|
// Transporter moves a candidate's bytes into dst, which the pipeline
|
||||||
@@ -73,7 +73,7 @@ type Transporter interface {
|
|||||||
// the manager reports terminal state.
|
// the manager reports terminal state.
|
||||||
type Delegator interface {
|
type Delegator interface {
|
||||||
// Delegate submits the request and returns the manager's own ID.
|
// Delegate submits the request and returns the manager's own ID.
|
||||||
Delegate(ctx context.Context, req Request) (string, error)
|
Delegate(ctx context.Context, dl Download) (string, error)
|
||||||
|
|
||||||
// Poll reports on a previously delegated request.
|
// Poll reports on a previously delegated request.
|
||||||
Poll(ctx context.Context, externalID string) (DelegateStatus, error)
|
Poll(ctx context.Context, externalID string) (DelegateStatus, error)
|
||||||
@@ -92,18 +92,18 @@ type Delegator interface {
|
|||||||
// pushes, the external system receives. Pulling happens only when the
|
// pushes, the external system receives. Pulling happens only when the
|
||||||
// user explicitly imports.
|
// user explicitly imports.
|
||||||
type Lister interface {
|
type Lister interface {
|
||||||
// PushWant records a want in the provider's own list and returns
|
// PushRequest records a request in the provider's own list and
|
||||||
// the provider's identifier for it. Implementations must be
|
// returns the provider's identifier for it. Implementations must
|
||||||
// idempotent: pushing a want the provider already has returns the
|
// be idempotent: pushing a request the provider already has returns
|
||||||
// existing identifier rather than duplicating it.
|
// the existing identifier rather than duplicating it.
|
||||||
PushWant(ctx context.Context, w Want) (string, error)
|
PushRequest(ctx context.Context, r Request) (string, error)
|
||||||
|
|
||||||
// RemoveWant drops a previously pushed want. Best-effort.
|
// RemoveRequest drops a previously pushed request. Best-effort.
|
||||||
RemoveWant(ctx context.Context, externalID string) error
|
RemoveRequest(ctx context.Context, externalID string) error
|
||||||
|
|
||||||
// ListWants reads the provider's list back, for the deliberate
|
// ListRequests reads the provider's list back, for the deliberate
|
||||||
// import path. LibraryID is filled in by the caller.
|
// import path. LibraryID is filled in by the caller.
|
||||||
ListWants(ctx context.Context) ([]Want, error)
|
ListRequests(ctx context.Context) ([]Request, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderInfo is a provider's identity as the frontend sees it.
|
// ProviderInfo is a provider's identity as the frontend sees it.
|
||||||
|
|||||||
@@ -237,8 +237,8 @@ type lidarrTrackFile struct {
|
|||||||
// Delegate adds the album to Lidarr, monitors it and triggers a search.
|
// Delegate adds the album to Lidarr, monitors it and triggers a search.
|
||||||
// The external ID returned is Lidarr's album ID, which is what Poll
|
// The external ID returned is Lidarr's album ID, which is what Poll
|
||||||
// needs and what survives a restart.
|
// needs and what survives a restart.
|
||||||
func (l *lidarr) Delegate(ctx context.Context, req Request) (string, error) {
|
func (l *lidarr) Delegate(ctx context.Context, dl Download) (string, error) {
|
||||||
album, err := l.findAlbum(ctx, req)
|
album, err := l.findAlbum(ctx, dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -276,12 +276,12 @@ func (l *lidarr) Delegate(ctx context.Context, req Request) (string, error) {
|
|||||||
// not.
|
// not.
|
||||||
func (l *lidarr) findAlbum(
|
func (l *lidarr) findAlbum(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
) (lidarrAlbum, error) {
|
) (lidarrAlbum, error) {
|
||||||
term := req.SearchText()
|
term := dl.SearchText()
|
||||||
|
|
||||||
if req.ReleaseGroupMBID != "" {
|
if dl.ReleaseGroupMBID != "" {
|
||||||
term = "lidarr:" + req.ReleaseGroupMBID
|
term = "lidarr:" + dl.ReleaseGroupMBID
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []struct {
|
var results []struct {
|
||||||
@@ -300,7 +300,7 @@ func (l *lidarr) findAlbum(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, req.SearchText())
|
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, dl.SearchText())
|
||||||
}
|
}
|
||||||
|
|
||||||
// addArtistForAlbum adds the album's artist so the album becomes a real
|
// addArtistForAlbum adds the album's artist so the album becomes a real
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Lidarr's Lister role: mirroring this app's wanted list into Lidarr's
|
// Lidarr's Lister role: mirroring this app's request list into Lidarr's
|
||||||
// own monitoring.
|
// own monitoring.
|
||||||
//
|
//
|
||||||
// Lidarr already models exactly what a want is — a monitored artist or
|
// Lidarr already models exactly what a request is — a monitored artist or
|
||||||
// a monitored album — so the mapping is direct and, more usefully, it
|
// a monitored album — so the mapping is direct and, more usefully, it
|
||||||
// means an artist subscription made here keeps working through Lidarr's
|
// means an artist subscription made here keeps working through Lidarr's
|
||||||
// own release-checking even while this app is closed. That is the
|
// own release-checking even while this app is closed. That is the
|
||||||
@@ -21,52 +21,52 @@ import (
|
|||||||
//
|
//
|
||||||
// The mapping is deliberately lossy in one direction only:
|
// The mapping is deliberately lossy in one direction only:
|
||||||
//
|
//
|
||||||
// artist want -> Lidarr artist, monitored
|
// artist request -> Lidarr artist, monitored
|
||||||
// release-group want -> Lidarr album, monitored (artist added if new)
|
// release-group request -> Lidarr album, monitored (artist added if new)
|
||||||
// release want -> same, at release-group granularity
|
// release request -> same, at release-group granularity
|
||||||
// recording want -> not pushed; Lidarr has no concept of wanting
|
// recording request -> not pushed; Lidarr has no concept of wanting
|
||||||
// one track, and monitoring the whole album to
|
// one track, and monitoring the whole album to
|
||||||
// get it would download far more than asked.
|
// get it would download far more than asked.
|
||||||
//
|
//
|
||||||
// Nothing here searches. Pushing a want expresses intent; Lidarr
|
// Nothing here searches. Pushing a request expresses intent; Lidarr
|
||||||
// decides when to act on it, which is the point of delegating.
|
// decides when to act on it, which is the point of delegating.
|
||||||
|
|
||||||
// PushWant records a want in Lidarr's own monitoring.
|
// PushRequest records a request in Lidarr's own monitoring.
|
||||||
//
|
//
|
||||||
// It is idempotent because Lidarr is: adding an artist that already
|
// It is idempotent because Lidarr is: adding an artist that already
|
||||||
// exists returns the existing record, and monitoring an already-
|
// exists returns the existing record, and monitoring an already-
|
||||||
// monitored album is a no-op. Callers rely on that — the reconciler
|
// monitored album is a no-op. Callers rely on that — the reconciler
|
||||||
// pushes on every pass until it gets an ID back.
|
// pushes on every pass until it gets an ID back.
|
||||||
func (l *lidarr) PushWant(ctx context.Context, w Want) (string, error) {
|
func (l *lidarr) PushRequest(ctx context.Context, r Request) (string, error) {
|
||||||
switch w.Entity {
|
switch r.Entity {
|
||||||
case EntityArtist:
|
case EntityArtist:
|
||||||
return l.pushArtistWant(ctx, w)
|
return l.pushArtistRequest(ctx, r)
|
||||||
case EntityReleaseGroup, EntityRelease:
|
case EntityReleaseGroup, EntityRelease:
|
||||||
return l.pushAlbumWant(ctx, w)
|
return l.pushAlbumRequest(ctx, r)
|
||||||
case EntityRecording:
|
case EntityRecording:
|
||||||
// Deliberately unsupported rather than approximated. See the
|
// Deliberately unsupported rather than approximated. See the
|
||||||
// mapping note above.
|
// mapping note above.
|
||||||
return "", nil
|
return "", nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, w.Entity)
|
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, r.Entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushArtistWant makes Lidarr monitor an artist.
|
// pushArtistRequest makes Lidarr monitor an artist.
|
||||||
//
|
//
|
||||||
// Scope is honoured through Lidarr's own monitor option rather than by
|
// Scope is honoured through Lidarr's own monitor option rather than by
|
||||||
// pushing each album separately: "future" maps to monitoring new
|
// pushing each album separately: "future" maps to monitoring new
|
||||||
// releases only, "all" to monitoring everything missing. Letting
|
// releases only, "all" to monitoring everything missing. Letting
|
||||||
// Lidarr apply the policy means it stays applied to albums released
|
// Lidarr apply the policy means it stays applied to albums released
|
||||||
// after this push, which is what a subscription is for.
|
// after this push, which is what a subscription is for.
|
||||||
func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
|
func (l *lidarr) pushArtistRequest(ctx context.Context, r Request) (string, error) {
|
||||||
existing, err := l.findArtistByMBID(ctx, w.MBID)
|
existing, err := l.findArtistByMBID(ctx, r.MBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
monitor := "future"
|
monitor := "future"
|
||||||
if w.Scope == ScopeAll {
|
if r.Scope == ScopeAll {
|
||||||
monitor = "missing"
|
monitor = "missing"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +84,13 @@ func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
name := w.Artist
|
name := r.Artist
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = w.Title
|
name = r.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"foreignArtistId": w.MBID,
|
"foreignArtistId": r.MBID,
|
||||||
"artistName": name,
|
"artistName": name,
|
||||||
"qualityProfileId": quality,
|
"qualityProfileId": quality,
|
||||||
"metadataProfileId": metadata,
|
"metadataProfileId": metadata,
|
||||||
@@ -118,13 +118,13 @@ func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
|
|||||||
return strconv.Itoa(created.ID), nil
|
return strconv.Itoa(created.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushAlbumWant makes Lidarr monitor one album, adding its artist if
|
// pushAlbumRequest makes Lidarr monitor one album, adding its artist if
|
||||||
// Lidarr has never heard of them.
|
// Lidarr has never heard of them.
|
||||||
func (l *lidarr) pushAlbumWant(ctx context.Context, w Want) (string, error) {
|
func (l *lidarr) pushAlbumRequest(ctx context.Context, r Request) (string, error) {
|
||||||
album, err := l.findAlbum(ctx, Request{
|
album, err := l.findAlbum(ctx, Download{
|
||||||
ReleaseGroupMBID: w.MBID,
|
ReleaseGroupMBID: r.MBID,
|
||||||
Artist: w.Artist,
|
Artist: r.Artist,
|
||||||
Album: w.Title,
|
Album: r.Title,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -146,13 +146,13 @@ func (l *lidarr) pushAlbumWant(ctx context.Context, w Want) (string, error) {
|
|||||||
return strconv.Itoa(album.ID), nil
|
return strconv.Itoa(album.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveWant stops Lidarr monitoring something.
|
// RemoveRequest stops Lidarr monitoring something.
|
||||||
//
|
//
|
||||||
// It unmonitors rather than deletes: the user's Lidarr may have been
|
// It unmonitors rather than deletes: the user's Lidarr may have been
|
||||||
// monitoring that artist long before this app existed, and removing a
|
// monitoring that artist long before this app existed, and removing a
|
||||||
// want here is not permission to tear down their setup. An unmonitored
|
// request here is not permission to tear down their setup. An unmonitored
|
||||||
// artist stays in their library with its files intact.
|
// artist stays in their library with its files intact.
|
||||||
func (l *lidarr) RemoveWant(ctx context.Context, externalID string) error {
|
func (l *lidarr) RemoveRequest(ctx context.Context, externalID string) error {
|
||||||
id, err := strconv.Atoi(externalID)
|
id, err := strconv.Atoi(externalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: bad lidarr id %q", ErrLidarrNoMatch, externalID)
|
return fmt.Errorf("%w: bad lidarr id %q", ErrLidarrNoMatch, externalID)
|
||||||
@@ -180,14 +180,14 @@ func (l *lidarr) RemoveWant(ctx context.Context, externalID string) error {
|
|||||||
return l.client.put(ctx, endpoint, artist, nil)
|
return l.client.put(ctx, endpoint, artist, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListWants reads Lidarr's monitored artists back, for the deliberate
|
// ListRequests reads Lidarr's monitored artists back, for the deliberate
|
||||||
// "import what Lidarr is already watching" action.
|
// "import what Lidarr is already watching" action.
|
||||||
//
|
//
|
||||||
// Only artists are imported, not their individual monitored albums: an
|
// Only artists are imported, not their individual monitored albums: an
|
||||||
// artist is the durable statement of intent, and importing every
|
// artist is the durable statement of intent, and importing every
|
||||||
// monitored album alongside it would produce a wanted list that is
|
// monitored album alongside it would produce a request list that is
|
||||||
// mostly redundant with the subscription that generated it.
|
// mostly redundant with the subscription that generated it.
|
||||||
func (l *lidarr) ListWants(ctx context.Context) ([]Want, error) {
|
func (l *lidarr) ListRequests(ctx context.Context) ([]Request, error) {
|
||||||
var artists []struct {
|
var artists []struct {
|
||||||
lidarrArtist
|
lidarrArtist
|
||||||
|
|
||||||
@@ -198,14 +198,14 @@ func (l *lidarr) ListWants(ctx context.Context) ([]Want, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]Want, 0, len(artists))
|
out := make([]Request, 0, len(artists))
|
||||||
|
|
||||||
for _, a := range artists {
|
for _, a := range artists {
|
||||||
if !a.Monitored || a.ForeignArtistID == "" {
|
if !a.Monitored || a.ForeignArtistID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
out = append(out, Want{
|
out = append(out, Request{
|
||||||
MBID: a.ForeignArtistID,
|
MBID: a.ForeignArtistID,
|
||||||
Entity: EntityArtist,
|
Entity: EntityArtist,
|
||||||
Artist: a.ArtistName,
|
Artist: a.ArtistName,
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import (
|
|||||||
// scope translated into Lidarr's own monitor option — so the policy
|
// scope translated into Lidarr's own monitor option — so the policy
|
||||||
// keeps applying to albums released after the push, which is the whole
|
// keeps applying to albums released after the push, which is the whole
|
||||||
// reason to mirror a subscription rather than a list of albums.
|
// reason to mirror a subscription rather than a list of albums.
|
||||||
func TestLidarrPushArtistWantMapsScope(t *testing.T) {
|
func TestLidarrPushArtistRequestMapsScope(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
scope WantScope
|
scope RequestScope
|
||||||
wantMonitor string
|
wantMonitor string
|
||||||
}{
|
}{
|
||||||
{name: "future", scope: ScopeFuture, wantMonitor: "future"},
|
{name: "future", scope: ScopeFuture, wantMonitor: "future"},
|
||||||
@@ -25,14 +25,14 @@ func TestLidarrPushArtistWantMapsScope(t *testing.T) {
|
|||||||
stub := newLidarrStub(t)
|
stub := newLidarrStub(t)
|
||||||
l := newStubLidarr(t, stub)
|
l := newStubLidarr(t, stub)
|
||||||
|
|
||||||
id, err := l.PushWant(context.Background(), Want{
|
id, err := l.PushRequest(context.Background(), Request{
|
||||||
MBID: "artist-mbid",
|
MBID: "artist-mbid",
|
||||||
Entity: EntityArtist,
|
Entity: EntityArtist,
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Scope: tt.scope,
|
Scope: tt.scope,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s: PushWant: %v", tt.name, err)
|
t.Fatalf("%s: PushRequest: %v", tt.name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if id != "42" {
|
if id != "42" {
|
||||||
@@ -70,7 +70,7 @@ func TestLidarrPushArtistWantMapsScope(t *testing.T) {
|
|||||||
// Pushing a want Lidarr already has returns the existing ID instead of
|
// Pushing a want Lidarr already has returns the existing ID instead of
|
||||||
// adding a second copy — the reconciler pushes on every pass, so this
|
// adding a second copy — the reconciler pushes on every pass, so this
|
||||||
// is load-bearing rather than tidy.
|
// is load-bearing rather than tidy.
|
||||||
func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
|
func TestLidarrPushArtistRequestIsIdempotent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
stub := newLidarrStub(t)
|
stub := newLidarrStub(t)
|
||||||
@@ -83,13 +83,13 @@ func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
|
|||||||
|
|
||||||
l := newStubLidarr(t, stub)
|
l := newStubLidarr(t, stub)
|
||||||
|
|
||||||
id, err := l.PushWant(context.Background(), Want{
|
id, err := l.PushRequest(context.Background(), Request{
|
||||||
MBID: "artist-mbid",
|
MBID: "artist-mbid",
|
||||||
Entity: EntityArtist,
|
Entity: EntityArtist,
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PushWant: %v", err)
|
t.Fatalf("PushRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if id != "7" {
|
if id != "7" {
|
||||||
@@ -107,19 +107,19 @@ func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
|
|||||||
|
|
||||||
// Lidarr cannot express "I want one track", and monitoring the whole
|
// Lidarr cannot express "I want one track", and monitoring the whole
|
||||||
// album to get it would download far more than was asked for.
|
// album to get it would download far more than was asked for.
|
||||||
func TestLidarrPushRecordingWantIsSkipped(t *testing.T) {
|
func TestLidarrPushRecordingRequestIsSkipped(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
stub := newLidarrStub(t)
|
stub := newLidarrStub(t)
|
||||||
l := newStubLidarr(t, stub)
|
l := newStubLidarr(t, stub)
|
||||||
|
|
||||||
id, err := l.PushWant(context.Background(), Want{
|
id, err := l.PushRequest(context.Background(), Request{
|
||||||
MBID: "recording-mbid",
|
MBID: "recording-mbid",
|
||||||
Entity: EntityRecording,
|
Entity: EntityRecording,
|
||||||
Title: "Paranoid Android",
|
Title: "Paranoid Android",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PushWant: %v", err)
|
t.Fatalf("PushRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if id != "" {
|
if id != "" {
|
||||||
@@ -141,7 +141,7 @@ func TestLidarrPushRecordingWantIsSkipped(t *testing.T) {
|
|||||||
|
|
||||||
// Importing adopts monitored artists conservatively: a subscription
|
// Importing adopts monitored artists conservatively: a subscription
|
||||||
// pulled in from elsewhere must not queue a back catalogue.
|
// pulled in from elsewhere must not queue a back catalogue.
|
||||||
func TestLidarrListWantsImportsMonitoredArtistsOnly(t *testing.T) {
|
func TestLidarrListRequestsImportsMonitoredArtistsOnly(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
stub := newLidarrStub(t)
|
stub := newLidarrStub(t)
|
||||||
@@ -168,40 +168,40 @@ func TestLidarrListWantsImportsMonitoredArtistsOnly(t *testing.T) {
|
|||||||
|
|
||||||
l := newStubLidarr(t, stub)
|
l := newStubLidarr(t, stub)
|
||||||
|
|
||||||
wants, err := l.ListWants(context.Background())
|
requests, err := l.ListRequests(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListWants: %v", err)
|
t.Fatalf("ListRequests: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(wants) != 1 {
|
if len(requests) != 1 {
|
||||||
t.Fatalf("imported %d wants, want 1", len(wants))
|
t.Fatalf("imported %d requests, want 1", len(requests))
|
||||||
}
|
}
|
||||||
|
|
||||||
w := wants[0]
|
req := requests[0]
|
||||||
|
|
||||||
if w.MBID != "artist-1" {
|
if req.MBID != "artist-1" {
|
||||||
t.Errorf("mbid = %q, want artist-1", w.MBID)
|
t.Errorf("mbid = %q, want artist-1", req.MBID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.Entity != EntityArtist {
|
if req.Entity != EntityArtist {
|
||||||
t.Errorf("entity = %q, want artist", w.Entity)
|
t.Errorf("entity = %q, want artist", req.Entity)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.Scope != ScopeFuture {
|
if req.Scope != ScopeFuture {
|
||||||
t.Errorf("scope = %q, want the conservative future", w.Scope)
|
t.Errorf("scope = %q, want the conservative future", req.Scope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removing a want must not tear down a Lidarr setup that may predate
|
// Removing a want must not tear down a Lidarr setup that may predate
|
||||||
// this app: it unmonitors, it does not delete.
|
// this app: it unmonitors, it does not delete.
|
||||||
func TestLidarrRemoveWantUnmonitorsOnly(t *testing.T) {
|
func TestLidarrRemoveRequestUnmonitorsOnly(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
stub := newLidarrStub(t)
|
stub := newLidarrStub(t)
|
||||||
l := newStubLidarr(t, stub)
|
l := newStubLidarr(t, stub)
|
||||||
|
|
||||||
if err := l.RemoveWant(context.Background(), "55"); err != nil {
|
if err := l.RemoveRequest(context.Background(), "55"); err != nil {
|
||||||
t.Fatalf("RemoveWant: %v", err)
|
t.Fatalf("RemoveRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stub.mu.Lock()
|
stub.mu.Lock()
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ func TestLidarrDelegateExistingAlbum(t *testing.T) {
|
|||||||
|
|
||||||
l := newStubLidarr(t, stub)
|
l := newStubLidarr(t, stub)
|
||||||
|
|
||||||
externalID, err := l.Delegate(context.Background(), Request{
|
externalID, err := l.Delegate(context.Background(), Download{
|
||||||
ReleaseGroupMBID: "rg-mbid",
|
ReleaseGroupMBID: "rg-mbid",
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Album: "OK Computer",
|
Album: "OK Computer",
|
||||||
@@ -373,7 +373,7 @@ func TestLidarrDelegateNewArtistMonitorsNothingByDefault(t *testing.T) {
|
|||||||
|
|
||||||
l := newStubLidarr(t, stub)
|
l := newStubLidarr(t, stub)
|
||||||
|
|
||||||
if _, err := l.Delegate(context.Background(), Request{
|
if _, err := l.Delegate(context.Background(), Download{
|
||||||
ReleaseGroupMBID: "rg-mbid",
|
ReleaseGroupMBID: "rg-mbid",
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Album: "OK Computer",
|
Album: "OK Computer",
|
||||||
@@ -414,7 +414,7 @@ func TestLidarrDelegateNoMatch(t *testing.T) {
|
|||||||
|
|
||||||
l := newStubLidarr(t, stub)
|
l := newStubLidarr(t, stub)
|
||||||
|
|
||||||
_, err := l.Delegate(context.Background(), Request{
|
_, err := l.Delegate(context.Background(), Download{
|
||||||
Artist: "Nobody",
|
Artist: "Nobody",
|
||||||
Album: "Nothing",
|
Album: "Nothing",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -210,10 +210,10 @@ type prowlarrResult struct {
|
|||||||
// Search queries every configured indexer through Prowlarr.
|
// Search queries every configured indexer through Prowlarr.
|
||||||
func (p *prowlarr) Search(
|
func (p *prowlarr) Search(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
) ([]Candidate, error) {
|
) ([]Candidate, error) {
|
||||||
query := url.Values{}
|
query := url.Values{}
|
||||||
query.Set("query", req.SearchText())
|
query.Set("query", dl.SearchText())
|
||||||
query.Set("categories", prowlarrMusicCategory)
|
query.Set("categories", prowlarrMusicCategory)
|
||||||
query.Set("type", "search")
|
query.Set("type", "search")
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func TestProwlarrSearchMarksProtocols(t *testing.T) {
|
|||||||
|
|
||||||
p := newStubProwlarr(t, stub, nil)
|
p := newStubProwlarr(t, stub, nil)
|
||||||
|
|
||||||
got, err := p.Search(context.Background(), Request{
|
got, err := p.Search(context.Background(), Download{
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Album: "OK Computer",
|
Album: "OK Computer",
|
||||||
})
|
})
|
||||||
@@ -230,7 +230,7 @@ func TestProwlarrFiltersDeadTorrents(t *testing.T) {
|
|||||||
|
|
||||||
p := newStubProwlarr(t, stub, map[string]string{"minSeeders": "1"})
|
p := newStubProwlarr(t, stub, map[string]string{"minSeeders": "1"})
|
||||||
|
|
||||||
got, err := p.Search(context.Background(), Request{Query: "x"})
|
got, err := p.Search(context.Background(), Download{Query: "x"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Search: %v", err)
|
t.Fatalf("Search: %v", err)
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ func TestProwlarrSearchesMusicCategory(t *testing.T) {
|
|||||||
p := newStubProwlarr(t, stub, nil)
|
p := newStubProwlarr(t, stub, nil)
|
||||||
|
|
||||||
if _, err := p.Search(
|
if _, err := p.Search(
|
||||||
context.Background(), Request{Query: "radiohead"},
|
context.Background(), Download{Query: "radiohead"},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("Search: %v", err)
|
t.Fatalf("Search: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ func (t slskdTransfer) done() (finished, ok bool) {
|
|||||||
// per-folder candidates. A folder from one peer is the unit a user
|
// per-folder candidates. A folder from one peer is the unit a user
|
||||||
// actually wants: Soulseek has no album concept, but people organise
|
// actually wants: Soulseek has no album concept, but people organise
|
||||||
// their shares by album directory.
|
// their shares by album directory.
|
||||||
func (s *slskd) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
func (s *slskd) Search(ctx context.Context, dl Download) ([]Candidate, error) {
|
||||||
// slskd's search endpoint deserializes id as a .NET Guid server-side,
|
// slskd's search endpoint deserializes id as a .NET Guid server-side,
|
||||||
// so it must be a dashed UUID — the app's own newID() (a plain hex
|
// so it must be a dashed UUID — the app's own newID() (a plain hex
|
||||||
// string, used for request/item IDs elsewhere) is rejected with an
|
// string, used for request/item IDs elsewhere) is rejected with an
|
||||||
@@ -287,7 +287,7 @@ func (s *slskd) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
|||||||
|
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"id": searchID,
|
"id": searchID,
|
||||||
"searchText": req.SearchText(),
|
"searchText": dl.SearchText(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.client.post(ctx, "/api/v0/searches", body, nil); err != nil {
|
if err := s.client.post(ctx, "/api/v0/searches", body, nil); err != nil {
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ func TestSlskdGroupsResultsByPeerAndFolder(t *testing.T) {
|
|||||||
|
|
||||||
s, _ := newStubSlskd(t, stub)
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
got, err := s.Search(context.Background(), Request{
|
got, err := s.Search(context.Background(), Download{
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Album: "OK Computer",
|
Album: "OK Computer",
|
||||||
})
|
})
|
||||||
@@ -354,7 +354,7 @@ func TestSlskdSkipsTinyFolders(t *testing.T) {
|
|||||||
|
|
||||||
s, _ := newStubSlskd(t, stub)
|
s, _ := newStubSlskd(t, stub)
|
||||||
|
|
||||||
got, err := s.Search(context.Background(), Request{Query: "x"})
|
got, err := s.Search(context.Background(), Download{Query: "x"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Search: %v", err)
|
t.Fatalf("Search: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,9 +201,9 @@ func (e ytEntry) link() string {
|
|||||||
// how yt-dlp is actually useful for albums — a single "full album"
|
// how yt-dlp is actually useful for albums — a single "full album"
|
||||||
// video is one file and cannot be imported as tracks. Without a
|
// video is one file and cannot be imported as tracks. Without a
|
||||||
// tracklist it falls back to returning the top individual results.
|
// tracklist it falls back to returning the top individual results.
|
||||||
func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
func (y *ytDlp) Search(ctx context.Context, dl Download) ([]Candidate, error) {
|
||||||
if len(req.Expected) > 0 {
|
if len(dl.Expected) > 0 {
|
||||||
c, err := y.assembleAlbum(ctx, req)
|
c, err := y.assembleAlbum(ctx, dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -213,7 +213,7 @@ func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := y.search(ctx, req.SearchText(), ytSearchCount)
|
entries, err := y.search(ctx, dl.SearchText(), ytSearchCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -257,7 +257,7 @@ func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
|
|||||||
// threshold decides whether what arrived is enough.
|
// threshold decides whether what arrived is enough.
|
||||||
func (y *ytDlp) assembleAlbum(
|
func (y *ytDlp) assembleAlbum(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req Request,
|
dl Download,
|
||||||
) (Candidate, error) {
|
) (Candidate, error) {
|
||||||
type hit struct {
|
type hit struct {
|
||||||
index int
|
index int
|
||||||
@@ -272,10 +272,10 @@ func (y *ytDlp) assembleAlbum(
|
|||||||
group, gctx := errgroup.WithContext(ctx)
|
group, gctx := errgroup.WithContext(ctx)
|
||||||
group.SetLimit(ytTrackConcurrency)
|
group.SetLimit(ytTrackConcurrency)
|
||||||
|
|
||||||
for i, track := range req.Expected {
|
for i, track := range dl.Expected {
|
||||||
group.Go(func() error {
|
group.Go(func() error {
|
||||||
query := strings.TrimSpace(
|
query := strings.TrimSpace(
|
||||||
req.Artist + " " + track.Title,
|
dl.Artist + " " + track.Title,
|
||||||
)
|
)
|
||||||
|
|
||||||
entries, err := y.search(gctx, query, 1)
|
entries, err := y.search(gctx, query, 1)
|
||||||
@@ -300,11 +300,11 @@ func (y *ytDlp) assembleAlbum(
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := Candidate{
|
c := Candidate{
|
||||||
ID: "ytdlp:album:" + req.ID,
|
ID: "ytdlp:album:" + dl.ID,
|
||||||
Kind: KindYtDlp,
|
Kind: KindYtDlp,
|
||||||
Protocol: ProtocolDirect,
|
Protocol: ProtocolDirect,
|
||||||
Title: req.Album,
|
Title: dl.Album,
|
||||||
Artist: req.Artist,
|
Artist: dl.Artist,
|
||||||
Origin: "yt-dlp (assembled per track)",
|
Origin: "yt-dlp (assembled per track)",
|
||||||
Health: 0.75,
|
Health: 0.75,
|
||||||
Payload: map[string]string{},
|
Payload: map[string]string{},
|
||||||
@@ -312,7 +312,7 @@ func (y *ytDlp) assembleAlbum(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, h := range hits {
|
for _, h := range hits {
|
||||||
track := req.Expected[h.index]
|
track := dl.Expected[h.index]
|
||||||
|
|
||||||
link := h.entry.link()
|
link := h.entry.link()
|
||||||
if link == "" {
|
if link == "" {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ cat <<'EOF'
|
|||||||
EOF
|
EOF
|
||||||
`)
|
`)
|
||||||
|
|
||||||
got, err := y.Search(context.Background(), Request{
|
got, err := y.Search(context.Background(), Download{
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Album: "OK Computer",
|
Album: "OK Computer",
|
||||||
})
|
})
|
||||||
@@ -173,7 +173,7 @@ not json at all
|
|||||||
EOF
|
EOF
|
||||||
`)
|
`)
|
||||||
|
|
||||||
got, err := y.Search(context.Background(), Request{Query: "radiohead"})
|
got, err := y.Search(context.Background(), Download{Query: "radiohead"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Search: %v", err)
|
t.Fatalf("Search: %v", err)
|
||||||
}
|
}
|
||||||
@@ -193,8 +193,8 @@ func TestYtDlpAssemblesAlbumFromTracklist(t *testing.T) {
|
|||||||
echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https://example.com/x","filesize_approx":4000000}'
|
echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https://example.com/x","filesize_approx":4000000}'
|
||||||
`)
|
`)
|
||||||
|
|
||||||
req := Request{
|
dl := Download{
|
||||||
ID: "req-1",
|
ID: "dl-1",
|
||||||
ReleaseMBID: "mbid-1",
|
ReleaseMBID: "mbid-1",
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Album: "OK Computer",
|
Album: "OK Computer",
|
||||||
@@ -205,7 +205,7 @@ echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https:/
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := y.Search(context.Background(), req)
|
got, err := y.Search(context.Background(), dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Search: %v", err)
|
t.Fatalf("Search: %v", err)
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ case "$*" in
|
|||||||
esac
|
esac
|
||||||
`)
|
`)
|
||||||
|
|
||||||
got, err := y.Search(context.Background(), Request{
|
got, err := y.Search(context.Background(), Download{
|
||||||
ID: "req-1",
|
ID: "req-1",
|
||||||
ReleaseMBID: "mbid-1",
|
ReleaseMBID: "mbid-1",
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
|
|||||||
+137
-23
@@ -34,12 +34,13 @@ const (
|
|||||||
weightArtistFit = 0.12
|
weightArtistFit = 0.12
|
||||||
)
|
)
|
||||||
|
|
||||||
// Quality sub-weights.
|
// Quality sub-weights. They sum to 1.0 along with weightSizeFit below.
|
||||||
const (
|
const (
|
||||||
weightFormat = 0.45
|
weightFormat = 0.42
|
||||||
weightBitrate = 0.25
|
weightBitrate = 0.23
|
||||||
weightHealth = 0.20
|
weightHealth = 0.20
|
||||||
weightPriority = 0.10
|
weightPriority = 0.10
|
||||||
|
weightSizeFit = 0.05
|
||||||
)
|
)
|
||||||
|
|
||||||
// unanchoredCap bounds the match score of a free-text request. Without
|
// unanchoredCap bounds the match score of a free-text request. Without
|
||||||
@@ -47,20 +48,119 @@ const (
|
|||||||
// looking score would be a lie — and auto-pick keys off this.
|
// looking score would be a lie — and auto-pick keys off this.
|
||||||
const unanchoredCap = 0.65
|
const unanchoredCap = 0.65
|
||||||
|
|
||||||
|
// AutoDownloadPrefs gates and scores what AutoPickable may choose
|
||||||
|
// without asking. Zero values are permissive: no size window and no
|
||||||
|
// format restriction.
|
||||||
|
type AutoDownloadPrefs struct {
|
||||||
|
// MinSizeMB and MaxSizeMB bound what auto-pick will grab. Zero
|
||||||
|
// means no bound on that side. A candidate outside the window is
|
||||||
|
// filtered out of auto-pick entirely, not merely scored down — a
|
||||||
|
// tiny "sampler" torrent or a boxset ten times the expected size is
|
||||||
|
// usually the wrong thing entirely, not a worse copy of the right
|
||||||
|
// thing.
|
||||||
|
MinSizeMB int `json:"minSizeMb"`
|
||||||
|
MaxSizeMB int `json:"maxSizeMb"`
|
||||||
|
|
||||||
|
// PreferredSizeMB nudges the score toward a target size within the
|
||||||
|
// min/max window (a lossless rip and a heavily-padded lossless rip
|
||||||
|
// can both pass the window). Zero disables the nudge; sizeFit then
|
||||||
|
// returns a neutral value that does not affect ranking.
|
||||||
|
PreferredSizeMB int `json:"preferredSizeMb"`
|
||||||
|
|
||||||
|
// AllowedFormats restricts auto-pick to candidates whose audio
|
||||||
|
// files are all in one of these formats. Empty means no
|
||||||
|
// restriction.
|
||||||
|
AllowedFormats []Format `json:"allowedFormats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// eligible reports whether a candidate may be auto-picked under these
|
||||||
|
// preferences: within the size window (when set) and, when a format
|
||||||
|
// list is given, every audio file in an allowed format.
|
||||||
|
func (p AutoDownloadPrefs) eligible(c Candidate) bool {
|
||||||
|
const bytesPerMB = 1 << 20
|
||||||
|
|
||||||
|
if p.MinSizeMB > 0 && c.TotalSize < int64(p.MinSizeMB)*bytesPerMB {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.MaxSizeMB > 0 && c.TotalSize > int64(p.MaxSizeMB)*bytesPerMB {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.AllowedFormats) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := make(map[Format]bool, len(p.AllowedFormats))
|
||||||
|
for _, f := range p.AllowedFormats {
|
||||||
|
allowed[f] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range c.AudioFiles() {
|
||||||
|
if !allowed[f.Format] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter returns only the candidates these preferences allow to be
|
||||||
|
// auto-picked, in the same (already ranked) order.
|
||||||
|
func (p AutoDownloadPrefs) filter(ranked []Candidate) []Candidate {
|
||||||
|
out := make([]Candidate, 0, len(ranked))
|
||||||
|
|
||||||
|
for _, c := range ranked {
|
||||||
|
if p.eligible(c) {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sizeFit scores how close totalSize is to PreferredSizeMB, 0..1,
|
||||||
|
// falling off linearly as the size doubles or halves away from it.
|
||||||
|
// Returns a neutral 0.5 when no preference is set, so the absence of a
|
||||||
|
// preference does not bias ranking.
|
||||||
|
func (p AutoDownloadPrefs) sizeFit(totalSize int64) float64 {
|
||||||
|
const (
|
||||||
|
bytesPerMB = 1 << 20
|
||||||
|
neutral = 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
if p.PreferredSizeMB <= 0 || totalSize <= 0 {
|
||||||
|
return neutral
|
||||||
|
}
|
||||||
|
|
||||||
|
preferred := float64(p.PreferredSizeMB) * bytesPerMB
|
||||||
|
ratio := float64(totalSize) / preferred
|
||||||
|
|
||||||
|
if ratio < 1 {
|
||||||
|
ratio = 1 / ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
// ratio is now >= 1: 1.0 is an exact match, 2.0 is double or half
|
||||||
|
// the preferred size. Falls to 0 at 2x away and beyond.
|
||||||
|
fit := 1 - (ratio - 1)
|
||||||
|
|
||||||
|
return clamp01(fit)
|
||||||
|
}
|
||||||
|
|
||||||
// Score fills a candidate's Match, Quality and Score fields.
|
// Score fills a candidate's Match, Quality and Score fields.
|
||||||
func Score(req Request, c Candidate, priority int) Candidate {
|
func Score(dl Download, c Candidate, priority int, prefs AutoDownloadPrefs) Candidate {
|
||||||
c.Files = AnnotateFiles(c.Files)
|
c.Files = AnnotateFiles(c.Files)
|
||||||
|
|
||||||
audio := c.AudioFiles()
|
audio := c.AudioFiles()
|
||||||
|
|
||||||
matched, titleFit := matchFiles(audio, req.Expected)
|
matched, titleFit := matchFiles(audio, dl.Expected)
|
||||||
|
|
||||||
// Write the alignment back so the picker can show which file maps
|
// Write the alignment back so the picker can show which file maps
|
||||||
// to which track.
|
// to which track.
|
||||||
c.Files = mergeMatched(c.Files, matched)
|
c.Files = mergeMatched(c.Files, matched)
|
||||||
|
|
||||||
c.Match = scoreMatch(req, c, audio, titleFit)
|
c.Match = scoreMatch(dl, c, audio, titleFit)
|
||||||
c.Quality = scoreQuality(c, audio, priority)
|
c.Quality = scoreQuality(c, audio, priority, prefs)
|
||||||
|
|
||||||
c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall
|
c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall
|
||||||
|
|
||||||
@@ -69,17 +169,17 @@ func Score(req Request, c Candidate, priority int) Candidate {
|
|||||||
|
|
||||||
// scoreMatch answers whether this candidate is the requested release.
|
// scoreMatch answers whether this candidate is the requested release.
|
||||||
func scoreMatch(
|
func scoreMatch(
|
||||||
req Request,
|
dl Download,
|
||||||
c Candidate,
|
c Candidate,
|
||||||
audio []CandidateFile,
|
audio []CandidateFile,
|
||||||
titleFit float64,
|
titleFit float64,
|
||||||
) MatchScore {
|
) MatchScore {
|
||||||
m := MatchScore{
|
m := MatchScore{
|
||||||
Anchored: req.Anchored(),
|
Anchored: dl.Anchored(),
|
||||||
TitleFit: titleFit,
|
TitleFit: titleFit,
|
||||||
}
|
}
|
||||||
|
|
||||||
m.Completeness = completeness(len(audio), len(req.Expected))
|
m.Completeness = completeness(len(audio), len(dl.Expected))
|
||||||
|
|
||||||
// The candidate's own title, and the folder its files sit in, are
|
// The candidate's own title, and the folder its files sit in, are
|
||||||
// two independent guesses at the album name. Take the better one:
|
// two independent guesses at the album name. Take the better one:
|
||||||
@@ -90,16 +190,16 @@ func scoreMatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.AlbumFit = math.Max(
|
m.AlbumFit = math.Max(
|
||||||
autotag.TitleSimilarity(req.Album, c.Title),
|
autotag.TitleSimilarity(dl.Album, c.Title),
|
||||||
autotag.TitleSimilarity(req.Album, folder),
|
autotag.TitleSimilarity(dl.Album, folder),
|
||||||
)
|
)
|
||||||
|
|
||||||
m.ArtistFit = artistFit(req.Artist, c)
|
m.ArtistFit = artistFit(dl.Artist, c)
|
||||||
|
|
||||||
// With no expected tracklist there is no title signal at all, so
|
// With no expected tracklist there is no title signal at all, so
|
||||||
// redistribute its weight onto the album/artist evidence rather
|
// redistribute its weight onto the album/artist evidence rather
|
||||||
// than scoring every free-text result as half-wrong.
|
// than scoring every free-text result as half-wrong.
|
||||||
if len(req.Expected) == 0 {
|
if len(dl.Expected) == 0 {
|
||||||
m.Overall = 0.55*m.AlbumFit + 0.45*m.ArtistFit
|
m.Overall = 0.55*m.AlbumFit + 0.45*m.ArtistFit
|
||||||
} else {
|
} else {
|
||||||
m.Overall = weightTitleFit*m.TitleFit +
|
m.Overall = weightTitleFit*m.TitleFit +
|
||||||
@@ -178,10 +278,12 @@ func scoreQuality(
|
|||||||
c Candidate,
|
c Candidate,
|
||||||
audio []CandidateFile,
|
audio []CandidateFile,
|
||||||
priority int,
|
priority int,
|
||||||
|
prefs AutoDownloadPrefs,
|
||||||
) QualityScore {
|
) QualityScore {
|
||||||
q := QualityScore{
|
q := QualityScore{
|
||||||
Health: clamp01(c.Health),
|
Health: clamp01(c.Health),
|
||||||
Priority: clamp01(float64(priority) / 100.0),
|
Priority: clamp01(float64(priority) / 100.0),
|
||||||
|
SizeFit: prefs.sizeFit(c.TotalSize),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(audio) == 0 {
|
if len(audio) == 0 {
|
||||||
@@ -211,7 +313,8 @@ func scoreQuality(
|
|||||||
q.Overall = weightFormat*q.FormatRank +
|
q.Overall = weightFormat*q.FormatRank +
|
||||||
weightBitrate*q.Bitrate +
|
weightBitrate*q.Bitrate +
|
||||||
weightHealth*q.Health +
|
weightHealth*q.Health +
|
||||||
weightPriority*q.Priority
|
weightPriority*q.Priority +
|
||||||
|
weightSizeFit*q.SizeFit
|
||||||
|
|
||||||
if q.Mixed {
|
if q.Mixed {
|
||||||
q.Overall *= 0.9
|
q.Overall *= 0.9
|
||||||
@@ -316,9 +419,10 @@ func lossyBitrateScore(kbps int) float64 {
|
|||||||
// on match, then on provider priority, then on file count, so the order
|
// on match, then on provider priority, then on file count, so the order
|
||||||
// is stable across runs rather than map-iteration dependent.
|
// is stable across runs rather than map-iteration dependent.
|
||||||
func Rank(
|
func Rank(
|
||||||
req Request,
|
dl Download,
|
||||||
candidates []Candidate,
|
candidates []Candidate,
|
||||||
priority func(providerID int64) int,
|
priority func(providerID int64) int,
|
||||||
|
prefs AutoDownloadPrefs,
|
||||||
) []Candidate {
|
) []Candidate {
|
||||||
out := make([]Candidate, 0, len(candidates))
|
out := make([]Candidate, 0, len(candidates))
|
||||||
|
|
||||||
@@ -328,7 +432,7 @@ func Rank(
|
|||||||
p = priority(c.ProviderID)
|
p = priority(c.ProviderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
out = append(out, Score(req, c, p))
|
out = append(out, Score(dl, c, p, prefs))
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
@@ -354,31 +458,41 @@ func Rank(
|
|||||||
// to grab without asking. It demands an anchored request, a high match,
|
// to grab without asking. It demands an anchored request, a high match,
|
||||||
// decent quality, and daylight between first and second place — if two
|
// decent quality, and daylight between first and second place — if two
|
||||||
// candidates are close, the choice is the user's.
|
// candidates are close, the choice is the user's.
|
||||||
func AutoPickable(req Request, ranked []Candidate) bool {
|
func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool {
|
||||||
const (
|
const (
|
||||||
minMatch = 0.85
|
minMatch = 0.85
|
||||||
minQuality = 0.5
|
minQuality = 0.5
|
||||||
minLead = 0.08
|
minLead = 0.08
|
||||||
)
|
)
|
||||||
|
|
||||||
if !req.Anchored() || len(ranked) == 0 {
|
if !dl.Anchored() || len(ranked) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// An anchor with no tracklist behind it is an anchor in name only:
|
// An anchor with no tracklist behind it is an anchor in name only:
|
||||||
// the match score then rests on album and artist text alone, which
|
// the match score then rests on album and artist text alone, which
|
||||||
// is exactly the evidence a wrong-album candidate also has. This
|
// is exactly the evidence a wrong-album candidate also has. This
|
||||||
// matters most for the wanted list, where nobody is watching.
|
// matters most for the request list, where nobody is watching.
|
||||||
if len(req.Expected) == 0 {
|
if len(dl.Expected) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
best := ranked[0]
|
// The guardrails apply before the match/quality/lead checks: a
|
||||||
|
// candidate outside the allowed size or format is not a worse
|
||||||
|
// choice, it is not a choice auto-pick may make at all, so it must
|
||||||
|
// not count as "the winner" nor as "second place" for the lead
|
||||||
|
// check below.
|
||||||
|
eligible := prefs.filter(ranked)
|
||||||
|
if len(eligible) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
best := eligible[0]
|
||||||
if best.Match.Overall < minMatch || best.Quality.Overall < minQuality {
|
if best.Match.Overall < minMatch || best.Quality.Overall < minQuality {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ranked) > 1 && best.Score-ranked[1].Score < minLead {
|
if len(eligible) > 1 && best.Score-eligible[1].Score < minLead {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+160
-22
@@ -3,8 +3,8 @@ package download
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
// okComputer is the reference request used across ranking tests.
|
// okComputer is the reference request used across ranking tests.
|
||||||
func okComputer() Request {
|
func okComputer() Download {
|
||||||
return Request{
|
return Download{
|
||||||
ReleaseMBID: "mbid-ok-computer",
|
ReleaseMBID: "mbid-ok-computer",
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Album: "OK Computer",
|
Album: "OK Computer",
|
||||||
@@ -53,7 +53,7 @@ func allTitles() []string {
|
|||||||
func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
|
func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := okComputer()
|
dl := okComputer()
|
||||||
|
|
||||||
flac := candidateFor("flac", allTitles(), ".flac", 30_000_000)
|
flac := candidateFor("flac", allTitles(), ".flac", 30_000_000)
|
||||||
mp3 := candidateFor("mp3", allTitles(), ".mp3", 3_000_000)
|
mp3 := candidateFor("mp3", allTitles(), ".mp3", 3_000_000)
|
||||||
@@ -62,7 +62,7 @@ func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
|
|||||||
mp3.Files[i].Bitrate = 128
|
mp3.Files[i].Bitrate = 128
|
||||||
}
|
}
|
||||||
|
|
||||||
ranked := Rank(req, []Candidate{mp3, flac}, nil)
|
ranked := Rank(dl, []Candidate{mp3, flac}, nil, AutoDownloadPrefs{})
|
||||||
|
|
||||||
if ranked[0].ID != "flac" {
|
if ranked[0].ID != "flac" {
|
||||||
t.Fatalf("winner = %s, want flac", ranked[0].ID)
|
t.Fatalf("winner = %s, want flac", ranked[0].ID)
|
||||||
@@ -83,7 +83,7 @@ func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
|
|||||||
func TestRankMatchDominatesQuality(t *testing.T) {
|
func TestRankMatchDominatesQuality(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := okComputer()
|
dl := okComputer()
|
||||||
|
|
||||||
// Right album, poor bitrate.
|
// Right album, poor bitrate.
|
||||||
right := candidateFor("right", allTitles(), ".mp3", 2_000_000)
|
right := candidateFor("right", allTitles(), ".mp3", 2_000_000)
|
||||||
@@ -103,7 +103,7 @@ func TestRankMatchDominatesQuality(t *testing.T) {
|
|||||||
trackToken(i+1) + " - x.flac"
|
trackToken(i+1) + " - x.flac"
|
||||||
}
|
}
|
||||||
|
|
||||||
ranked := Rank(req, []Candidate{wrong, right}, nil)
|
ranked := Rank(dl, []Candidate{wrong, right}, nil, AutoDownloadPrefs{})
|
||||||
|
|
||||||
if ranked[0].ID != "right" {
|
if ranked[0].ID != "right" {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
@@ -116,12 +116,12 @@ func TestRankMatchDominatesQuality(t *testing.T) {
|
|||||||
func TestIncompleteCandidateScoresLower(t *testing.T) {
|
func TestIncompleteCandidateScoresLower(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := okComputer()
|
dl := okComputer()
|
||||||
|
|
||||||
full := candidateFor("full", allTitles(), ".flac", 30_000_000)
|
full := candidateFor("full", allTitles(), ".flac", 30_000_000)
|
||||||
partial := candidateFor("partial", allTitles()[:2], ".flac", 30_000_000)
|
partial := candidateFor("partial", allTitles()[:2], ".flac", 30_000_000)
|
||||||
|
|
||||||
ranked := Rank(req, []Candidate{partial, full}, nil)
|
ranked := Rank(dl, []Candidate{partial, full}, nil, AutoDownloadPrefs{})
|
||||||
|
|
||||||
if ranked[0].ID != "full" {
|
if ranked[0].ID != "full" {
|
||||||
t.Fatalf("winner = %s, want full", ranked[0].ID)
|
t.Fatalf("winner = %s, want full", ranked[0].ID)
|
||||||
@@ -138,7 +138,7 @@ func TestIncompleteCandidateScoresLower(t *testing.T) {
|
|||||||
func TestMixedFormatIsPenalized(t *testing.T) {
|
func TestMixedFormatIsPenalized(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := okComputer()
|
dl := okComputer()
|
||||||
|
|
||||||
clean := candidateFor("clean", allTitles(), ".flac", 30_000_000)
|
clean := candidateFor("clean", allTitles(), ".flac", 30_000_000)
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ func TestMixedFormatIsPenalized(t *testing.T) {
|
|||||||
mixed.Files[2].Path = "Radiohead - OK Computer/03 - x.mp3"
|
mixed.Files[2].Path = "Radiohead - OK Computer/03 - x.mp3"
|
||||||
mixed.Files[2].Format = FormatUnknown
|
mixed.Files[2].Format = FormatUnknown
|
||||||
|
|
||||||
ranked := Rank(req, []Candidate{mixed, clean}, nil)
|
ranked := Rank(dl, []Candidate{mixed, clean}, nil, AutoDownloadPrefs{})
|
||||||
|
|
||||||
var mixedScore QualityScore
|
var mixedScore QualityScore
|
||||||
|
|
||||||
@@ -170,10 +170,10 @@ func TestMixedFormatIsPenalized(t *testing.T) {
|
|||||||
func TestUnanchoredMatchIsCapped(t *testing.T) {
|
func TestUnanchoredMatchIsCapped(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := Request{Artist: "Radiohead", Album: "OK Computer"}
|
dl := Download{Artist: "Radiohead", Album: "OK Computer"}
|
||||||
c := candidateFor("c", allTitles(), ".flac", 30_000_000)
|
c := candidateFor("c", allTitles(), ".flac", 30_000_000)
|
||||||
|
|
||||||
scored := Score(req, c, 50)
|
scored := Score(dl, c, 50, AutoDownloadPrefs{})
|
||||||
|
|
||||||
if scored.Match.Anchored {
|
if scored.Match.Anchored {
|
||||||
t.Error("free-text request reported as anchored")
|
t.Error("free-text request reported as anchored")
|
||||||
@@ -190,19 +190,20 @@ func TestUnanchoredMatchIsCapped(t *testing.T) {
|
|||||||
func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
|
func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := okComputer()
|
dl := okComputer()
|
||||||
best := Score(req, candidateFor("a", allTitles(), ".flac", 30_000_000), 50)
|
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
|
||||||
|
|
||||||
t.Run("clear winner is auto-pickable", func(t *testing.T) {
|
t.Run("clear winner is auto-pickable", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
weak := Score(
|
weak := Score(
|
||||||
req,
|
dl,
|
||||||
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
|
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
|
||||||
50,
|
50,
|
||||||
|
AutoDownloadPrefs{},
|
||||||
)
|
)
|
||||||
|
|
||||||
if !AutoPickable(req, []Candidate{best, weak}) {
|
if !AutoPickable(dl, []Candidate{best, weak}, AutoDownloadPrefs{}) {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"want auto-pickable: match %f quality %f lead %f",
|
"want auto-pickable: match %f quality %f lead %f",
|
||||||
best.Match.Overall, best.Quality.Overall, best.Score-weak.Score,
|
best.Match.Overall, best.Quality.Overall, best.Score-weak.Score,
|
||||||
@@ -216,7 +217,7 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
|
|||||||
twin := best
|
twin := best
|
||||||
twin.ID = "twin"
|
twin.ID = "twin"
|
||||||
|
|
||||||
if AutoPickable(req, []Candidate{best, twin}) {
|
if AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) {
|
||||||
t.Error("identical candidates must not auto-pick")
|
t.Error("identical candidates must not auto-pick")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -224,9 +225,9 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
|
|||||||
t.Run("free text is never auto-pickable", func(t *testing.T) {
|
t.Run("free text is never auto-pickable", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
free := Request{Artist: "Radiohead", Album: "OK Computer"}
|
free := Download{Artist: "Radiohead", Album: "OK Computer"}
|
||||||
|
|
||||||
if AutoPickable(free, []Candidate{best}) {
|
if AutoPickable(free, []Candidate{best}, AutoDownloadPrefs{}) {
|
||||||
t.Error("unanchored request must not auto-pick")
|
t.Error("unanchored request must not auto-pick")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -234,7 +235,7 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
|
|||||||
t.Run("empty list is not", func(t *testing.T) {
|
t.Run("empty list is not", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if AutoPickable(req, nil) {
|
if AutoPickable(dl, nil, AutoDownloadPrefs{}) {
|
||||||
t.Error("empty candidate list must not auto-pick")
|
t.Error("empty candidate list must not auto-pick")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -276,7 +277,7 @@ func TestCompleteness(t *testing.T) {
|
|||||||
func TestProviderPriorityBreaksTies(t *testing.T) {
|
func TestProviderPriorityBreaksTies(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := okComputer()
|
dl := okComputer()
|
||||||
|
|
||||||
a := candidateFor("a", allTitles(), ".flac", 30_000_000)
|
a := candidateFor("a", allTitles(), ".flac", 30_000_000)
|
||||||
a.ProviderID = 1
|
a.ProviderID = 1
|
||||||
@@ -292,9 +293,146 @@ func TestProviderPriorityBreaksTies(t *testing.T) {
|
|||||||
return 10
|
return 10
|
||||||
}
|
}
|
||||||
|
|
||||||
ranked := Rank(req, []Candidate{a, b}, priority)
|
ranked := Rank(dl, []Candidate{a, b}, priority, AutoDownloadPrefs{})
|
||||||
|
|
||||||
if ranked[0].ID != "b" {
|
if ranked[0].ID != "b" {
|
||||||
t.Errorf("winner = %s, want b (higher provider priority)", ranked[0].ID)
|
t.Errorf("winner = %s, want b (higher provider priority)", ranked[0].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mb = 1 << 20
|
||||||
|
|
||||||
|
func TestAutoDownloadPrefsEligible(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
flacCandidate := candidateFor("c", allTitles(), ".flac", 30_000_000)
|
||||||
|
flacCandidate.Files = AnnotateFiles(flacCandidate.Files)
|
||||||
|
flacCandidate.TotalSize = 300 * mb
|
||||||
|
|
||||||
|
mp3Candidate := candidateFor("c", allTitles(), ".mp3", 3_000_000)
|
||||||
|
mp3Candidate.Files = AnnotateFiles(mp3Candidate.Files)
|
||||||
|
mp3Candidate.TotalSize = 30 * mb
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefs AutoDownloadPrefs
|
||||||
|
c Candidate
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"zero value is permissive", AutoDownloadPrefs{}, flacCandidate, true},
|
||||||
|
{
|
||||||
|
"within min/max window",
|
||||||
|
AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 500},
|
||||||
|
flacCandidate, true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"below minimum",
|
||||||
|
AutoDownloadPrefs{MinSizeMB: 400},
|
||||||
|
flacCandidate, false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"above maximum",
|
||||||
|
AutoDownloadPrefs{MaxSizeMB: 200},
|
||||||
|
flacCandidate, false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allowed format passes",
|
||||||
|
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
|
||||||
|
flacCandidate, true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"disallowed format rejected",
|
||||||
|
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
|
||||||
|
mp3Candidate, false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := tt.prefs.eligible(tt.c); got != tt.want {
|
||||||
|
t.Errorf("eligible() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoDownloadPrefsFilter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
small := candidateFor("small", allTitles(), ".flac", 10_000_000)
|
||||||
|
small.TotalSize = 50 * mb
|
||||||
|
|
||||||
|
big := candidateFor("big", allTitles(), ".flac", 30_000_000)
|
||||||
|
big.TotalSize = 500 * mb
|
||||||
|
|
||||||
|
prefs := AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 600}
|
||||||
|
|
||||||
|
filtered := prefs.filter([]Candidate{small, big})
|
||||||
|
|
||||||
|
if len(filtered) != 1 || filtered[0].ID != "big" {
|
||||||
|
t.Errorf("filter() = %v, want only the in-window candidate", filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoDownloadPrefsSizeFit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const neutral = 0.5
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefs AutoDownloadPrefs
|
||||||
|
totalSize int64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"no preference is neutral", AutoDownloadPrefs{}, 300 * mb, neutral},
|
||||||
|
{
|
||||||
|
"exact match scores 1",
|
||||||
|
AutoDownloadPrefs{PreferredSizeMB: 300},
|
||||||
|
300 * mb, 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"double the preferred size scores 0",
|
||||||
|
AutoDownloadPrefs{PreferredSizeMB: 300},
|
||||||
|
600 * mb, 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"half the preferred size scores 0",
|
||||||
|
AutoDownloadPrefs{PreferredSizeMB: 300},
|
||||||
|
150 * mb, 0.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := tt.prefs.sizeFit(tt.totalSize); got != tt.want {
|
||||||
|
t.Errorf("sizeFit(%d) = %f, want %f", tt.totalSize, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An otherwise-perfect candidate must not auto-pick when it falls
|
||||||
|
// outside the configured size guard: the guardrail applies before the
|
||||||
|
// match/quality/lead checks, not as one more input averaged into them.
|
||||||
|
func TestAutoPickableRejectsCandidateOutsideSizeGuard(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dl := okComputer()
|
||||||
|
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
|
||||||
|
best.TotalSize = 500 * mb
|
||||||
|
|
||||||
|
if !AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{}) {
|
||||||
|
t.Fatal("expected this candidate to be auto-pickable with no guardrails")
|
||||||
|
}
|
||||||
|
|
||||||
|
tight := AutoDownloadPrefs{MinSizeMB: 10, MaxSizeMB: 100}
|
||||||
|
|
||||||
|
if AutoPickable(dl, []Candidate{best}, tight) {
|
||||||
|
t.Error("candidate outside the size guard must not auto-pick")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The reconciler is the only thing that turns wants into downloads.
|
// The reconciler is the only thing that turns requests into downloads.
|
||||||
//
|
//
|
||||||
// It runs on a slow loop rather than reacting to events, because
|
// It runs on a slow loop rather than reacting to events, because
|
||||||
// everything it cares about changes slowly: a release the user wants
|
// everything it cares about changes slowly: a release the user requests
|
||||||
// appears on a source days or weeks after they asked, an artist puts
|
// appears on a source days or weeks after they asked, an artist puts
|
||||||
// out an album once a year, and a library gains files by scan rather
|
// out an album once a year, and a library gains files by scan rather
|
||||||
// than by notification. A loop that wakes a few times a day is
|
// than by notification. A loop that wakes a few times a day is
|
||||||
@@ -24,17 +24,17 @@ import (
|
|||||||
//
|
//
|
||||||
// Each pass does four things, in this order and for this reason:
|
// Each pass does four things, in this order and for this reason:
|
||||||
//
|
//
|
||||||
// 1. Expand artist subscriptions into per-album wants, so step 2 sees
|
// 1. Expand artist subscriptions into per-album requests, so step 2 sees
|
||||||
// them this pass rather than next.
|
// them this pass rather than next.
|
||||||
// 2. Retire wants the library already owns — including ones the user
|
// 2. Retire requests the library already owns — including ones the user
|
||||||
// satisfied by other means, which is why ownership is checked
|
// satisfied by other means, which is why ownership is checked
|
||||||
// rather than assumed from our own downloads.
|
// rather than assumed from our own downloads.
|
||||||
// 3. Push the list to clients that keep their own (Lidarr), so the
|
// 3. Push the list to clients that keep their own (Lidarr), so the
|
||||||
// user's intent is expressed in both places.
|
// user's intent is expressed in both places.
|
||||||
// 4. Attempt a bounded batch of due wants.
|
// 4. Attempt a bounded batch of due requests.
|
||||||
//
|
//
|
||||||
// Nothing here fails a want. A want that cannot be found gets an
|
// Nothing here fails a request. A request that cannot be found gets an
|
||||||
// attempt recorded and a longer backoff, and stays exactly as wanted as
|
// attempt recorded and a longer backoff, and stays exactly as requested as
|
||||||
// it was.
|
// it was.
|
||||||
|
|
||||||
// CatalogPort is what the reconciler needs to know about the world of
|
// CatalogPort is what the reconciler needs to know about the world of
|
||||||
@@ -51,8 +51,8 @@ type CatalogPort interface {
|
|||||||
) ([]CatalogItem, error)
|
) ([]CatalogItem, error)
|
||||||
|
|
||||||
// Tracklist resolves a release group or release to the tracks it
|
// Tracklist resolves a release group or release to the tracks it
|
||||||
// should contain. This is what makes a want's download safe to
|
// should contain. This is what makes a request's download safe to
|
||||||
// complete unattended, so a want with no tracklist is never
|
// complete unattended, so a request with no tracklist is never
|
||||||
// auto-grabbed.
|
// auto-grabbed.
|
||||||
Tracklist(
|
Tracklist(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -64,8 +64,8 @@ type CatalogPort interface {
|
|||||||
// names.
|
// names.
|
||||||
Owns(ctx context.Context, entity Entity, mbid string) (bool, error)
|
Owns(ctx context.Context, entity Entity, mbid string) (bool, error)
|
||||||
|
|
||||||
// Describe fills in display text for a want the user added by MBID
|
// Describe fills in display text for a request the user added by MBID
|
||||||
// alone. Best-effort: an unknown MBID returns false and the want
|
// alone. Best-effort: an unknown MBID returns false and the request
|
||||||
// is still perfectly valid.
|
// is still perfectly valid.
|
||||||
Describe(
|
Describe(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -87,7 +87,7 @@ type CatalogItem struct {
|
|||||||
PrimaryType string
|
PrimaryType string
|
||||||
|
|
||||||
// SecondaryTypes carries "Compilation", "Live", "Remix" and
|
// SecondaryTypes carries "Compilation", "Live", "Remix" and
|
||||||
// friends. Their presence is what an artist want's default scope
|
// friends. Their presence is what an artist request's default scope
|
||||||
// filters out.
|
// filters out.
|
||||||
SecondaryTypes []string
|
SecondaryTypes []string
|
||||||
|
|
||||||
@@ -100,25 +100,25 @@ type CatalogItem struct {
|
|||||||
|
|
||||||
// Reconciler defaults.
|
// Reconciler defaults.
|
||||||
const (
|
const (
|
||||||
// defaultReconcileInterval is how often the wanted list is worked.
|
// defaultReconcileInterval is how often the request list is worked.
|
||||||
// Four times a day is far more often than new music appears and far
|
// Four times a day is far more often than new music appears and far
|
||||||
// less often than any provider would object to.
|
// less often than any provider would object to.
|
||||||
defaultReconcileInterval = 6 * time.Hour
|
defaultReconcileInterval = 6 * time.Hour
|
||||||
|
|
||||||
// startupDelay lets the app finish starting — library scan, explore
|
// startupDelay lets the app finish starting — library scan, explore
|
||||||
// index, provider construction — before the first pass. A wanted
|
// index, provider construction — before the first pass. A requested
|
||||||
// list worked against an index that has not loaded yet would record
|
// list worked against an index that has not loaded yet would record
|
||||||
// a pile of pointless attempts.
|
// a pile of pointless attempts.
|
||||||
startupDelay = 3 * time.Minute
|
startupDelay = 3 * time.Minute
|
||||||
|
|
||||||
// maxExpandPerArtist bounds how many child wants one artist
|
// maxExpandPerArtist bounds how many child requests one artist
|
||||||
// subscription creates in a single pass, so switching an artist to
|
// subscription creates in a single pass, so switching an artist to
|
||||||
// full-discography scope does not enqueue four hundred albums at
|
// full-discography scope does not enqueue four hundred albums at
|
||||||
// once. The remainder is picked up next pass.
|
// once. The remainder is picked up next pass.
|
||||||
maxExpandPerArtist = 40
|
maxExpandPerArtist = 40
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reconciler works the wanted list.
|
// Reconciler works the request list.
|
||||||
type Reconciler struct {
|
type Reconciler struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
store *Store
|
store *Store
|
||||||
@@ -143,12 +143,12 @@ type Reconciler struct {
|
|||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
|
|
||||||
// runMu serializes passes: two reconcilers racing would search for
|
// runMu serializes passes: two reconcilers racing would search for
|
||||||
// the same want twice.
|
// the same request twice.
|
||||||
runMu sync.Mutex
|
runMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewReconciler builds a reconciler. catalog may be nil, in which case
|
// NewReconciler builds a reconciler. catalog may be nil, in which case
|
||||||
// the wanted list still stores and lists wants but never acts on them —
|
// the request list still stores and lists requests but never acts on them —
|
||||||
// which is the right behaviour when the explore index is unavailable.
|
// which is the right behaviour when the explore index is unavailable.
|
||||||
func NewReconciler(
|
func NewReconciler(
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
@@ -176,7 +176,7 @@ func (r *Reconciler) SetInterval(d time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBatch overrides how many wants one pass attempts.
|
// SetBatch overrides how many requests one pass attempts.
|
||||||
func (r *Reconciler) SetBatch(n int) {
|
func (r *Reconciler) SetBatch(n int) {
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
r.batch = n
|
r.batch = n
|
||||||
@@ -200,7 +200,7 @@ func (r *Reconciler) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trigger asks for a pass as soon as possible without blocking the
|
// Trigger asks for a pass as soon as possible without blocking the
|
||||||
// caller. Used when the user adds a want and expects something to
|
// caller. Used when the user adds a request and expects something to
|
||||||
// happen.
|
// happen.
|
||||||
func (r *Reconciler) Trigger() {
|
func (r *Reconciler) Trigger() {
|
||||||
select {
|
select {
|
||||||
@@ -229,27 +229,27 @@ func (r *Reconciler) loop(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := r.RunOnce(ctx); err != nil {
|
if _, err := r.RunOnce(ctx); err != nil {
|
||||||
r.logger.Warn("wanted list reconcile failed", "error", err)
|
r.logger.Warn("request list reconcile failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary reports what a pass did, for logging and for the UI.
|
// Summary reports what a pass did, for logging and for the UI.
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
// Expanded is how many child wants artist subscriptions produced.
|
// Expanded is how many child requests artist subscriptions produced.
|
||||||
Expanded int `json:"expanded"`
|
Expanded int `json:"expanded"`
|
||||||
|
|
||||||
// Satisfied is how many wants the library turned out to own.
|
// Satisfied is how many requests the library turned out to own.
|
||||||
Satisfied int `json:"satisfied"`
|
Satisfied int `json:"satisfied"`
|
||||||
|
|
||||||
// Attempted is how many wants were searched for.
|
// Attempted is how many requests were searched for.
|
||||||
Attempted int `json:"attempted"`
|
Attempted int `json:"attempted"`
|
||||||
|
|
||||||
// Started is how many of those found a clear enough winner to
|
// Started is how many of those found a clear enough winner to
|
||||||
// download unattended.
|
// download unattended.
|
||||||
Started int `json:"started"`
|
Started int `json:"started"`
|
||||||
|
|
||||||
// Synced is how many wants were pushed to an external list.
|
// Synced is how many requests were pushed to an external list.
|
||||||
Synced int `json:"synced"`
|
Synced int `json:"synced"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ func (s Summary) changed() bool {
|
|||||||
return s.Expanded > 0 || s.Satisfied > 0 || s.Started > 0
|
return s.Expanded > 0 || s.Satisfied > 0 || s.Started > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunOnce works the wanted list once. It is safe to call directly, and
|
// RunOnce works the request list once. It is safe to call directly, and
|
||||||
// the "search now" button does.
|
// the "search now" button does.
|
||||||
func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
|
func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
|
||||||
r.runMu.Lock()
|
r.runMu.Lock()
|
||||||
@@ -296,7 +296,7 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
|
|||||||
summary.Started = started
|
summary.Started = started
|
||||||
|
|
||||||
r.logger.Info(
|
r.logger.Info(
|
||||||
"reconciled wanted list",
|
"reconciled request list",
|
||||||
"expanded", summary.Expanded,
|
"expanded", summary.Expanded,
|
||||||
"satisfied", summary.Satisfied,
|
"satisfied", summary.Satisfied,
|
||||||
"attempted", summary.Attempted,
|
"attempted", summary.Attempted,
|
||||||
@@ -315,15 +315,15 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
|
|||||||
// Artist expansion
|
// Artist expansion
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// expandArtists turns artist subscriptions into per-album wants.
|
// expandArtists turns artist subscriptions into per-album requests.
|
||||||
//
|
//
|
||||||
// The expansion is idempotent: child wants are upserted on (mbid,
|
// The expansion is idempotent: child requests are upserted on (mbid,
|
||||||
// library), so re-running adds only what is genuinely new. That is
|
// library), so re-running adds only what is genuinely new. That is
|
||||||
// what makes an artist want a standing subscription rather than a
|
// what makes an artist request a standing subscription rather than a
|
||||||
// one-time queue-filling operation — an album released next year gets
|
// one-time queue-filling operation — an album released next year gets
|
||||||
// picked up by the same code path that ran today.
|
// picked up by the same code path that ran today.
|
||||||
func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
|
func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
|
||||||
artists, err := r.store.ListArtistWants(ctx)
|
artists, err := r.store.ListActiveRequests(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -336,7 +336,7 @@ func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
|
|||||||
// One artist whose discography will not resolve must not
|
// One artist whose discography will not resolve must not
|
||||||
// stop the rest of the list.
|
// stop the rest of the list.
|
||||||
r.logger.Warn(
|
r.logger.Warn(
|
||||||
"could not expand artist want",
|
"could not expand artist request",
|
||||||
"artist", artist.Label(),
|
"artist", artist.Label(),
|
||||||
"mbid", artist.MBID,
|
"mbid", artist.MBID,
|
||||||
"error", err,
|
"error", err,
|
||||||
@@ -352,7 +352,7 @@ func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// expandArtist expands one subscription.
|
// expandArtist expands one subscription.
|
||||||
func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error) {
|
func (r *Reconciler) expandArtist(ctx context.Context, artist Request) (int, error) {
|
||||||
groups, err := r.catalog.ReleaseGroupsForArtist(ctx, artist.MBID)
|
groups, err := r.catalog.ReleaseGroupsForArtist(ctx, artist.MBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -365,16 +365,16 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if !wantsReleaseGroup(artist, rg) {
|
if !requestsReleaseGroup(artist, rg) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existence is checked before inserting rather than relying on
|
// Existence is checked before inserting rather than relying on
|
||||||
// the upsert, because "how many albums are new this pass" is
|
// the upsert, because "how many albums are new this pass" is
|
||||||
// the number the UI reports and an upsert cannot tell an insert
|
// the number the UI reports and an upsert cannot tell an insert
|
||||||
// from a no-op. It also means a want the user pinned by hand
|
// from a no-op. It also means a request the user pinned by hand
|
||||||
// is never quietly reparented under the artist.
|
// is never quietly reparented under the artist.
|
||||||
if _, exists, err := r.store.FindWant(
|
if _, exists, err := r.store.FindRequest(
|
||||||
ctx, rg.MBID, artist.LibraryID,
|
ctx, rg.MBID, artist.LibraryID,
|
||||||
); err != nil || exists {
|
); err != nil || exists {
|
||||||
continue
|
continue
|
||||||
@@ -385,7 +385,7 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
|
|||||||
credit = artist.Artist
|
credit = artist.Artist
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := r.store.AddWant(ctx, Want{
|
if _, err := r.store.AddRequest(ctx, Request{
|
||||||
MBID: rg.MBID,
|
MBID: rg.MBID,
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: artist.LibraryID,
|
LibraryID: artist.LibraryID,
|
||||||
@@ -394,7 +394,7 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
|
|||||||
ParentID: artist.ID,
|
ParentID: artist.ID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
r.logger.Warn(
|
r.logger.Warn(
|
||||||
"could not add derived want",
|
"could not add derived request",
|
||||||
"release_group", rg.MBID,
|
"release_group", rg.MBID,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
@@ -408,9 +408,9 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
|
|||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantsReleaseGroup applies an artist subscription's filters to one
|
// requestsReleaseGroup applies an artist subscription's filters to one
|
||||||
// release group.
|
// release group.
|
||||||
func wantsReleaseGroup(artist Want, rg CatalogItem) bool {
|
func requestsReleaseGroup(artist Request, rg CatalogItem) bool {
|
||||||
if rg.MBID == "" || rg.InLibrary {
|
if rg.MBID == "" || rg.InLibrary {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -457,30 +457,30 @@ func releaseDateAfter(date string, since time.Time) bool {
|
|||||||
// Retiring what the library already has
|
// Retiring what the library already has
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// retireOwned satisfies wants the library turns out to own.
|
// retireOwned satisfies requests the library turns out to own.
|
||||||
//
|
//
|
||||||
// Ownership is asked of the library rather than inferred from our own
|
// Ownership is asked of the library rather than inferred from our own
|
||||||
// completed downloads on purpose: the user may have bought the album,
|
// completed downloads on purpose: the user may have bought the album,
|
||||||
// ripped their CD, or copied it in from another machine, and a wanted
|
// ripped their CD, or copied it in from another machine, and a requested
|
||||||
// list that keeps hunting for music already sitting on disk is worse
|
// list that keeps hunting for music already sitting on disk is worse
|
||||||
// than no wanted list at all.
|
// than no request list at all.
|
||||||
func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
|
func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
|
||||||
wants, err := r.store.ListWants(ctx)
|
requests, err := r.store.ListRequests(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
satisfied := 0
|
satisfied := 0
|
||||||
|
|
||||||
for _, w := range wants {
|
for _, req := range requests {
|
||||||
if w.State != WantStateWanted || w.Entity.Expands() {
|
if req.State != RequestStateWanted || req.Entity.Expands() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
owned, err := r.catalog.Owns(ctx, w.Entity, w.MBID)
|
owned, err := r.catalog.Owns(ctx, req.Entity, req.MBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.Debug(
|
r.logger.Debug(
|
||||||
"ownership check failed", "want", w.MBID, "error", err,
|
"ownership check failed", "request", req.MBID, "error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
@@ -490,8 +490,8 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.store.SatisfyWant(ctx, w.ID); err != nil {
|
if err := r.store.SatisfyRequest(ctx, req.ID); err != nil {
|
||||||
r.logger.Warn("could not satisfy want", "want", w.ID, "error", err)
|
r.logger.Warn("could not satisfy request", "request", req.ID, "error", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -506,15 +506,15 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
|
|||||||
// Attempting downloads
|
// Attempting downloads
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// attemptDue searches for a bounded batch of due wants and grabs the
|
// attemptDue searches for a bounded batch of due requests and grabs the
|
||||||
// ones with a clear winner.
|
// ones with a clear winner.
|
||||||
func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, err error) {
|
func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, err error) {
|
||||||
due, err := r.store.ListDueWants(ctx, r.batch)
|
due, err := r.store.ListDueRequests(ctx, r.batch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, w := range due {
|
for _, req := range due {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return attempted, started, nil
|
return attempted, started, nil
|
||||||
@@ -523,7 +523,7 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
|
|||||||
|
|
||||||
attempted++
|
attempted++
|
||||||
|
|
||||||
ok, reason := r.attempt(ctx, w)
|
ok, reason := r.attempt(ctx, req)
|
||||||
if ok {
|
if ok {
|
||||||
started++
|
started++
|
||||||
|
|
||||||
@@ -531,10 +531,10 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := r.store.RecordAttempt(
|
if err := r.store.RecordAttempt(
|
||||||
ctx, w.ID, w.Attempts, reason,
|
ctx, req.ID, req.Attempts, reason,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
r.logger.Warn(
|
r.logger.Warn(
|
||||||
"could not record want attempt", "want", w.ID, "error", err,
|
"could not record request attempt", "request", req.ID, "error", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,64 +542,64 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
|
|||||||
return attempted, started, nil
|
return attempted, started, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// tracklistFor resolves what a want should contain, which is the
|
// tracklistFor resolves what a request should contain, which is the
|
||||||
// evidence an unattended download is checked against.
|
// evidence an unattended download is checked against.
|
||||||
//
|
//
|
||||||
// A track want is its own tracklist: one entry, built from the title
|
// A track request is its own tracklist: one entry, built from the title
|
||||||
// the want already carries. That single expected title is what lets
|
// the request already carries. That single expected title is what lets
|
||||||
// filename matching score a track download at all — without it a
|
// filename matching score a track download at all — without it a
|
||||||
// request for one song would be scored as an album with no tracks and
|
// request for one song would be scored as an album with no tracks and
|
||||||
// could never clear the auto-pick bar.
|
// could never clear the auto-pick bar.
|
||||||
func (r *Reconciler) tracklistFor(
|
func (r *Reconciler) tracklistFor(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
w Want,
|
req Request,
|
||||||
) ([]ExpectedTrack, error) {
|
) ([]ExpectedTrack, error) {
|
||||||
if w.Entity != EntityRecording {
|
if req.Entity != EntityRecording {
|
||||||
return r.catalog.Tracklist(ctx, w.Entity, w.MBID)
|
return r.catalog.Tracklist(ctx, req.Entity, req.MBID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.Title == "" {
|
if req.Title == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return []ExpectedTrack{{
|
return []ExpectedTrack{{
|
||||||
Position: 1,
|
Position: 1,
|
||||||
Title: w.Title,
|
Title: req.Title,
|
||||||
Artist: w.Artist,
|
Artist: req.Artist,
|
||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// attempt tries one want. It returns false with a human-readable
|
// attempt tries one request. It returns false with a human-readable
|
||||||
// reason rather than an error, because none of the ways this does not
|
// reason rather than an error, because none of the ways this does not
|
||||||
// work out are failures: no providers configured yet, nothing on any
|
// work out are failures: no providers configured yet, nothing on any
|
||||||
// source, or nothing good enough to take without asking are all just
|
// source, or nothing good enough to take without asking are all just
|
||||||
// "not today".
|
// "not today".
|
||||||
func (r *Reconciler) attempt(ctx context.Context, w Want) (bool, string) {
|
func (r *Reconciler) attempt(ctx context.Context, req Request) (bool, string) {
|
||||||
expected, err := r.tracklistFor(ctx, w)
|
expected, err := r.tracklistFor(ctx, req)
|
||||||
if err != nil || len(expected) == 0 {
|
if err != nil || len(expected) == 0 {
|
||||||
// Without a tracklist an unattended grab has nothing to verify
|
// Without a tracklist an unattended grab has nothing to verify
|
||||||
// itself against, so this want waits rather than guessing. The
|
// itself against, so this request waits rather than guessing. The
|
||||||
// tracklist usually arrives on its own once the explore index
|
// tracklist usually arrives on its own once the explore index
|
||||||
// fetches the release.
|
// fetches the release.
|
||||||
return false, "waiting for the tracklist to resolve"
|
return false, "waiting for the tracklist to resolve"
|
||||||
}
|
}
|
||||||
|
|
||||||
req := w.ToRequest(newID())
|
dl := req.ToDownload(newID())
|
||||||
req.Expected = expected
|
dl.Expected = expected
|
||||||
|
|
||||||
if req.Artist == "" || req.Album == "" {
|
if dl.Artist == "" || dl.Album == "" {
|
||||||
if item, ok := r.catalog.Describe(ctx, w.Entity, w.MBID); ok {
|
if item, ok := r.catalog.Describe(ctx, req.Entity, req.MBID); ok {
|
||||||
if req.Artist == "" {
|
if dl.Artist == "" {
|
||||||
req.Artist = item.Artist
|
dl.Artist = item.Artist
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Album == "" {
|
if dl.Album == "" {
|
||||||
req.Album = item.Title
|
dl.Album = item.Title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
started, reason, err := r.manager.Attempt(ctx, req)
|
started, reason, err := r.manager.Attempt(ctx, dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNoProviders) {
|
if errors.Is(err, ErrNoProviders) {
|
||||||
return false, "no download clients are enabled"
|
return false, "no download clients are enabled"
|
||||||
@@ -619,7 +619,7 @@ func (r *Reconciler) attempt(ctx context.Context, w Want) (bool, string) {
|
|||||||
// External list sync
|
// External list sync
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// syncExternalLists pushes wants to providers that keep a persistent
|
// syncExternalLists pushes requests to providers that keep a persistent
|
||||||
// list of their own.
|
// list of their own.
|
||||||
//
|
//
|
||||||
// The sync is one-directional by design. Two systems that both accept
|
// The sync is one-directional by design. Two systems that both accept
|
||||||
@@ -634,21 +634,21 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
wants, err := r.store.ListWants(ctx)
|
requests, err := r.store.ListRequests(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.Warn("could not list wants for sync", "error", err)
|
r.logger.Warn("could not list requests for sync", "error", err)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
|
|
||||||
for _, w := range wants {
|
for _, req := range requests {
|
||||||
if w.State != WantStateWanted {
|
if req.State != RequestStateWanted {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
external := w.ExternalIDs
|
external := req.ExternalIDs
|
||||||
if external == nil {
|
if external == nil {
|
||||||
external = map[string]string{}
|
external = map[string]string{}
|
||||||
}
|
}
|
||||||
@@ -661,11 +661,11 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
externalID, err := l.PushWant(ctx, w)
|
externalID, err := l.PushRequest(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.Debug(
|
r.logger.Debug(
|
||||||
"could not push want to external list",
|
"could not push request to external list",
|
||||||
"want", w.MBID,
|
"request", req.MBID,
|
||||||
"provider", id,
|
"provider", id,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
@@ -686,9 +686,9 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.store.SetWantExternalIDs(ctx, w.ID, external); err != nil {
|
if err := r.store.SetRequestExternalIDs(ctx, req.ID, external); err != nil {
|
||||||
r.logger.Warn(
|
r.logger.Warn(
|
||||||
"could not record external want ids", "want", w.ID, "error", err,
|
"could not record external request ids", "request", req.ID, "error", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,7 +696,7 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
|
|||||||
return synced
|
return synced
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportExternal pulls an external manager's own list into the wanted
|
// ImportExternal pulls an external manager's own list into the requested
|
||||||
// list. This is the one place data flows the other way, and it is a
|
// list. This is the one place data flows the other way, and it is a
|
||||||
// deliberate user action ("import my monitored Lidarr artists") rather
|
// deliberate user action ("import my monitored Lidarr artists") rather
|
||||||
// than part of the loop, because silently adopting whatever another
|
// than part of the loop, because silently adopting whatever another
|
||||||
@@ -715,19 +715,19 @@ func (r *Reconciler) ImportExternal(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
external, err := l.ListWants(ctx)
|
external, err := l.ListRequests(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("list external wants: %w", err)
|
return 0, fmt.Errorf("list external requests: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
imported := 0
|
imported := 0
|
||||||
|
|
||||||
for _, w := range external {
|
for _, req := range external {
|
||||||
w.LibraryID = libraryID
|
req.LibraryID = libraryID
|
||||||
|
|
||||||
if _, err := r.store.AddWant(ctx, w); err != nil {
|
if _, err := r.store.AddRequest(ctx, req); err != nil {
|
||||||
r.logger.Warn(
|
r.logger.Warn(
|
||||||
"could not import external want", "mbid", w.MBID, "error", err,
|
"could not import external request", "mbid", req.MBID, "error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -111,14 +111,14 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
|
|||||||
{MBID: "rg-2", Title: "Second", FirstReleaseDate: "2030-06-01"},
|
{MBID: "rg-2", Title: "Second", FirstReleaseDate: "2030-06-01"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := f.store.AddWant(ctx, Want{
|
if _, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "artist-1",
|
MBID: "artist-1",
|
||||||
Entity: EntityArtist,
|
Entity: EntityArtist,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Scope: ScopeAll,
|
Scope: ScopeAll,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
first, err := f.reconciler.expandArtists(ctx)
|
first, err := f.reconciler.expandArtists(ctx)
|
||||||
@@ -127,7 +127,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if first != 2 {
|
if first != 2 {
|
||||||
t.Fatalf("first pass created %d wants, want 2", first)
|
t.Fatalf("first pass created %d requests, want 2", first)
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := f.reconciler.expandArtists(ctx)
|
second, err := f.reconciler.expandArtists(ctx)
|
||||||
@@ -136,7 +136,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if second != 0 {
|
if second != 0 {
|
||||||
t.Errorf("second pass created %d wants, want 0", second)
|
t.Errorf("second pass created %d requests, want 0", second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A new album appearing later is picked up by the same pass.
|
// A new album appearing later is picked up by the same pass.
|
||||||
@@ -153,7 +153,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if third != 1 {
|
if third != 1 {
|
||||||
t.Errorf("third pass created %d wants, want 1", third)
|
t.Errorf("third pass created %d requests, want 1", third)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,32 +170,32 @@ func TestExpandArtistFutureScopeSkipsBackCatalogue(t *testing.T) {
|
|||||||
{MBID: "rg-new", Title: "New", FirstReleaseDate: "2099-01-01"},
|
{MBID: "rg-new", Title: "New", FirstReleaseDate: "2099-01-01"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := f.store.AddWant(ctx, Want{
|
if _, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "artist-1",
|
MBID: "artist-1",
|
||||||
Entity: EntityArtist,
|
Entity: EntityArtist,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Scope: ScopeFuture,
|
Scope: ScopeFuture,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := f.reconciler.expandArtists(ctx); err != nil {
|
if _, err := f.reconciler.expandArtists(ctx); err != nil {
|
||||||
t.Fatalf("expandArtists: %v", err)
|
t.Fatalf("expandArtists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
wants, err := f.store.ListWants(ctx)
|
requests, err := f.store.ListRequests(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListWants: %v", err)
|
t.Fatalf("ListDownloads: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, w := range wants {
|
for _, req := range requests {
|
||||||
if w.MBID == "rg-old" {
|
if req.MBID == "rg-old" {
|
||||||
t.Error("future scope queued a back-catalogue album")
|
t.Error("future scope queued a back-catalogue album")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(wants) != 2 {
|
if len(requests) != 2 {
|
||||||
t.Errorf("got %d wants (artist + new album), want 2", len(wants))
|
t.Errorf("got %d requests (artist + new album), want 2", len(requests))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,12 +209,12 @@ func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
|
|||||||
|
|
||||||
f.catalog.discographyErr = errors.New("index not ready") //nolint:err113 // test
|
f.catalog.discographyErr = errors.New("index not ready") //nolint:err113 // test
|
||||||
|
|
||||||
if _, err := f.store.AddWant(ctx, Want{
|
if _, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "artist-1",
|
MBID: "artist-1",
|
||||||
Entity: EntityArtist,
|
Entity: EntityArtist,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := f.reconciler.expandArtists(ctx)
|
created, err := f.reconciler.expandArtists(ctx)
|
||||||
@@ -223,24 +223,24 @@ func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if created != 0 {
|
if created != 0 {
|
||||||
t.Errorf("created %d wants from a failing catalog, want 0", created)
|
t.Errorf("created %d requests from a failing catalog, want 0", created)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Something the library already owns is retired, however it got there.
|
// Something the library already owns is retired, however it got there.
|
||||||
func TestRetireOwnedSatisfiesWants(t *testing.T) {
|
func TestRetireOwnedSatisfiesRequests(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := newReconcileFixture(t)
|
f := newReconcileFixture(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
id, err := f.store.AddWant(ctx, Want{
|
id, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "rg-1",
|
MBID: "rg-1",
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f.catalog.owned["rg-1"] = true
|
f.catalog.owned["rg-1"] = true
|
||||||
@@ -251,16 +251,16 @@ func TestRetireOwnedSatisfiesWants(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if n != 1 {
|
if n != 1 {
|
||||||
t.Fatalf("retired %d wants, want 1", n)
|
t.Fatalf("retired %d requests, want 1", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
w, err := f.store.GetWant(ctx, id)
|
req, err := f.store.GetRequest(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetWant: %v", err)
|
t.Fatalf("GetRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.State != WantStateSatisfied {
|
if req.State != RequestStateSatisfied {
|
||||||
t.Errorf("state = %q, want satisfied", w.State)
|
t.Errorf("state = %q, want satisfied", req.State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
|
|||||||
provider := fakeWithAlbum(1, "source", ".flac")
|
provider := fakeWithAlbum(1, "source", ".flac")
|
||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||||
|
|
||||||
id, err := f.store.AddWant(ctx, Want{
|
id, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "rg-1",
|
MBID: "rg-1",
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
@@ -283,10 +283,10 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
|
|||||||
Title: "OK Computer",
|
Title: "OK Computer",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
|
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
|
||||||
|
|
||||||
summary, err := f.reconciler.RunOnce(ctx)
|
summary, err := f.reconciler.RunOnce(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -301,9 +301,9 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
waitFor(t, func() bool {
|
waitFor(t, func() bool {
|
||||||
w, err := f.store.GetWant(ctx, id)
|
req, err := f.store.GetRequest(ctx, id)
|
||||||
|
|
||||||
return err == nil && w.State == WantStateSatisfied
|
return err == nil && req.State == RequestStateSatisfied
|
||||||
}, "want was never satisfied after its download completed")
|
}, "want was never satisfied after its download completed")
|
||||||
|
|
||||||
if provider.GrabCalls != 1 {
|
if provider.GrabCalls != 1 {
|
||||||
@@ -313,7 +313,7 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
|
|||||||
|
|
||||||
// Nothing good enough is not a failure. The want stays wanted, gains
|
// Nothing good enough is not a failure. The want stays wanted, gains
|
||||||
// an attempt and a reason, and leaves no request row behind.
|
// an attempt and a reason, and leaves no request row behind.
|
||||||
func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
|
func TestReconcileKeepsRequestingWhenNothingIsGoodEnough(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := newReconcileFixture(t)
|
f := newReconcileFixture(t)
|
||||||
@@ -328,7 +328,7 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
|
|||||||
|
|
||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||||
|
|
||||||
id, err := f.store.AddWant(ctx, Want{
|
id, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "rg-1",
|
MBID: "rg-1",
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
@@ -336,10 +336,10 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
|
|||||||
Title: "OK Computer",
|
Title: "OK Computer",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
|
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
|
||||||
|
|
||||||
summary, err := f.reconciler.RunOnce(ctx)
|
summary, err := f.reconciler.RunOnce(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -350,32 +350,32 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
|
|||||||
t.Fatalf("started %d downloads, want 0", summary.Started)
|
t.Fatalf("started %d downloads, want 0", summary.Started)
|
||||||
}
|
}
|
||||||
|
|
||||||
w, err := f.store.GetWant(ctx, id)
|
req, err := f.store.GetRequest(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetWant: %v", err)
|
t.Fatalf("GetRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.State != WantStateWanted {
|
if req.State != RequestStateWanted {
|
||||||
t.Errorf("state = %q, want it still wanted", w.State)
|
t.Errorf("state = %q, want it still wanted", req.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.Attempts != 1 {
|
if req.Attempts != 1 {
|
||||||
t.Errorf("attempts = %d, want 1", w.Attempts)
|
t.Errorf("attempts = %d, want 1", req.Attempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.LastError == "" {
|
if req.LastError == "" {
|
||||||
t.Error("no reason was recorded for the user")
|
t.Error("no reason was recorded for the user")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !w.NextTryAt.After(time.Now()) {
|
if !req.NextTryAt.After(time.Now()) {
|
||||||
t.Errorf("next try at %v, want it in the future", w.NextTryAt)
|
t.Errorf("next try at %v, want it in the future", req.NextTryAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The whole point of Attempt over Start: an unsuccessful pass
|
// The whole point of Attempt over Start: an unsuccessful pass
|
||||||
// leaves no request row to clutter the downloads list.
|
// leaves no request row to clutter the downloads list.
|
||||||
requests, err := f.store.ListRequests(ctx, 50)
|
requests, err := f.store.ListDownloads(ctx, 50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListRequests: %v", err)
|
t.Fatalf("ListDownloads: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(requests) != 0 {
|
if len(requests) != 0 {
|
||||||
@@ -393,14 +393,14 @@ func TestReconcileWaitsWithoutTracklist(t *testing.T) {
|
|||||||
provider := fakeWithAlbum(1, "source", ".flac")
|
provider := fakeWithAlbum(1, "source", ".flac")
|
||||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||||
|
|
||||||
if _, err := f.store.AddWant(ctx, Want{
|
if _, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "rg-1",
|
MBID: "rg-1",
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
Title: "OK Computer",
|
Title: "OK Computer",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := f.reconciler.RunOnce(ctx)
|
summary, err := f.reconciler.RunOnce(ctx)
|
||||||
@@ -421,27 +421,27 @@ func TestReconcileWaitsWithoutTracklist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Artist subscriptions are never attempted as downloads: they expand.
|
// Artist subscriptions are never attempted as downloads: they expand.
|
||||||
func TestArtistWantsAreNeverDue(t *testing.T) {
|
func TestArtistRequestsAreNeverDue(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := newReconcileFixture(t)
|
f := newReconcileFixture(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if _, err := f.store.AddWant(ctx, Want{
|
if _, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "artist-1",
|
MBID: "artist-1",
|
||||||
Entity: EntityArtist,
|
Entity: EntityArtist,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
due, err := f.store.ListDueWants(ctx, 10)
|
due, err := f.store.ListDueRequests(ctx, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListDueWants: %v", err)
|
t.Fatalf("ListDueRequests: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(due) != 0 {
|
if len(due) != 0 {
|
||||||
t.Errorf("got %d due wants, want 0 — artists expand, not download", len(due))
|
t.Errorf("got %d due requests, want 0 — artists expand, not download", len(due))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,16 +456,16 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
|
|||||||
f.reconciler.SetBatch(2)
|
f.reconciler.SetBatch(2)
|
||||||
|
|
||||||
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
|
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
|
||||||
if _, err := f.store.AddWant(ctx, Want{
|
if _, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: mbid,
|
MBID: mbid,
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Title: "Album " + mbid,
|
Title: "Album " + mbid,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f.catalog.tracklists[mbid] = fourTrackRequest().Expected
|
f.catalog.tracklists[mbid] = fourTrackDownload().Expected
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := f.reconciler.RunOnce(ctx)
|
summary, err := f.reconciler.RunOnce(ctx)
|
||||||
@@ -474,7 +474,7 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if summary.Attempted != 2 {
|
if summary.Attempted != 2 {
|
||||||
t.Errorf("attempted %d wants, want 2 (the batch size)", summary.Attempted)
|
t.Errorf("attempted %d requests, want 2 (the batch size)", summary.Attempted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Request is a persistent "I want this", stored as a MusicBrainz ID
|
||||||
|
// and almost nothing else.
|
||||||
|
//
|
||||||
|
// The distinction from Download is the whole point of this file. A
|
||||||
|
// Download is one attempt: it searches, it grabs, it succeeds or fails,
|
||||||
|
// and then it is history. A Request outlives every attempt made on its
|
||||||
|
// behalf. Nothing being findable today is the normal case for obscure
|
||||||
|
// music, and the correct response is to try again next week, not to
|
||||||
|
// show the user a failed row they have to remember to retry.
|
||||||
|
//
|
||||||
|
// Because a Request is only an MBID, it stays true when everything
|
||||||
|
// around it changes: the explore index is rebuilt, a provider is
|
||||||
|
// swapped out, the release the user originally saw is superseded by a
|
||||||
|
// remaster. The display fields are a cache for the list view and are
|
||||||
|
// never consulted for matching.
|
||||||
|
|
||||||
|
// Entity says what a request's MBID names, and is the only type
|
||||||
|
// distinction the durable request list makes.
|
||||||
|
type Entity string
|
||||||
|
|
||||||
|
// Request entity types.
|
||||||
|
const (
|
||||||
|
// EntityArtist is a subscription rather than a thing to fetch: it
|
||||||
|
// is never satisfied, and each reconcile expands the artist's
|
||||||
|
// discography into child requests.
|
||||||
|
EntityArtist Entity = "artist"
|
||||||
|
|
||||||
|
// EntityReleaseGroup is an album in the abstract — any release of
|
||||||
|
// it satisfies the request, which is what a user means by "I want
|
||||||
|
// this album".
|
||||||
|
EntityReleaseGroup Entity = "release-group"
|
||||||
|
|
||||||
|
// EntityRelease is one specific edition, used when the user picked
|
||||||
|
// a particular pressing.
|
||||||
|
EntityRelease Entity = "release"
|
||||||
|
|
||||||
|
// EntityRecording is a single track.
|
||||||
|
EntityRecording Entity = "recording"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid reports whether e is a known entity type.
|
||||||
|
func (e Entity) Valid() bool {
|
||||||
|
switch e {
|
||||||
|
case EntityArtist, EntityReleaseGroup, EntityRelease, EntityRecording:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expands reports whether this entity produces child requests rather
|
||||||
|
// than being downloaded directly.
|
||||||
|
func (e Entity) Expands() bool {
|
||||||
|
return e == EntityArtist
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestState is where a request sits. There is deliberately no
|
||||||
|
// "failed": an attempt can fail, a request cannot. A request that has
|
||||||
|
// tried and not found anything is still wanted, with attempts and
|
||||||
|
// last_error recording why it is taking a while.
|
||||||
|
type RequestState string
|
||||||
|
|
||||||
|
// Request states.
|
||||||
|
const (
|
||||||
|
// RequestStateWanted is the active state: due for another attempt
|
||||||
|
// when its backoff elapses.
|
||||||
|
RequestStateWanted RequestState = "wanted"
|
||||||
|
|
||||||
|
// RequestStateSatisfied means the library owns it. How it got
|
||||||
|
// there — downloaded here, ripped, bought elsewhere — does not
|
||||||
|
// matter.
|
||||||
|
RequestStateSatisfied RequestState = "satisfied"
|
||||||
|
|
||||||
|
// RequestStatePaused is the user saying "keep this on the list but
|
||||||
|
// stop trying".
|
||||||
|
RequestStatePaused RequestState = "paused"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestScope applies to artist requests only.
|
||||||
|
type RequestScope string
|
||||||
|
|
||||||
|
// Artist request scopes.
|
||||||
|
const (
|
||||||
|
// ScopeFuture takes only releases first published after the artist
|
||||||
|
// was added. Default, because subscribing to an artist should not
|
||||||
|
// silently queue their entire back catalogue.
|
||||||
|
ScopeFuture RequestScope = "future"
|
||||||
|
|
||||||
|
// ScopeAll backfills the whole discography as well.
|
||||||
|
ScopeAll RequestScope = "all"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is one row of the durable request list.
|
||||||
|
type Request struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
MBID string `json:"mbid"`
|
||||||
|
Entity Entity `json:"entity"`
|
||||||
|
LibraryID int64 `json:"libraryId"`
|
||||||
|
|
||||||
|
// Artist and Title are display cache only. Matching always uses
|
||||||
|
// the MBID.
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
|
||||||
|
Scope RequestScope `json:"scope"`
|
||||||
|
|
||||||
|
// Secondary includes compilations, live albums and remixes in an
|
||||||
|
// artist request's expansion.
|
||||||
|
Secondary bool `json:"secondary"`
|
||||||
|
|
||||||
|
State RequestState `json:"state"`
|
||||||
|
|
||||||
|
// ParentID is set on requests the reconciler derived from an artist
|
||||||
|
// subscription. A request the user pinned directly has none, so
|
||||||
|
// removing the artist leaves it alone.
|
||||||
|
ParentID int64 `json:"parentId,omitempty"`
|
||||||
|
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
LastTriedAt time.Time `json:"lastTriedAt,omitempty"`
|
||||||
|
NextTryAt time.Time `json:"nextTryAt,omitempty"`
|
||||||
|
|
||||||
|
// ExternalIDs maps provider row ID (as a string, because JSON
|
||||||
|
// object keys are strings) to that provider's own identifier for
|
||||||
|
// this request. Only set for providers that keep a persistent
|
||||||
|
// list of their own.
|
||||||
|
ExternalIDs map[string]string `json:"externalIds,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchored is always true for a request: it is an MBID by construction.
|
||||||
|
// The method exists so requests and downloads read the same at call
|
||||||
|
// sites.
|
||||||
|
func (r Request) Anchored() bool { return r.MBID != "" }
|
||||||
|
|
||||||
|
// Label is the request list's one-line description of a request.
|
||||||
|
func (r Request) Label() string {
|
||||||
|
switch {
|
||||||
|
case r.Artist != "" && r.Title != "":
|
||||||
|
return r.Artist + " — " + r.Title
|
||||||
|
case r.Title != "":
|
||||||
|
return r.Title
|
||||||
|
case r.Artist != "":
|
||||||
|
return r.Artist
|
||||||
|
default:
|
||||||
|
return string(r.Entity) + " " + r.MBID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry backoff. A request that cannot be found is usually one that
|
||||||
|
// will not be findable for a while — a pre-release, something only
|
||||||
|
// ever on physical media, an artist no source indexes — so the
|
||||||
|
// schedule climbs fast and then sits at a weekly poll rather than
|
||||||
|
// hammering providers with the same fruitless search.
|
||||||
|
const (
|
||||||
|
// requestRetryBase is the delay after the first unsuccessful
|
||||||
|
// attempt.
|
||||||
|
requestRetryBase = 6 * time.Hour
|
||||||
|
|
||||||
|
// requestRetryMax caps the backoff. A weekly retry on a list of a
|
||||||
|
// few hundred requests is a handful of searches a day, which every
|
||||||
|
// provider tolerates.
|
||||||
|
requestRetryMax = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// requestRetryJitter spreads retries so a list added in one sitting
|
||||||
|
// does not come due in one burst.
|
||||||
|
requestRetryJitter = 0.2
|
||||||
|
)
|
||||||
|
|
||||||
|
// nextRetry returns when a request with the given attempt count should
|
||||||
|
// be tried again: exponential from requestRetryBase, capped at
|
||||||
|
// requestRetryMax, jittered so a batch added together does not stay in
|
||||||
|
// lockstep forever.
|
||||||
|
func nextRetry(now time.Time, attempts int) time.Time {
|
||||||
|
if attempts < 1 {
|
||||||
|
attempts = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap the exponent before shifting so a long-lived request cannot
|
||||||
|
// overflow the duration into something negative.
|
||||||
|
const maxExp = 16
|
||||||
|
|
||||||
|
exp := min(attempts-1, maxExp)
|
||||||
|
|
||||||
|
delay := float64(requestRetryBase) * math.Pow(2, float64(exp))
|
||||||
|
if delay > float64(requestRetryMax) {
|
||||||
|
delay = float64(requestRetryMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
jitter := delay * requestRetryJitter * (rand.Float64()*2 - 1) //nolint:gosec // spreading retries, not a secret
|
||||||
|
|
||||||
|
return now.Add(time.Duration(delay + jitter))
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestSource is the download source recorded for reconciler-raised
|
||||||
|
// downloads, so the downloads list can tell them apart from the ones a
|
||||||
|
// user started by hand.
|
||||||
|
const requestSource = "wanted"
|
||||||
|
|
||||||
|
// ToDownload builds the download that would satisfy this request.
|
||||||
|
// Expected is filled by the caller from the catalog, since resolving a
|
||||||
|
// tracklist is I/O and this is not.
|
||||||
|
func (r Request) ToDownload(id string) Download {
|
||||||
|
d := Download{
|
||||||
|
ID: id,
|
||||||
|
LibraryID: r.LibraryID,
|
||||||
|
Artist: r.Artist,
|
||||||
|
Album: r.Title,
|
||||||
|
RequestID: r.ID,
|
||||||
|
Source: requestSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Entity {
|
||||||
|
case EntityRelease:
|
||||||
|
d.ReleaseMBID = r.MBID
|
||||||
|
case EntityReleaseGroup:
|
||||||
|
d.ReleaseGroupMBID = r.MBID
|
||||||
|
case EntityRecording:
|
||||||
|
// A recording has no release anchor, so ranking has only the
|
||||||
|
// title to go on and auto-pick stays off. The MBID is still
|
||||||
|
// carried in RecordingMBID so a provider that can use it does.
|
||||||
|
d.RecordingMBID = r.MBID
|
||||||
|
case EntityArtist:
|
||||||
|
// Artist requests expand into children and are never turned
|
||||||
|
// into a download directly; this case exists so the switch is
|
||||||
|
// exhaustive rather than because it can happen.
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -17,18 +17,18 @@ func TestNextRetryClimbsAndCaps(t *testing.T) {
|
|||||||
attempts int
|
attempts int
|
||||||
nominal time.Duration
|
nominal time.Duration
|
||||||
}{
|
}{
|
||||||
{attempts: 1, nominal: wantRetryBase},
|
{attempts: 1, nominal: requestRetryBase},
|
||||||
{attempts: 2, nominal: 2 * wantRetryBase},
|
{attempts: 2, nominal: 2 * requestRetryBase},
|
||||||
{attempts: 3, nominal: 4 * wantRetryBase},
|
{attempts: 3, nominal: 4 * requestRetryBase},
|
||||||
{attempts: 20, nominal: wantRetryMax},
|
{attempts: 20, nominal: requestRetryMax},
|
||||||
{attempts: 500, nominal: wantRetryMax},
|
{attempts: 500, nominal: requestRetryMax},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
got := nextRetry(now, tt.attempts).Sub(now)
|
got := nextRetry(now, tt.attempts).Sub(now)
|
||||||
|
|
||||||
lo := time.Duration(float64(tt.nominal) * (1 - wantRetryJitter))
|
lo := time.Duration(float64(tt.nominal) * (1 - requestRetryJitter))
|
||||||
hi := time.Duration(float64(tt.nominal) * (1 + wantRetryJitter))
|
hi := time.Duration(float64(tt.nominal) * (1 + requestRetryJitter))
|
||||||
|
|
||||||
if got < lo || got > hi {
|
if got < lo || got > hi {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
@@ -83,14 +83,14 @@ func TestReleaseDateAfter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWantsReleaseGroupFilters(t *testing.T) {
|
func TestRequestsReleaseGroupFilters(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
subscribed := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
subscribed := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
future := Want{Scope: ScopeFuture, CreatedAt: subscribed}
|
future := Request{Scope: ScopeFuture, CreatedAt: subscribed}
|
||||||
all := Want{Scope: ScopeAll, CreatedAt: subscribed}
|
all := Request{Scope: ScopeAll, CreatedAt: subscribed}
|
||||||
allSecondary := Want{
|
allSecondary := Request{
|
||||||
Scope: ScopeAll, Secondary: true, CreatedAt: subscribed,
|
Scope: ScopeAll, Secondary: true, CreatedAt: subscribed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ func TestWantsReleaseGroupFilters(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
artist Want
|
artist Request
|
||||||
rg CatalogItem
|
rg CatalogItem
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
@@ -131,54 +131,54 @@ func TestWantsReleaseGroupFilters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
if got := wantsReleaseGroup(tt.artist, tt.rg); got != tt.want {
|
if got := requestsReleaseGroup(tt.artist, tt.rg); got != tt.want {
|
||||||
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
|
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWantToRequestAnchors(t *testing.T) {
|
func TestRequestToDownloadAnchors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
entity Entity
|
entity Entity
|
||||||
check func(Request) string
|
check func(Download) string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
entity: EntityReleaseGroup,
|
entity: EntityReleaseGroup,
|
||||||
check: func(r Request) string {
|
check: func(r Download) string {
|
||||||
return r.ReleaseGroupMBID
|
return r.ReleaseGroupMBID
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
entity: EntityRelease,
|
entity: EntityRelease,
|
||||||
check: func(r Request) string { return r.ReleaseMBID },
|
check: func(r Download) string { return r.ReleaseMBID },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
entity: EntityRecording,
|
entity: EntityRecording,
|
||||||
check: func(r Request) string { return r.RecordingMBID },
|
check: func(r Download) string { return r.RecordingMBID },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
w := Want{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
|
req := Request{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
|
||||||
|
|
||||||
req := w.ToRequest("req-1")
|
dl := req.ToDownload("dl-1")
|
||||||
|
|
||||||
if got := tt.check(req); got != "mbid-x" {
|
if got := tt.check(dl); got != "mbid-x" {
|
||||||
t.Errorf("%s: anchor = %q, want mbid-x", tt.entity, got)
|
t.Errorf("%s: anchor = %q, want mbid-x", tt.entity, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !req.Anchored() {
|
if !dl.Anchored() {
|
||||||
t.Errorf("%s: request is not anchored", tt.entity)
|
t.Errorf("%s: download is not anchored", tt.entity)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.WantID != 7 {
|
if dl.RequestID != 7 {
|
||||||
t.Errorf("%s: WantID = %d, want 7", tt.entity, req.WantID)
|
t.Errorf("%s: RequestID = %d, want 7", tt.entity, dl.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Source != wantSource {
|
if dl.Source != requestSource {
|
||||||
t.Errorf("%s: Source = %q, want %q", tt.entity, req.Source, wantSource)
|
t.Errorf("%s: Source = %q, want %q", tt.entity, dl.Source, requestSource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ func TestWantToRequestAnchors(t *testing.T) {
|
|||||||
func TestAutoPickableRequiresTracklist(t *testing.T) {
|
func TestAutoPickableRequiresTracklist(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
req := Request{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
|
dl := Download{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
|
||||||
|
|
||||||
ranked := []Candidate{{
|
ranked := []Candidate{{
|
||||||
Match: MatchScore{Overall: 0.99, Anchored: true},
|
Match: MatchScore{Overall: 0.99, Anchored: true},
|
||||||
@@ -197,42 +197,42 @@ func TestAutoPickableRequiresTracklist(t *testing.T) {
|
|||||||
Score: 0.95,
|
Score: 0.95,
|
||||||
}}
|
}}
|
||||||
|
|
||||||
if AutoPickable(req, ranked) {
|
if AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
|
||||||
t.Error("auto-picked a request with no expected tracklist")
|
t.Error("auto-picked a download with no expected tracklist")
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
|
dl.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
|
||||||
|
|
||||||
if !AutoPickable(req, ranked) {
|
if !AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
|
||||||
t.Error("did not auto-pick a well-anchored, well-matched request")
|
t.Error("did not auto-pick a well-anchored, well-matched download")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wanted list's identity is the MBID, so the same one arriving
|
// The wanted list's identity is the MBID, so the same one arriving
|
||||||
// twice — in a different case, with whitespace — is one row.
|
// twice — in a different case, with whitespace — is one row.
|
||||||
func TestAddWantNormalizesAndDeduplicates(t *testing.T) {
|
func TestAddRequestNormalizesAndDeduplicates(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := newManagerFixture(t)
|
f := newManagerFixture(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
first, err := f.store.AddWant(ctx, Want{
|
first, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: " ABC-123 ",
|
MBID: " ABC-123 ",
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Title: "OK Computer",
|
Title: "OK Computer",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := f.store.AddWant(ctx, Want{
|
second, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "abc-123",
|
MBID: "abc-123",
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddWant again: %v", err)
|
t.Fatalf("AddRequest again: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if first != second {
|
if first != second {
|
||||||
@@ -240,27 +240,27 @@ func TestAddWantNormalizesAndDeduplicates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-adding with no title must not wipe the one we have.
|
// Re-adding with no title must not wipe the one we have.
|
||||||
w, err := f.store.GetWant(ctx, first)
|
req, err := f.store.GetRequest(ctx, first)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetWant: %v", err)
|
t.Fatalf("GetRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.Title != "OK Computer" {
|
if req.Title != "OK Computer" {
|
||||||
t.Errorf("title = %q, want it preserved", w.Title)
|
t.Errorf("title = %q, want it preserved", req.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.MBID != "abc-123" {
|
if req.MBID != "abc-123" {
|
||||||
t.Errorf("mbid = %q, want normalized", w.MBID)
|
t.Errorf("mbid = %q, want normalized", req.MBID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWantStoreLifecycle(t *testing.T) {
|
func TestRequestStoreLifecycle(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := newManagerFixture(t)
|
f := newManagerFixture(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
id, err := f.store.AddWant(ctx, Want{
|
id, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "rg-1",
|
MBID: "rg-1",
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
@@ -268,17 +268,17 @@ func TestWantStoreLifecycle(t *testing.T) {
|
|||||||
Title: "OK Computer",
|
Title: "OK Computer",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddWant: %v", err)
|
t.Fatalf("AddRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A brand new want is due immediately.
|
// A brand new want is due immediately.
|
||||||
due, err := f.store.ListDueWants(ctx, 10)
|
due, err := f.store.ListDueRequests(ctx, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListDueWants: %v", err)
|
t.Fatalf("ListDueRequests: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(due) != 1 {
|
if len(due) != 1 {
|
||||||
t.Fatalf("got %d due wants, want 1", len(due))
|
t.Fatalf("got %d due requests, want 1", len(due))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recording an attempt pushes it out of the due set without
|
// Recording an attempt pushes it out of the due set without
|
||||||
@@ -287,92 +287,92 @@ func TestWantStoreLifecycle(t *testing.T) {
|
|||||||
t.Fatalf("RecordAttempt: %v", err)
|
t.Fatalf("RecordAttempt: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
due, err = f.store.ListDueWants(ctx, 10)
|
due, err = f.store.ListDueRequests(ctx, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListDueWants after attempt: %v", err)
|
t.Fatalf("ListDueRequests after attempt: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(due) != 0 {
|
if len(due) != 0 {
|
||||||
t.Errorf("got %d due wants after an attempt, want 0", len(due))
|
t.Errorf("got %d due requests after an attempt, want 0", len(due))
|
||||||
}
|
}
|
||||||
|
|
||||||
w, err := f.store.GetWant(ctx, id)
|
req, err := f.store.GetRequest(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetWant: %v", err)
|
t.Fatalf("GetRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.State != WantStateWanted {
|
if req.State != RequestStateWanted {
|
||||||
t.Errorf("state = %q, want it still wanted", w.State)
|
t.Errorf("state = %q, want it still wanted", req.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.Attempts != 1 {
|
if req.Attempts != 1 {
|
||||||
t.Errorf("attempts = %d, want 1", w.Attempts)
|
t.Errorf("attempts = %d, want 1", req.Attempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.LastError == "" {
|
if req.LastError == "" {
|
||||||
t.Error("last error was not recorded")
|
t.Error("last error was not recorded")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := f.store.SatisfyWant(ctx, id); err != nil {
|
if err := f.store.SatisfyRequest(ctx, id); err != nil {
|
||||||
t.Fatalf("SatisfyWant: %v", err)
|
t.Fatalf("SatisfyRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
w, err = f.store.GetWant(ctx, id)
|
req, err = f.store.GetRequest(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetWant after satisfy: %v", err)
|
t.Fatalf("GetRequest after satisfy: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.State != WantStateSatisfied {
|
if req.State != RequestStateSatisfied {
|
||||||
t.Errorf("state = %q, want satisfied", w.State)
|
t.Errorf("state = %q, want satisfied", req.State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removing an artist subscription takes the albums it derived with it,
|
// Removing an artist subscription takes the albums it derived with it,
|
||||||
// so a user who unsubscribes does not keep downloading that artist.
|
// so a user who unsubscribes does not keep downloading that artist.
|
||||||
func TestDeleteArtistWantCascadesToChildren(t *testing.T) {
|
func TestDeleteArtistRequestCascadesToChildren(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := newManagerFixture(t)
|
f := newManagerFixture(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
artist, err := f.store.AddWant(ctx, Want{
|
artist, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "artist-1",
|
MBID: "artist-1",
|
||||||
Entity: EntityArtist,
|
Entity: EntityArtist,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
Artist: "Radiohead",
|
Artist: "Radiohead",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddWant artist: %v", err)
|
t.Fatalf("AddRequest artist: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := f.store.AddWant(ctx, Want{
|
if _, err := f.store.AddRequest(ctx, Request{
|
||||||
MBID: "rg-1",
|
MBID: "rg-1",
|
||||||
Entity: EntityReleaseGroup,
|
Entity: EntityReleaseGroup,
|
||||||
LibraryID: 1,
|
LibraryID: 1,
|
||||||
ParentID: artist,
|
ParentID: artist,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("AddWant child: %v", err)
|
t.Fatalf("AddRequest child: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
children, err := f.store.ListChildWants(ctx, artist)
|
children, err := f.store.ListChildRequests(ctx, artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListChildWants: %v", err)
|
t.Fatalf("ListChildRequests: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(children) != 1 {
|
if len(children) != 1 {
|
||||||
t.Fatalf("got %d children, want 1", len(children))
|
t.Fatalf("got %d children, want 1", len(children))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := f.store.DeleteWant(ctx, artist); err != nil {
|
if err := f.store.DeleteRequest(ctx, artist); err != nil {
|
||||||
t.Fatalf("DeleteWant: %v", err)
|
t.Fatalf("DeleteRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
all, err := f.store.ListWants(ctx)
|
all, err := f.store.ListRequests(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListWants: %v", err)
|
t.Fatalf("ListRequests: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(all) != 0 {
|
if len(all) != 0 {
|
||||||
t.Errorf("got %d wants after deleting the artist, want 0", len(all))
|
t.Errorf("got %d requests after deleting the artist, want 0", len(all))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
"yellowjacket/backend/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNoLibrary means the request did not name a library to attach the
|
// ErrNoLibrary means the caller did not name a library to attach the
|
||||||
// download to. Letting it through would hit the download_requests /
|
// download to. Letting it through would hit the download_downloads /
|
||||||
// download_wants foreign key on library_id and surface as a raw SQLite
|
// download_requests foreign key on library_id and surface as a raw
|
||||||
// error, so it is rejected here with a message the UI can show.
|
// SQLite error, so it is rejected here with a message the UI can show.
|
||||||
var ErrNoLibrary = errors.New("no library selected")
|
var ErrNoLibrary = errors.New("no library selected")
|
||||||
|
|
||||||
// Service is the frontend-facing surface of the download subsystem.
|
// Service is the frontend-facing surface of the download subsystem.
|
||||||
@@ -258,8 +258,20 @@ func (s *Service) TestProvider(id int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPreferences pushes the auto-download guardrails straight into the
|
||||||
|
// running Manager, without persisting them. Persistence is
|
||||||
|
// config.Config's job (GetDownloadPreferences/SetDownloadPreferences);
|
||||||
|
// this package cannot depend on config, since config already depends on
|
||||||
|
// download for UserConfig. The frontend settings save is expected to
|
||||||
|
// call the config setter and this method in the same action, the way
|
||||||
|
// UpdateProvider already achieves "live without a restart" by touching
|
||||||
|
// storage and the running Manager together.
|
||||||
|
func (s *Service) SetPreferences(prefs AutoDownloadPrefs) {
|
||||||
|
s.manager.SetPreferences(prefs)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Requests
|
// Downloads
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// SearchRequest is what the frontend submits to start a download.
|
// SearchRequest is what the frontend submits to start a download.
|
||||||
@@ -275,7 +287,7 @@ type SearchRequest struct {
|
|||||||
|
|
||||||
// StartResult is what the picker needs after a search.
|
// StartResult is what the picker needs after a search.
|
||||||
type StartResult struct {
|
type StartResult struct {
|
||||||
RequestID string `json:"requestId"`
|
DownloadID string `json:"downloadId"`
|
||||||
Candidates []Candidate `json:"candidates"`
|
Candidates []Candidate `json:"candidates"`
|
||||||
|
|
||||||
// AutoPicked reports that the pipeline already chose and is
|
// AutoPicked reports that the pipeline already chose and is
|
||||||
@@ -284,14 +296,21 @@ type StartResult struct {
|
|||||||
AutoPicked bool `json:"autoPicked"`
|
AutoPicked bool `json:"autoPicked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start searches for a release and either auto-picks a clear winner or
|
// StartDownload searches for a release and either auto-picks a clear
|
||||||
// returns ranked candidates for the user to choose from.
|
// winner or returns ranked candidates for the user to choose from.
|
||||||
func (s *Service) Start(req SearchRequest) (StartResult, error) {
|
//
|
||||||
|
// When the search carries a MusicBrainz anchor, it also resolves or
|
||||||
|
// creates the durable Request the anchor names and attaches it, via
|
||||||
|
// ensureRequest — so a manual download that fails or finds nothing
|
||||||
|
// right now leaves a durable record behind instead of just vanishing,
|
||||||
|
// and the reconciler picks it up on its normal schedule exactly as if
|
||||||
|
// the user had explicitly added it to the request list.
|
||||||
|
func (s *Service) StartDownload(req SearchRequest) (StartResult, error) {
|
||||||
if req.LibraryID <= 0 {
|
if req.LibraryID <= 0 {
|
||||||
return StartResult{}, ErrNoLibrary
|
return StartResult{}, ErrNoLibrary
|
||||||
}
|
}
|
||||||
|
|
||||||
r := Request{
|
dl := Download{
|
||||||
ID: newID(),
|
ID: newID(),
|
||||||
LibraryID: req.LibraryID,
|
LibraryID: req.LibraryID,
|
||||||
ReleaseMBID: req.ReleaseMBID,
|
ReleaseMBID: req.ReleaseMBID,
|
||||||
@@ -302,15 +321,19 @@ func (s *Service) Start(req SearchRequest) (StartResult, error) {
|
|||||||
Expected: req.Expected,
|
Expected: req.Expected,
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := s.manager.Start(context.Background(), r)
|
if id, ok := s.ensureRequest(dl); ok {
|
||||||
|
dl.RequestID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := s.manager.Start(context.Background(), dl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StartResult{}, err
|
return StartResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := StartResult{
|
result := StartResult{
|
||||||
RequestID: r.ID,
|
DownloadID: dl.ID,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
AutoPicked: AutoPickable(r, candidates),
|
AutoPicked: s.manager.AutoPickable(dl, candidates),
|
||||||
}
|
}
|
||||||
|
|
||||||
s.emit(events.DownloadsChanged)
|
s.emit(events.DownloadsChanged)
|
||||||
@@ -318,10 +341,51 @@ func (s *Service) Start(req SearchRequest) (StartResult, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureRequest resolves or creates the durable Request an anchored
|
||||||
|
// manual download should be attached to. Free-text downloads (no
|
||||||
|
// MBID) have nothing stable to attach to and are left alone.
|
||||||
|
//
|
||||||
|
// AddRequest already treats "asking twice" as one request and never
|
||||||
|
// resets backoff or un-pauses a paused request on conflict, so a
|
||||||
|
// manual download on something already paused still runs its one
|
||||||
|
// interactive attempt now without disturbing the request's state.
|
||||||
|
func (s *Service) ensureRequest(d Download) (int64, bool) {
|
||||||
|
var (
|
||||||
|
entity Entity
|
||||||
|
mbid string
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case d.ReleaseMBID != "":
|
||||||
|
entity, mbid = EntityRelease, d.ReleaseMBID
|
||||||
|
case d.ReleaseGroupMBID != "":
|
||||||
|
entity, mbid = EntityReleaseGroup, d.ReleaseGroupMBID
|
||||||
|
case d.RecordingMBID != "":
|
||||||
|
entity, mbid = EntityRecording, d.RecordingMBID
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.store.AddRequest(context.Background(), Request{
|
||||||
|
MBID: mbid,
|
||||||
|
Entity: entity,
|
||||||
|
LibraryID: d.LibraryID,
|
||||||
|
Artist: d.Artist,
|
||||||
|
Title: d.Album,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("could not attach request to manual download", "error", err)
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
// Pick starts the transfer for the candidate the user chose.
|
// Pick starts the transfer for the candidate the user chose.
|
||||||
func (s *Service) Pick(requestID, candidateID string) error {
|
func (s *Service) Pick(downloadID, candidateID string) error {
|
||||||
if err := s.manager.Pick(
|
if err := s.manager.Pick(
|
||||||
context.Background(), requestID, candidateID,
|
context.Background(), downloadID, candidateID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -331,9 +395,9 @@ func (s *Service) Pick(requestID, candidateID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel aborts a live request.
|
// Cancel aborts a live download.
|
||||||
func (s *Service) Cancel(requestID string) error {
|
func (s *Service) Cancel(downloadID string) error {
|
||||||
if err := s.manager.Cancel(context.Background(), requestID); err != nil {
|
if err := s.manager.Cancel(context.Background(), downloadID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,23 +406,28 @@ func (s *Service) Cancel(requestID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidates returns the ranked candidates of a live request, so the
|
// Candidates returns the ranked candidates of a live download, so the
|
||||||
// picker can be reopened without searching again.
|
// picker can be reopened without searching again.
|
||||||
func (s *Service) Candidates(requestID string) []Candidate {
|
func (s *Service) Candidates(downloadID string) []Candidate {
|
||||||
return s.manager.Candidates(requestID)
|
return s.manager.Candidates(downloadID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestView is one row of the downloads list.
|
// DownloadView is one row of the downloads list.
|
||||||
type RequestView struct {
|
//
|
||||||
Request
|
// would stop reading as "one row of the Downloads list" the moment this
|
||||||
|
// package also has a Requests list — see RequestInput/Request nearby.
|
||||||
|
//
|
||||||
|
//nolint:revive // stutters as download.DownloadView, but a bare "View"
|
||||||
|
type DownloadView struct {
|
||||||
|
Download
|
||||||
|
|
||||||
State State `json:"state"`
|
State State `json:"state"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Items []Item `json:"items"`
|
Items []DownloadItem `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRequests returns recent download requests, newest first.
|
// ListDownloads returns recent downloads, newest first.
|
||||||
func (s *Service) ListRequests(limit int) ([]RequestView, error) {
|
func (s *Service) ListDownloads(limit int) ([]DownloadView, error) {
|
||||||
const defaultLimit = 50
|
const defaultLimit = 50
|
||||||
|
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
@@ -367,36 +436,36 @@ func (s *Service) ListRequests(limit int) ([]RequestView, error) {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
requests, err := s.store.ListRequests(ctx, limit)
|
downloads, err := s.store.ListDownloads(ctx, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]RequestView, 0, len(requests))
|
out := make([]DownloadView, 0, len(downloads))
|
||||||
|
|
||||||
for _, r := range requests {
|
for _, d := range downloads {
|
||||||
state, errText, err := s.store.GetRequestState(ctx, r.ID)
|
state, errText, err := s.store.GetDownloadState(ctx, d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := s.store.ListItemsForRequest(ctx, r.ID)
|
items, err := s.store.ListItemsForDownload(ctx, d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
out = append(out, RequestView{
|
out = append(out, DownloadView{
|
||||||
Request: r,
|
Download: d,
|
||||||
State: state,
|
State: state,
|
||||||
Error: errText,
|
Error: errText,
|
||||||
Items: items,
|
Items: items,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearFinished removes terminal requests from the list.
|
// ClearFinished removes terminal downloads from the list.
|
||||||
func (s *Service) ClearFinished() error {
|
func (s *Service) ClearFinished() error {
|
||||||
if err := s.store.ClearFinished(context.Background()); err != nil {
|
if err := s.store.ClearFinished(context.Background()); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -408,19 +477,20 @@ func (s *Service) ClearFinished() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Wanted list
|
// Request list
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// SetReconciler wires the wanted-list loop. Optional: without it the
|
// SetReconciler wires the request-list loop. Optional: without it the
|
||||||
// wanted list still stores and lists wants, it just never acts on them.
|
// request list still stores and lists requests, it just never acts on
|
||||||
|
// them.
|
||||||
func (s *Service) SetReconciler(r *Reconciler) {
|
func (s *Service) SetReconciler(r *Reconciler) {
|
||||||
s.reconciler = r
|
s.reconciler = r
|
||||||
}
|
}
|
||||||
|
|
||||||
// WantRequest is what the frontend submits to want something. It is
|
// RequestInput is what the frontend submits to request something. It
|
||||||
// one MBID and the type of thing it names, because that is genuinely
|
// is one MBID and the type of thing it names, because that is
|
||||||
// all a want is.
|
// genuinely all a durable request is.
|
||||||
type WantRequest struct {
|
type RequestInput struct {
|
||||||
MBID string `json:"mbid"`
|
MBID string `json:"mbid"`
|
||||||
Entity string `json:"entity"`
|
Entity string `json:"entity"`
|
||||||
LibraryID int64 `json:"libraryId"`
|
LibraryID int64 `json:"libraryId"`
|
||||||
@@ -431,15 +501,15 @@ type WantRequest struct {
|
|||||||
Artist string `json:"artist"`
|
Artist string `json:"artist"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|
||||||
// Scope and Secondary apply to artist wants.
|
// Scope and Secondary apply to artist requests.
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
Secondary bool `json:"secondary"`
|
Secondary bool `json:"secondary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddWant puts something on the wanted list and asks for a reconcile
|
// AddRequest puts something on the request list and asks for a
|
||||||
// pass, so the user sees something happen rather than waiting six hours
|
// reconcile pass, so the user sees something happen rather than
|
||||||
// for the next scheduled one.
|
// waiting six hours for the next scheduled one.
|
||||||
func (s *Service) AddWant(req WantRequest) (int64, error) {
|
func (s *Service) AddRequest(req RequestInput) (int64, error) {
|
||||||
if req.LibraryID <= 0 {
|
if req.LibraryID <= 0 {
|
||||||
return 0, ErrNoLibrary
|
return 0, ErrNoLibrary
|
||||||
}
|
}
|
||||||
@@ -449,12 +519,12 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
|
|||||||
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, req.Entity)
|
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, req.Entity)
|
||||||
}
|
}
|
||||||
|
|
||||||
scope := WantScope(req.Scope)
|
scope := RequestScope(req.Scope)
|
||||||
if scope != ScopeAll {
|
if scope != ScopeAll {
|
||||||
scope = ScopeFuture
|
scope = ScopeFuture
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := s.store.AddWant(context.Background(), Want{
|
id, err := s.store.AddRequest(context.Background(), Request{
|
||||||
MBID: req.MBID,
|
MBID: req.MBID,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
LibraryID: req.LibraryID,
|
LibraryID: req.LibraryID,
|
||||||
@@ -467,7 +537,7 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.emit(events.WantedListChanged)
|
s.emit(events.RequestsChanged)
|
||||||
|
|
||||||
if s.reconciler != nil {
|
if s.reconciler != nil {
|
||||||
s.reconciler.Trigger()
|
s.reconciler.Trigger()
|
||||||
@@ -476,73 +546,73 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListWants returns the whole wanted list.
|
// ListRequests returns the whole durable request list.
|
||||||
func (s *Service) ListWants() ([]Want, error) {
|
func (s *Service) ListRequests() ([]Request, error) {
|
||||||
return s.store.ListWants(context.Background())
|
return s.store.ListRequests(context.Background())
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsWanted answers the Explore pages' question — should this album show
|
// IsRequested answers the Explore pages' question — should this album
|
||||||
// "want" or "wanted?" — without making them load the whole list.
|
// show "want" or "wanted?" — without making them load the whole list.
|
||||||
func (s *Service) IsWanted(mbid string, libraryID int64) (bool, error) {
|
func (s *Service) IsRequested(mbid string, libraryID int64) (bool, error) {
|
||||||
_, found, err := s.store.FindWant(context.Background(), mbid, libraryID)
|
_, found, err := s.store.FindRequest(context.Background(), mbid, libraryID)
|
||||||
|
|
||||||
return found, err
|
return found, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveWant takes something off the list. Removing an artist takes
|
// RemoveRequest takes something off the list. Removing an artist takes
|
||||||
// its derived albums with it, by cascade; an album the user pinned
|
// its derived albums with it, by cascade; an album the user pinned
|
||||||
// themselves has no parent and survives.
|
// themselves has no parent and survives.
|
||||||
func (s *Service) RemoveWant(id int64) error {
|
func (s *Service) RemoveRequest(id int64) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Tell any external list first, while the row is still readable.
|
// Tell any external list first, while the row is still readable.
|
||||||
s.withdrawExternal(ctx, id)
|
s.withdrawExternal(ctx, id)
|
||||||
|
|
||||||
if err := s.store.DeleteWant(ctx, id); err != nil {
|
if err := s.store.DeleteRequest(ctx, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.emit(events.WantedListChanged)
|
s.emit(events.RequestsChanged)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PauseWant stops attempts without forgetting the want.
|
// PauseRequest stops attempts without forgetting the request.
|
||||||
func (s *Service) PauseWant(id int64, paused bool) error {
|
func (s *Service) PauseRequest(id int64, paused bool) error {
|
||||||
state := WantStateWanted
|
state := RequestStateWanted
|
||||||
if paused {
|
if paused {
|
||||||
state = WantStatePaused
|
state = RequestStatePaused
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.SetWantState(
|
if err := s.store.SetRequestState(
|
||||||
context.Background(), id, state, "",
|
context.Background(), id, state, "",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.emit(events.WantedListChanged)
|
s.emit(events.RequestsChanged)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearSatisfiedWants drops everything already owned.
|
// ClearSatisfiedRequests drops everything already owned.
|
||||||
func (s *Service) ClearSatisfiedWants() error {
|
func (s *Service) ClearSatisfiedRequests() error {
|
||||||
if err := s.store.ClearSatisfiedWants(context.Background()); err != nil {
|
if err := s.store.ClearSatisfiedRequests(context.Background()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.emit(events.WantedListChanged)
|
s.emit(events.RequestsChanged)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReconcileWanted runs a pass now and reports what it did. This backs
|
// ReconcileRequests runs a pass now and reports what it did. This
|
||||||
// the "check now" button, so it runs synchronously: the user pressed it
|
// backs the "check now" button, so it runs synchronously: the user
|
||||||
// and is waiting for an answer.
|
// pressed it and is waiting for an answer.
|
||||||
func (s *Service) ReconcileWanted() (Summary, error) {
|
func (s *Service) ReconcileRequests() (Summary, error) {
|
||||||
if s.reconciler == nil {
|
if s.reconciler == nil {
|
||||||
return Summary{}, fmt.Errorf(
|
return Summary{}, fmt.Errorf(
|
||||||
"%w: the wanted list is not running", ErrUnsupported,
|
"%w: the request list is not running", ErrUnsupported,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,20 +621,20 @@ func (s *Service) ReconcileWanted() (Summary, error) {
|
|||||||
return summary, err
|
return summary, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.emit(events.WantedListChanged)
|
s.emit(events.RequestsChanged)
|
||||||
|
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportExternalWants adopts a provider's own list — "import the
|
// ImportExternalRequests adopts a provider's own list — "import the
|
||||||
// artists Lidarr is already monitoring".
|
// artists Lidarr is already monitoring".
|
||||||
func (s *Service) ImportExternalWants(
|
func (s *Service) ImportExternalRequests(
|
||||||
providerID int64,
|
providerID int64,
|
||||||
libraryID int64,
|
libraryID int64,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
if s.reconciler == nil {
|
if s.reconciler == nil {
|
||||||
return 0, fmt.Errorf(
|
return 0, fmt.Errorf(
|
||||||
"%w: the wanted list is not running", ErrUnsupported,
|
"%w: the request list is not running", ErrUnsupported,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,22 +645,22 @@ func (s *Service) ImportExternalWants(
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.emit(events.WantedListChanged)
|
s.emit(events.RequestsChanged)
|
||||||
|
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// withdrawExternal best-effort unmonitors a want in the external lists
|
// withdrawExternal best-effort unmonitors a request in the external
|
||||||
// it was pushed to. Failures are logged and ignored: the user asked to
|
// lists it was pushed to. Failures are logged and ignored: the user
|
||||||
// remove it from *this* list, and an unreachable Lidarr is not a reason
|
// asked to remove it from *this* list, and an unreachable Lidarr is not
|
||||||
// to refuse.
|
// a reason to refuse.
|
||||||
func (s *Service) withdrawExternal(ctx context.Context, id int64) {
|
func (s *Service) withdrawExternal(ctx context.Context, id int64) {
|
||||||
w, err := s.store.GetWant(ctx, id)
|
r, err := s.store.GetRequest(ctx, id)
|
||||||
if err != nil || len(w.ExternalIDs) == 0 {
|
if err != nil || len(r.ExternalIDs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, externalID := range w.ExternalIDs {
|
for key, externalID := range r.ExternalIDs {
|
||||||
providerID, err := strconv.ParseInt(key, 10, 64)
|
providerID, err := strconv.ParseInt(key, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -601,10 +671,10 @@ func (s *Service) withdrawExternal(ctx context.Context, id int64) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := l.RemoveWant(ctx, externalID); err != nil {
|
if err := l.RemoveRequest(ctx, externalID); err != nil {
|
||||||
s.logger.Debug(
|
s.logger.Debug(
|
||||||
"could not withdraw want from external list",
|
"could not withdraw request from external list",
|
||||||
"want", id,
|
"request", id,
|
||||||
"provider", providerID,
|
"provider", providerID,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
// CreateDownload persists a new download.
|
||||||
func (s *Store) CreateRequest(ctx context.Context, req Request) error {
|
func (s *Store) CreateDownload(ctx context.Context, dl Download) error {
|
||||||
expected, err := json.Marshal(req.Expected)
|
expected, err := json.Marshal(dl.Expected)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode expected tracks: %w", err)
|
return fmt.Errorf("encode expected tracks: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
source := req.Source
|
source := dl.Source
|
||||||
if source == "" {
|
if source == "" {
|
||||||
source = "manual"
|
source = "manual"
|
||||||
}
|
}
|
||||||
|
|
||||||
wantID := sql.NullInt64{}
|
requestID := sql.NullInt64{}
|
||||||
if req.WantID != 0 {
|
if dl.RequestID != 0 {
|
||||||
wantID = sql.NullInt64{Int64: req.WantID, Valid: true}
|
requestID = sql.NullInt64{Int64: dl.RequestID, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Queries.CreateDownloadRequest(
|
if err := s.db.Queries.CreateDownload(
|
||||||
ctx,
|
ctx,
|
||||||
sqlcgen.CreateDownloadRequestParams{
|
sqlcgen.CreateDownloadParams{
|
||||||
ID: req.ID,
|
ID: dl.ID,
|
||||||
LibraryID: req.LibraryID,
|
LibraryID: dl.LibraryID,
|
||||||
Source: source,
|
Source: source,
|
||||||
WantID: wantID,
|
RequestID: requestID,
|
||||||
ReleaseMbid: toNullString(req.ReleaseMBID),
|
ReleaseMbid: toNullString(dl.ReleaseMBID),
|
||||||
ReleaseGroupMbid: toNullString(req.ReleaseGroupMBID),
|
ReleaseGroupMbid: toNullString(dl.ReleaseGroupMBID),
|
||||||
RecordingMbid: toNullString(req.RecordingMBID),
|
RecordingMbid: toNullString(dl.RecordingMBID),
|
||||||
Artist: req.Artist,
|
Artist: dl.Artist,
|
||||||
Album: req.Album,
|
Album: dl.Album,
|
||||||
Query: req.Query,
|
Query: dl.Query,
|
||||||
Expected: string(expected),
|
Expected: string(expected),
|
||||||
State: string(StateSearching),
|
State: string(StateSearching),
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("create download request: %w", err)
|
return fmt.Errorf("create download: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRequest loads a request by ID.
|
// GetDownload loads a download by ID.
|
||||||
func (s *Store) GetRequest(ctx context.Context, id string) (Request, error) {
|
func (s *Store) GetDownload(ctx context.Context, id string) (Download, error) {
|
||||||
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
|
row, err := s.db.ReadQueries.GetDownload(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Request{}, fmt.Errorf("%w: request %s", ErrNotFound, id)
|
return Download{}, fmt.Errorf("%w: download %s", ErrNotFound, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Request{}, fmt.Errorf("get download request: %w", err)
|
return Download{}, fmt.Errorf("get download: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestRowToRequest(row), nil
|
return downloadRowToDownload(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRequestState returns a request's current state and error text.
|
// GetDownloadState returns a download's current state and error text.
|
||||||
// Kept separate from GetRequest because state is the one field that
|
// Kept separate from GetDownload because state is the one field that
|
||||||
// changes constantly while the rest of the row is immutable.
|
// changes constantly while the rest of the row is immutable.
|
||||||
func (s *Store) GetRequestState(
|
func (s *Store) GetDownloadState(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
id string,
|
id string,
|
||||||
) (State, string, error) {
|
) (State, string, error) {
|
||||||
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
|
row, err := s.db.ReadQueries.GetDownload(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", "", fmt.Errorf("%w: request %s", ErrNotFound, id)
|
return "", "", fmt.Errorf("%w: download %s", ErrNotFound, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", "", fmt.Errorf("get download request state: %w", err)
|
return "", "", fmt.Errorf("get download state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return State(row.State), row.Error, nil
|
return State(row.State), row.Error, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRequests returns the most recent requests, newest first.
|
// ListDownloads returns the most recent downloads, newest first.
|
||||||
func (s *Store) ListRequests(ctx context.Context, limit int) ([]Request, error) {
|
func (s *Store) ListDownloads(ctx context.Context, limit int) ([]Download, error) {
|
||||||
rows, err := s.db.ReadQueries.ListDownloadRequests(ctx, int64(limit))
|
rows, err := s.db.ReadQueries.ListDownloads(ctx, int64(limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list download requests: %w", err)
|
return nil, fmt.Errorf("list downloads: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]Request, 0, len(rows))
|
out := make([]Download, 0, len(rows))
|
||||||
|
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
out = append(out, requestRowToRequest(r))
|
out = append(out, downloadRowToDownload(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRequestState updates a request's state and error text.
|
// SetDownloadState updates a download's state and error text.
|
||||||
func (s *Store) SetRequestState(
|
func (s *Store) SetDownloadState(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
id string,
|
id string,
|
||||||
state State,
|
state State,
|
||||||
errText string,
|
errText string,
|
||||||
) error {
|
) error {
|
||||||
if err := s.db.Queries.SetDownloadRequestState(
|
if err := s.db.Queries.SetDownloadState(
|
||||||
ctx,
|
ctx,
|
||||||
sqlcgen.SetDownloadRequestStateParams{
|
sqlcgen.SetDownloadStateParams{
|
||||||
State: string(state),
|
State: string(state),
|
||||||
Error: errText,
|
Error: errText,
|
||||||
ID: id,
|
ID: id,
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("set download request state: %w", err)
|
return fmt.Errorf("set download state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteRequest removes a request and, by cascade, its items.
|
// DeleteDownload removes a download and, by cascade, its items.
|
||||||
func (s *Store) DeleteRequest(ctx context.Context, id string) error {
|
func (s *Store) DeleteDownload(ctx context.Context, id string) error {
|
||||||
if err := s.db.Queries.DeleteDownloadRequest(ctx, id); err != nil {
|
if err := s.db.Queries.DeleteDownload(ctx, id); err != nil {
|
||||||
return fmt.Errorf("delete download request: %w", err)
|
return fmt.Errorf("delete download: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearFinished removes every terminal request.
|
// ClearFinished removes every terminal download.
|
||||||
func (s *Store) ClearFinished(ctx context.Context) error {
|
func (s *Store) ClearFinished(ctx context.Context) error {
|
||||||
if err := s.db.Queries.DeleteFinishedDownloadRequests(ctx); err != nil {
|
if err := s.db.Queries.DeleteFinishedDownloads(ctx); err != nil {
|
||||||
return fmt.Errorf("clear finished download requests: %w", err)
|
return fmt.Errorf("clear finished downloads: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestRowToRequest decodes a stored request row.
|
// downloadRowToDownload decodes a stored download row.
|
||||||
func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
|
func downloadRowToDownload(r sqlcgen.DownloadDownload) Download {
|
||||||
var expected []ExpectedTrack
|
var expected []ExpectedTrack
|
||||||
|
|
||||||
_ = json.Unmarshal([]byte(r.Expected), &expected)
|
_ = json.Unmarshal([]byte(r.Expected), &expected)
|
||||||
|
|
||||||
return Request{
|
return Download{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
LibraryID: r.LibraryID,
|
LibraryID: r.LibraryID,
|
||||||
Source: r.Source,
|
Source: r.Source,
|
||||||
WantID: r.WantID.Int64,
|
RequestID: r.RequestID.Int64,
|
||||||
ReleaseMBID: r.ReleaseMbid.String,
|
ReleaseMBID: r.ReleaseMbid.String,
|
||||||
ReleaseGroupMBID: r.ReleaseGroupMbid.String,
|
ReleaseGroupMBID: r.ReleaseGroupMbid.String,
|
||||||
RecordingMBID: r.RecordingMbid.String,
|
RecordingMBID: r.RecordingMbid.String,
|
||||||
@@ -294,10 +294,16 @@ func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
|
|||||||
// Items
|
// Items
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Item is one grab attempt, as stored.
|
// DownloadItem is one grab attempt, as stored.
|
||||||
type Item struct {
|
//
|
||||||
|
// deliberate: distinguishes it from download.Download (the attempt) and
|
||||||
|
// download.Request (the durable record) at every call site, which a bare
|
||||||
|
// "Item" would not.
|
||||||
|
//
|
||||||
|
//nolint:revive // stutters as download.DownloadItem, but the name is
|
||||||
|
type DownloadItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
RequestID string `json:"requestId"`
|
DownloadID string `json:"downloadId"`
|
||||||
ProviderID int64 `json:"providerId"`
|
ProviderID int64 `json:"providerId"`
|
||||||
Transport int64 `json:"transportId,omitempty"`
|
Transport int64 `json:"transportId,omitempty"`
|
||||||
ExternalID string `json:"externalId,omitempty"`
|
ExternalID string `json:"externalId,omitempty"`
|
||||||
@@ -313,7 +319,7 @@ type Item struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateItem persists a grab attempt.
|
// CreateItem persists a grab attempt.
|
||||||
func (s *Store) CreateItem(ctx context.Context, item Item) error {
|
func (s *Store) CreateItem(ctx context.Context, item DownloadItem) error {
|
||||||
candidate, err := json.Marshal(item.Candidate)
|
candidate, err := json.Marshal(item.Candidate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode candidate: %w", err)
|
return fmt.Errorf("encode candidate: %w", err)
|
||||||
@@ -328,7 +334,7 @@ func (s *Store) CreateItem(ctx context.Context, item Item) error {
|
|||||||
ctx,
|
ctx,
|
||||||
sqlcgen.CreateDownloadItemParams{
|
sqlcgen.CreateDownloadItemParams{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
RequestID: item.RequestID,
|
DownloadID: item.DownloadID,
|
||||||
ProviderID: item.ProviderID,
|
ProviderID: item.ProviderID,
|
||||||
TransportID: transport,
|
TransportID: transport,
|
||||||
ExternalID: item.ExternalID,
|
ExternalID: item.ExternalID,
|
||||||
@@ -345,30 +351,31 @@ func (s *Store) CreateItem(ctx context.Context, item Item) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetItem loads one item.
|
// GetItem loads one item.
|
||||||
func (s *Store) GetItem(ctx context.Context, id string) (Item, error) {
|
func (s *Store) GetItem(ctx context.Context, id string) (DownloadItem, error) {
|
||||||
row, err := s.db.ReadQueries.GetDownloadItem(ctx, id)
|
row, err := s.db.ReadQueries.GetDownloadItem(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Item{}, fmt.Errorf("%w: item %s", ErrNotFound, id)
|
return DownloadItem{}, fmt.Errorf("%w: item %s", ErrNotFound, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Item{}, fmt.Errorf("get download item: %w", err)
|
return DownloadItem{}, fmt.Errorf("get download item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return itemRowToItem(row), nil
|
return itemRowToItem(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListItemsForRequest returns a request's grab attempts, oldest first.
|
// ListItemsForDownload returns a download's grab attempts, oldest
|
||||||
func (s *Store) ListItemsForRequest(
|
// first.
|
||||||
|
func (s *Store) ListItemsForDownload(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
requestID string,
|
downloadID string,
|
||||||
) ([]Item, error) {
|
) ([]DownloadItem, error) {
|
||||||
rows, err := s.db.ReadQueries.ListDownloadItemsForRequest(ctx, requestID)
|
rows, err := s.db.ReadQueries.ListDownloadItemsForDownload(ctx, downloadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list download items: %w", err)
|
return nil, fmt.Errorf("list download items: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]Item, 0, len(rows))
|
out := make([]DownloadItem, 0, len(rows))
|
||||||
|
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
out = append(out, itemRowToItem(r))
|
out = append(out, itemRowToItem(r))
|
||||||
@@ -379,13 +386,13 @@ func (s *Store) ListItemsForRequest(
|
|||||||
|
|
||||||
// ListLiveItems returns every non-terminal item. Called at startup to
|
// ListLiveItems returns every non-terminal item. Called at startup to
|
||||||
// decide what to resume, reconcile or abandon.
|
// decide what to resume, reconcile or abandon.
|
||||||
func (s *Store) ListLiveItems(ctx context.Context) ([]Item, error) {
|
func (s *Store) ListLiveItems(ctx context.Context) ([]DownloadItem, error) {
|
||||||
rows, err := s.db.ReadQueries.ListLiveDownloadItems(ctx)
|
rows, err := s.db.ReadQueries.ListLiveDownloadItems(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list live download items: %w", err)
|
return nil, fmt.Errorf("list live download items: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]Item, 0, len(rows))
|
out := make([]DownloadItem, 0, len(rows))
|
||||||
|
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
out = append(out, itemRowToItem(r))
|
out = append(out, itemRowToItem(r))
|
||||||
@@ -479,7 +486,7 @@ func (s *Store) SetItemImported(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// itemRowToItem decodes a stored item row.
|
// itemRowToItem decodes a stored item row.
|
||||||
func itemRowToItem(r sqlcgen.DownloadItem) Item {
|
func itemRowToItem(r sqlcgen.DownloadItem) DownloadItem {
|
||||||
var (
|
var (
|
||||||
candidate Candidate
|
candidate Candidate
|
||||||
imported []string
|
imported []string
|
||||||
@@ -488,9 +495,9 @@ func itemRowToItem(r sqlcgen.DownloadItem) Item {
|
|||||||
_ = json.Unmarshal([]byte(r.Candidate), &candidate)
|
_ = json.Unmarshal([]byte(r.Candidate), &candidate)
|
||||||
_ = json.Unmarshal([]byte(r.ImportedPaths), &imported)
|
_ = json.Unmarshal([]byte(r.ImportedPaths), &imported)
|
||||||
|
|
||||||
return Item{
|
return DownloadItem{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
RequestID: r.RequestID,
|
DownloadID: r.DownloadID,
|
||||||
ProviderID: r.ProviderID,
|
ProviderID: r.ProviderID,
|
||||||
Transport: r.TransportID.Int64,
|
Transport: r.TransportID.Int64,
|
||||||
ExternalID: r.ExternalID,
|
ExternalID: r.ExternalID,
|
||||||
|
|||||||
+33
-25
@@ -82,29 +82,36 @@ func (c Caps) Handles(p Protocol) bool {
|
|||||||
return slices.Contains(c.Transports, p)
|
return slices.Contains(c.Transports, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request is what the user asked for. Requests that carry a MusicBrainz
|
// Download is one search-and-grab attempt: it searches, it grabs, it
|
||||||
// anchor are far more reliable than free-text ones, because the anchor
|
// succeeds or fails, and then it is history. A Download that carries a
|
||||||
// gives the import step an expected tracklist to match against — so the
|
// MusicBrainz anchor is far more reliable than a free-text one, because
|
||||||
// pipeline records which it got and refuses to auto-pick without one.
|
// the anchor gives the import step an expected tracklist to match
|
||||||
type Request struct {
|
// against — so the pipeline records which it got and refuses to
|
||||||
|
// auto-pick without one.
|
||||||
|
//
|
||||||
|
// A Download is not the same thing as a Request (request.go): a
|
||||||
|
// Request is durable and outlives every attempt made on its behalf,
|
||||||
|
// while a Download is one such attempt and is disposable.
|
||||||
|
type Download struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
||||||
// Anchors. Any may be empty; all empty means free-text.
|
// Anchors. Any may be empty; all empty means free-text.
|
||||||
ReleaseMBID string `json:"releaseMbid,omitempty"`
|
ReleaseMBID string `json:"releaseMbid,omitempty"`
|
||||||
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
|
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
|
||||||
|
|
||||||
// RecordingMBID anchors a single-track request. Its Expected holds
|
// RecordingMBID anchors a single-track download. Its Expected holds
|
||||||
// exactly that one track, which is what lets a track request be
|
// exactly that one track, which is what lets a track download be
|
||||||
// scored — and therefore auto-picked — on the same footing as an
|
// scored — and therefore auto-picked — on the same footing as an
|
||||||
// album.
|
// album.
|
||||||
RecordingMBID string `json:"recordingMbid,omitempty"`
|
RecordingMBID string `json:"recordingMbid,omitempty"`
|
||||||
|
|
||||||
// WantID links back to the wanted-list row this request was raised
|
// RequestID links back to the durable Request row this download was
|
||||||
// for, or 0 for a request the user started by hand. The reconciler
|
// raised for or attached to, or 0 for a free-text download with
|
||||||
// writes the outcome back through it.
|
// nothing stable to attach to. The reconciler and manual anchored
|
||||||
WantID int64 `json:"wantId,omitempty"`
|
// downloads both write the outcome back through it.
|
||||||
|
RequestID int64 `json:"requestId,omitempty"`
|
||||||
|
|
||||||
// Source records where the request came from, for the downloads
|
// Source records where the download came from, for the downloads
|
||||||
// list. Empty means "manual".
|
// list. Empty means "manual".
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
|
|
||||||
@@ -116,7 +123,7 @@ type Request struct {
|
|||||||
|
|
||||||
// Expected is the tracklist the anchor resolves to, used for
|
// Expected is the tracklist the anchor resolves to, used for
|
||||||
// completeness scoring and for the autotag match at import. Empty
|
// completeness scoring and for the autotag match at import. Empty
|
||||||
// for free-text requests.
|
// for free-text downloads.
|
||||||
Expected []ExpectedTrack `json:"expected,omitempty"`
|
Expected []ExpectedTrack `json:"expected,omitempty"`
|
||||||
|
|
||||||
// LibraryID is the library imported files belong to.
|
// LibraryID is the library imported files belong to.
|
||||||
@@ -125,12 +132,12 @@ type Request struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchored reports whether the request carries a MusicBrainz ID. Only
|
// Anchored reports whether the download carries a MusicBrainz ID. Only
|
||||||
// anchored requests are eligible for auto-pick.
|
// anchored downloads are eligible for auto-pick.
|
||||||
func (r Request) Anchored() bool {
|
func (d Download) Anchored() bool {
|
||||||
return r.ReleaseMBID != "" ||
|
return d.ReleaseMBID != "" ||
|
||||||
r.ReleaseGroupMBID != "" ||
|
d.ReleaseGroupMBID != "" ||
|
||||||
r.RecordingMBID != ""
|
d.RecordingMBID != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchText returns the string to hand a provider's search endpoint.
|
// SearchText returns the string to hand a provider's search endpoint.
|
||||||
@@ -142,16 +149,16 @@ func (r Request) Anchored() bool {
|
|||||||
// return zero results on providers that expect every term to appear
|
// return zero results on providers that expect every term to appear
|
||||||
// in a match (Soulseek in particular), so the artist is dropped when
|
// in a match (Soulseek in particular), so the artist is dropped when
|
||||||
// the album title already leads with it.
|
// the album title already leads with it.
|
||||||
func (r Request) SearchText() string {
|
func (d Download) SearchText() string {
|
||||||
if r.Query != "" {
|
if d.Query != "" {
|
||||||
return r.Query
|
return d.Query
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Artist != "" && albumLeadsWithArtist(r.Artist, r.Album) {
|
if d.Artist != "" && albumLeadsWithArtist(d.Artist, d.Album) {
|
||||||
return strings.TrimSpace(r.Album)
|
return strings.TrimSpace(d.Album)
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(r.Artist + " " + r.Album)
|
return strings.TrimSpace(d.Artist + " " + d.Album)
|
||||||
}
|
}
|
||||||
|
|
||||||
// albumLeadsWithArtist reports whether album starts with artist as a
|
// albumLeadsWithArtist reports whether album starts with artist as a
|
||||||
@@ -295,6 +302,7 @@ type QualityScore struct {
|
|||||||
Bitrate float64 `json:"bitrate"`
|
Bitrate float64 `json:"bitrate"`
|
||||||
Health float64 `json:"health"` // seeders, free slots
|
Health float64 `json:"health"` // seeders, free slots
|
||||||
Priority float64 `json:"priority"` // user's per-provider preference
|
Priority float64 `json:"priority"` // user's per-provider preference
|
||||||
|
SizeFit float64 `json:"sizeFit"` // closeness to the preferred download size
|
||||||
|
|
||||||
// Mixed marks a candidate whose files are not all the same format,
|
// Mixed marks a candidate whose files are not all the same format,
|
||||||
// which usually means a hand-assembled folder rather than a rip.
|
// which usually means a hand-assembled folder rather than a rip.
|
||||||
|
|||||||
@@ -2,52 +2,52 @@ package download
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestRequest_SearchText(t *testing.T) {
|
func TestDownload_SearchText(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
req Request
|
dl Download
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "query overrides everything",
|
name: "query overrides everything",
|
||||||
req: Request{Artist: "Blank Banshee", Album: "0", Query: "raw text"},
|
dl: Download{Artist: "Blank Banshee", Album: "0", Query: "raw text"},
|
||||||
want: "raw text",
|
want: "raw text",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ordinary album keeps artist and album",
|
name: "ordinary album keeps artist and album",
|
||||||
req: Request{Artist: "Pink Floyd", Album: "The Wall"},
|
dl: Download{Artist: "Pink Floyd", Album: "The Wall"},
|
||||||
want: "Pink Floyd The Wall",
|
want: "Pink Floyd The Wall",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "album title leads with artist name",
|
name: "album title leads with artist name",
|
||||||
req: Request{Artist: "Blank Banshee", Album: "Blank Banshee 0"},
|
dl: Download{Artist: "Blank Banshee", Album: "Blank Banshee 0"},
|
||||||
want: "Blank Banshee 0",
|
want: "Blank Banshee 0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "self-titled album",
|
name: "self-titled album",
|
||||||
req: Request{Artist: "Boston", Album: "Boston"},
|
dl: Download{Artist: "Boston", Album: "Boston"},
|
||||||
want: "Boston",
|
want: "Boston",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "artist name as a substring, not a word prefix",
|
name: "artist name as a substring, not a word prefix",
|
||||||
req: Request{Artist: "Air", Album: "Repair"},
|
dl: Download{Artist: "Air", Album: "Repair"},
|
||||||
want: "Air Repair",
|
want: "Air Repair",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "case-insensitive match",
|
name: "case-insensitive match",
|
||||||
req: Request{Artist: "blank banshee", Album: "BLANK BANSHEE 0"},
|
dl: Download{Artist: "blank banshee", Album: "BLANK BANSHEE 0"},
|
||||||
want: "BLANK BANSHEE 0",
|
want: "BLANK BANSHEE 0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no artist",
|
name: "no artist",
|
||||||
req: Request{Album: "Compilation"},
|
dl: Download{Album: "Compilation"},
|
||||||
want: "Compilation",
|
want: "Compilation",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if got := tt.req.SearchText(); got != tt.want {
|
if got := tt.dl.SearchText(); got != tt.want {
|
||||||
t.Errorf("SearchText() = %q, want %q", got, tt.want)
|
t.Errorf("SearchText() = %q, want %q", got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.
|
// open download picker re-read the provider list.
|
||||||
DownloadProvidersChanged = "DownloadProvidersChanged"
|
DownloadProvidersChanged = "DownloadProvidersChanged"
|
||||||
|
|
||||||
// DownloadsChanged fires when the set of download requests changes
|
// DownloadsChanged fires when the set of downloads changes (started,
|
||||||
// (started, picked, cancelled, cleared). Per-transfer progress does
|
// picked, cancelled, cleared). Per-transfer progress does not use
|
||||||
// not use this — it flows through the jobs registry's JobsChanged,
|
// this — it flows through the jobs registry's JobsChanged, which
|
||||||
// which already coalesces high-frequency updates.
|
// already coalesces high-frequency updates.
|
||||||
DownloadsChanged = "DownloadsChanged"
|
DownloadsChanged = "DownloadsChanged"
|
||||||
|
|
||||||
// WantedListChanged fires when the wanted list gains, loses or
|
// RequestsChanged fires when the request list gains, loses or
|
||||||
// retires an entry — including from a background reconcile pass,
|
// retires an entry — including from a background reconcile pass,
|
||||||
// which is why the list is event-driven rather than fetched once on
|
// which is why the list is event-driven rather than fetched once on
|
||||||
// mount.
|
// mount.
|
||||||
WantedListChanged = "WantedListChanged"
|
RequestsChanged = "RequestsChanged"
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ import '@components/autotag-view/autotag-view.ts';
|
|||||||
import '@components/first-run-wizard/first-run-wizard.ts';
|
import '@components/first-run-wizard/first-run-wizard.ts';
|
||||||
import '@components/jobs/job-indicator.ts';
|
import '@components/jobs/job-indicator.ts';
|
||||||
import '@components/jobs/jobs-view.ts';
|
import '@components/jobs/jobs-view.ts';
|
||||||
import '@components/wanted-view/wanted-view.ts';
|
import '@components/downloads-view/downloads-view.ts';
|
||||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||||
@@ -64,7 +64,7 @@ const VIEW_TAGS: Record<string, string> = {
|
|||||||
playlists: 'playlist-view',
|
playlists: 'playlist-view',
|
||||||
explore: 'explore-view',
|
explore: 'explore-view',
|
||||||
autotag: 'autotag-view',
|
autotag: 'autotag-view',
|
||||||
wanted: 'wanted-view',
|
downloads: 'downloads-view',
|
||||||
jobs: 'jobs-view',
|
jobs: 'jobs-view',
|
||||||
settings: 'config-page',
|
settings: 'config-page',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,8 +15,28 @@ import type {
|
|||||||
} from '@store/download-store';
|
} from '@store/download-store';
|
||||||
import { downloadStore } from '@store/download-store';
|
import { downloadStore } from '@store/download-store';
|
||||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||||
|
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/Config';
|
||||||
|
import { SetPreferences } from '@go/download/Service';
|
||||||
|
import type { download } from '@go/models';
|
||||||
import './config-section';
|
import './config-section';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed audio formats for auto-download, mirrored from
|
||||||
|
* backend/download/types.go's `Format` constants. `FormatUnknown` is
|
||||||
|
* deliberately excluded — it names "no format detected", not a format a
|
||||||
|
* user could opt into.
|
||||||
|
*/
|
||||||
|
const AUTO_DOWNLOAD_FORMATS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'flac', label: 'FLAC' },
|
||||||
|
{ value: 'alac', label: 'ALAC' },
|
||||||
|
{ value: 'wav', label: 'WAV' },
|
||||||
|
{ value: 'mp3', label: 'MP3' },
|
||||||
|
{ value: 'aac', label: 'AAC' },
|
||||||
|
{ value: 'ogg', label: 'OGG' },
|
||||||
|
{ value: 'opus', label: 'Opus' },
|
||||||
|
{ value: 'wma', label: 'WMA' },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download client configuration.
|
* Download client configuration.
|
||||||
*
|
*
|
||||||
@@ -59,6 +79,24 @@ export class DownloadClients extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private errorMessage = '';
|
private errorMessage = '';
|
||||||
|
|
||||||
|
/** Working copy of the auto-download guardrails. */
|
||||||
|
@state()
|
||||||
|
private prefs: download.AutoDownloadPrefs = {
|
||||||
|
minSizeMb: 0,
|
||||||
|
maxSizeMb: 0,
|
||||||
|
preferredSizeMb: 0,
|
||||||
|
allowedFormats: [],
|
||||||
|
} as download.AutoDownloadPrefs;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private prefsSaving = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private prefsError = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private prefsSaved = false;
|
||||||
|
|
||||||
private unsubscribe: (() => void) | null = null;
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
@@ -67,6 +105,7 @@ export class DownloadClients extends LitElement {
|
|||||||
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
|
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
|
||||||
|
|
||||||
void downloadStore.init().then(() => this.syncFromStore());
|
void downloadStore.init().then(() => this.syncFromStore());
|
||||||
|
void this.loadPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
@@ -81,6 +120,14 @@ export class DownloadClients extends LitElement {
|
|||||||
this.descriptors = downloadStore.descriptors;
|
this.descriptors = downloadStore.descriptors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadPreferences(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.prefs = await GetDownloadPreferences();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load auto-download preferences:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static override styles = [
|
static override styles = [
|
||||||
designTokens,
|
designTokens,
|
||||||
css`
|
css`
|
||||||
@@ -178,6 +225,21 @@ export class DownloadClients extends LitElement {
|
|||||||
.field-row .browse-button {
|
.field-row .browse-button {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.format-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4em 1em;
|
||||||
|
margin-top: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -208,6 +270,104 @@ export class DownloadClients extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</config-section>
|
</config-section>
|
||||||
|
|
||||||
|
<config-section
|
||||||
|
heading="Auto-download preferences"
|
||||||
|
description="Guardrails on what the pipeline may grab without asking — a manual pick is never restricted by these, only automatic ones."
|
||||||
|
>
|
||||||
|
${this.prefsError
|
||||||
|
? html`<wa-callout variant="danger">${this.prefsError}</wa-callout>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
<div class="form">
|
||||||
|
<div class="field-row">
|
||||||
|
<wa-input
|
||||||
|
label="Minimum size (MB)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="No minimum"
|
||||||
|
.value=${this.prefs.minSizeMb ? String(this.prefs.minSizeMb) : ''}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
this.prefs = {
|
||||||
|
...this.prefs,
|
||||||
|
minSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
></wa-input>
|
||||||
|
<wa-input
|
||||||
|
label="Maximum size (MB)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="No maximum"
|
||||||
|
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
this.prefs = {
|
||||||
|
...this.prefs,
|
||||||
|
maxSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
></wa-input>
|
||||||
|
<wa-input
|
||||||
|
label="Preferred size (MB)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="No preference"
|
||||||
|
.value=${this.prefs.preferredSizeMb
|
||||||
|
? String(this.prefs.preferredSizeMb)
|
||||||
|
: ''}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
this.prefs = {
|
||||||
|
...this.prefs,
|
||||||
|
preferredSizeMb:
|
||||||
|
Number((e.target as HTMLInputElement).value) || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
></wa-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="requires">
|
||||||
|
Allowed formats — leave all unchecked to allow any format.
|
||||||
|
</div>
|
||||||
|
<div class="format-options">
|
||||||
|
${AUTO_DOWNLOAD_FORMATS.map(
|
||||||
|
(format) => html`
|
||||||
|
<label class="format-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
.checked=${(this.prefs.allowedFormats ?? []).includes(
|
||||||
|
format.value,
|
||||||
|
)}
|
||||||
|
@change=${(e: Event) =>
|
||||||
|
this.toggleFormat(
|
||||||
|
format.value,
|
||||||
|
(e.target as HTMLInputElement).checked,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
${format.label}
|
||||||
|
</label>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
${this.prefsSaved
|
||||||
|
? html`<span class="test-result ok">Saved.</span>`
|
||||||
|
: nothing}
|
||||||
|
<wa-button
|
||||||
|
size="small"
|
||||||
|
variant="brand"
|
||||||
|
?disabled=${this.prefsSaving}
|
||||||
|
@click=${this.savePreferences}
|
||||||
|
>
|
||||||
|
${this.prefsSaving
|
||||||
|
? html`<wa-spinner></wa-spinner>`
|
||||||
|
: 'Save preferences'}
|
||||||
|
</wa-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</config-section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +714,37 @@ export class DownloadClients extends LitElement {
|
|||||||
this.testing = null;
|
this.testing = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toggleFormat(format: string, checked: boolean): void {
|
||||||
|
const current = this.prefs.allowedFormats ?? [];
|
||||||
|
const allowedFormats = checked
|
||||||
|
? [...current, format]
|
||||||
|
: current.filter((f) => f !== format);
|
||||||
|
|
||||||
|
this.prefs = { ...this.prefs, allowedFormats };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the guardrails both to disk and to the running download
|
||||||
|
* manager in one action — persistence alone would leave the setting
|
||||||
|
* inert until restart, which is exactly the bug this mirrors away
|
||||||
|
* from (see `config.Library`'s prior persist-without-apply gap).
|
||||||
|
*/
|
||||||
|
private savePreferences = async () => {
|
||||||
|
this.prefsSaving = true;
|
||||||
|
this.prefsError = '';
|
||||||
|
this.prefsSaved = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await SetDownloadPreferences(this.prefs);
|
||||||
|
await SetPreferences(this.prefs);
|
||||||
|
this.prefsSaved = true;
|
||||||
|
} catch (err) {
|
||||||
|
this.prefsError = String(err);
|
||||||
|
} finally {
|
||||||
|
this.prefsSaving = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class DownloadPicker extends LitElement {
|
|||||||
private candidates: DownloadCandidate[] = [];
|
private candidates: DownloadCandidate[] = [];
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private requestId = '';
|
private downloadId = '';
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private autoPicked = false;
|
private autoPicked = false;
|
||||||
@@ -141,7 +141,7 @@ export class DownloadPicker extends LitElement {
|
|||||||
expected: this.expected ?? [],
|
expected: this.expected ?? [],
|
||||||
} as download.SearchRequest);
|
} as download.SearchRequest);
|
||||||
|
|
||||||
this.requestId = result.requestId;
|
this.downloadId = result.downloadId;
|
||||||
this.candidates = result.candidates ?? [];
|
this.candidates = result.candidates ?? [];
|
||||||
this.autoPicked = result.autoPicked;
|
this.autoPicked = result.autoPicked;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -158,7 +158,7 @@ export class DownloadPicker extends LitElement {
|
|||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadStore.pick(this.requestId, event.detail.candidateId);
|
await downloadStore.pick(this.downloadId, event.detail.candidateId);
|
||||||
this.close();
|
this.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.errorMessage = String(err);
|
this.errorMessage = String(err);
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
|
import { customElement, state } from 'lit/decorators.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
|
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||||
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import { downloadStore, stateLabel } from '@store/download-store';
|
||||||
|
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
|
||||||
|
import { libraryStore } from '@store/library-store';
|
||||||
|
|
||||||
|
type Tab = 'requests' | 'downloads';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The downloads page: what music the user has asked for, and what has
|
||||||
|
* actually been attempted.
|
||||||
|
*
|
||||||
|
* These are two different lists on purpose. A Request is durable — "get
|
||||||
|
* this whenever available" — and stays around, retried on a backoff,
|
||||||
|
* until it is satisfied or removed. A Download is one search-and-grab
|
||||||
|
* attempt; it can fail or complete and that is the end of its story. The
|
||||||
|
* Requests tab is the list the durable, not-a-failure-just-because-it's-
|
||||||
|
* still-here content the wanted list used to be; the Downloads tab is the
|
||||||
|
* attempt history nothing rendered before this page existed.
|
||||||
|
*/
|
||||||
|
@customElement('downloads-view')
|
||||||
|
export class DownloadsView extends LitElement {
|
||||||
|
@state() private tab: Tab = 'requests';
|
||||||
|
|
||||||
|
@state() private requests: Request[] = [];
|
||||||
|
|
||||||
|
@state() private downloads: DownloadRecord[] = [];
|
||||||
|
|
||||||
|
@state() private checking = false;
|
||||||
|
|
||||||
|
@state() private lastSummary: RequestSummary | null = null;
|
||||||
|
|
||||||
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
designTokens,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
border-bottom-color: var(--yj-accent, #ffd43b);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--yj-bg-surface, #181818);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row + .row {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--yj-text-tertiary, #888);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail.error {
|
||||||
|
color: var(--wa-color-danger-fill-loud, #c65f5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--yj-text-tertiary, #888);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
this.unsubscribe = downloadStore.subscribe(() => {
|
||||||
|
this.requests = downloadStore.requests;
|
||||||
|
this.downloads = downloadStore.downloads;
|
||||||
|
});
|
||||||
|
|
||||||
|
void downloadStore.init().then(() => {
|
||||||
|
this.requests = downloadStore.requests;
|
||||||
|
this.downloads = downloadStore.downloads;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
|
||||||
|
this.unsubscribe?.();
|
||||||
|
this.unsubscribe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<header>
|
||||||
|
<h1>Downloads</h1>
|
||||||
|
${this.tab === 'requests'
|
||||||
|
? html`
|
||||||
|
<wa-button
|
||||||
|
size="small"
|
||||||
|
appearance="outlined"
|
||||||
|
?disabled=${this.checking}
|
||||||
|
@click=${() => void this.checkNow()}
|
||||||
|
>
|
||||||
|
<wa-icon slot="start" name="rotate"></wa-icon>
|
||||||
|
${this.checking ? 'Checking…' : 'Check now'}
|
||||||
|
</wa-button>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="subtitle">
|
||||||
|
Music you have requested, and the download attempts that
|
||||||
|
have run for it. A request that cannot be found today stays
|
||||||
|
on the list and is looked for again later.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div
|
||||||
|
class="tab ${this.tab === 'requests' ? 'active' : ''}"
|
||||||
|
@click=${() => (this.tab = 'requests')}
|
||||||
|
>
|
||||||
|
Requests
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="tab ${this.tab === 'downloads' ? 'active' : ''}"
|
||||||
|
@click=${() => (this.tab = 'downloads')}
|
||||||
|
>
|
||||||
|
Downloads
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.tab === 'requests' ? this.renderRequests() : this.renderDownloads()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Requests tab
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
private renderRequests() {
|
||||||
|
const subscriptions = this.requests.filter((r) => r.entity === 'artist');
|
||||||
|
const wanted = this.requests.filter(
|
||||||
|
(r) => r.entity !== 'artist' && r.state === 'wanted',
|
||||||
|
);
|
||||||
|
const paused = this.requests.filter((r) => r.state === 'paused');
|
||||||
|
const satisfied = this.requests.filter((r) => r.state === 'satisfied');
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${this.renderSummary()}
|
||||||
|
${satisfied.length > 0
|
||||||
|
? html`
|
||||||
|
<div class="actions">
|
||||||
|
<wa-button
|
||||||
|
size="small"
|
||||||
|
appearance="plain"
|
||||||
|
@click=${() =>
|
||||||
|
void downloadStore.clearSatisfiedRequests()}
|
||||||
|
>
|
||||||
|
Clear found
|
||||||
|
</wa-button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${this.requests.length === 0 ? this.renderEmptyRequests() : nothing}
|
||||||
|
${this.renderRequestSection(
|
||||||
|
'Following',
|
||||||
|
subscriptions,
|
||||||
|
(r) => this.renderSubscription(r),
|
||||||
|
)}
|
||||||
|
${this.renderRequestSection('Looking for', wanted, (r) => this.renderRequest(r))}
|
||||||
|
${this.renderRequestSection('Paused', paused, (r) => this.renderRequest(r))}
|
||||||
|
${this.renderRequestSection('Found', satisfied, (r) => this.renderRequest(r))}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEmptyRequests() {
|
||||||
|
return html`
|
||||||
|
<div class="empty">
|
||||||
|
Nothing requested yet. Use “Want this” on an album or artist
|
||||||
|
to add it here.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSummary() {
|
||||||
|
if (!this.lastSummary) return nothing;
|
||||||
|
|
||||||
|
const s = this.lastSummary;
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
s.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
|
||||||
|
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
|
||||||
|
s.started > 0 ? `${s.started} downloading` : '',
|
||||||
|
s.attempted > 0 ? `${s.attempted} searched for` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<p class="summary">
|
||||||
|
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRequestSection(
|
||||||
|
title: string,
|
||||||
|
items: Request[],
|
||||||
|
renderer: (request: Request) => unknown,
|
||||||
|
) {
|
||||||
|
if (items.length === 0) return nothing;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<h2>${title}</h2>
|
||||||
|
${items.map((request) => renderer(request))}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An artist row is a subscription, not a queued download, so it
|
||||||
|
* shows what it covers rather than a retry count — the albums it
|
||||||
|
* produced appear in their own section.
|
||||||
|
*/
|
||||||
|
private renderSubscription(request: Request) {
|
||||||
|
return html`
|
||||||
|
<div class="row">
|
||||||
|
<wa-icon name="user-group"></wa-icon>
|
||||||
|
<div class="row-main">
|
||||||
|
<div class="title">
|
||||||
|
${request.artist || request.title || request.mbid}
|
||||||
|
</div>
|
||||||
|
<div class="detail">
|
||||||
|
${request.scope === 'all'
|
||||||
|
? 'Whole discography, plus new releases'
|
||||||
|
: 'New releases only'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge">Following</span>
|
||||||
|
<div class="actions">
|
||||||
|
<wa-button
|
||||||
|
size="small"
|
||||||
|
appearance="plain"
|
||||||
|
@click=${() => void this.toggleScope(request)}
|
||||||
|
>
|
||||||
|
${request.scope === 'all' ? 'New only' : 'Everything'}
|
||||||
|
</wa-button>
|
||||||
|
${this.renderRemove(request)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRequest(request: Request) {
|
||||||
|
return html`
|
||||||
|
<div class="row">
|
||||||
|
<wa-icon
|
||||||
|
name=${request.entity === 'recording' ? 'music' : 'compact-disc'}
|
||||||
|
></wa-icon>
|
||||||
|
<div class="row-main">
|
||||||
|
<div class="title">
|
||||||
|
${request.artist ? `${request.artist} — ` : ''}${request.title ||
|
||||||
|
request.mbid}
|
||||||
|
</div>
|
||||||
|
<div class="detail">${requestDetail(request)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
${request.state === 'satisfied'
|
||||||
|
? nothing
|
||||||
|
: html`
|
||||||
|
<wa-button
|
||||||
|
size="small"
|
||||||
|
appearance="plain"
|
||||||
|
@click=${() =>
|
||||||
|
void downloadStore.pauseRequest(
|
||||||
|
request.id,
|
||||||
|
request.state !== 'paused',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
${request.state === 'paused' ? 'Resume' : 'Pause'}
|
||||||
|
</wa-button>
|
||||||
|
`}
|
||||||
|
${this.renderRemove(request)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRemove(request: Request) {
|
||||||
|
return html`
|
||||||
|
<wa-button
|
||||||
|
size="small"
|
||||||
|
appearance="plain"
|
||||||
|
@click=${() => void downloadStore.removeRequest(request.id)}
|
||||||
|
>
|
||||||
|
<wa-icon name="xmark"></wa-icon>
|
||||||
|
</wa-button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widens or narrows what an artist subscription covers. */
|
||||||
|
private async toggleScope(request: Request): Promise<void> {
|
||||||
|
try {
|
||||||
|
const libraryId =
|
||||||
|
request.libraryId || (await libraryStore.getDefaultLibraryId());
|
||||||
|
if (!libraryId) {
|
||||||
|
console.error(
|
||||||
|
'Could not change what this subscription covers: no library available',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await downloadStore.addRequest({
|
||||||
|
mbid: request.mbid,
|
||||||
|
entity: 'artist',
|
||||||
|
libraryId,
|
||||||
|
artist: request.artist,
|
||||||
|
title: request.title,
|
||||||
|
scope: request.scope === 'all' ? 'future' : 'all',
|
||||||
|
secondary: request.secondary,
|
||||||
|
} as never);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Could not change what this subscription covers:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkNow(): Promise<void> {
|
||||||
|
this.checking = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.lastSummary = await downloadStore.reconcileRequests();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Could not check the requests list:', err);
|
||||||
|
} finally {
|
||||||
|
this.checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Downloads tab
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
private renderDownloads() {
|
||||||
|
if (this.downloads.length === 0) {
|
||||||
|
return html`
|
||||||
|
<div class="empty">
|
||||||
|
No downloads yet. Attempts made by "Download now" or the
|
||||||
|
background reconciler show up here.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.downloads.map((view) => this.renderDownload(view));
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDownload(view: DownloadRecord) {
|
||||||
|
const title = view.artist
|
||||||
|
? `${view.artist}${view.album ? ` — ${view.album}` : ''}`
|
||||||
|
: view.query || view.album || 'Untitled download';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="row">
|
||||||
|
<wa-icon name="compact-disc"></wa-icon>
|
||||||
|
<div class="row-main">
|
||||||
|
<div class="title">${title}</div>
|
||||||
|
<div class="detail">${this.downloadDetail(view)}</div>
|
||||||
|
${view.error
|
||||||
|
? html`<div class="detail error">${view.error}</div>`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
<span class="badge">${stateLabel(view.state)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider/progress summary for a download's second line. */
|
||||||
|
private downloadDetail(view: DownloadRecord): string {
|
||||||
|
const providers = [
|
||||||
|
...new Set(view.items.map((item) => item.candidate?.origin).filter(Boolean)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (providers.length > 0) parts.push(providers.join(', '));
|
||||||
|
if (view.source) parts.push(view.source);
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(' · ') : 'No provider info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second line of a request row: what is happening, in the user's
|
||||||
|
* terms.
|
||||||
|
*
|
||||||
|
* A request that has been tried and not found is reported as still being
|
||||||
|
* looked for rather than as an error, because that is what it is — the
|
||||||
|
* retry is already scheduled and there is nothing for the user to do.
|
||||||
|
*/
|
||||||
|
function requestDetail(request: Request): string {
|
||||||
|
if (request.state === 'satisfied') return 'In your library';
|
||||||
|
if (request.state === 'paused') return 'Paused';
|
||||||
|
|
||||||
|
if (request.attempts === 0) return 'Not looked for yet';
|
||||||
|
|
||||||
|
const reason = request.lastError ? ` — ${request.lastError}` : '';
|
||||||
|
|
||||||
|
return `Looked for ${request.attempts} time${request.attempts === 1 ? '' : 's'}${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'downloads-view': DownloadsView;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,11 +120,11 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
/** True once a download client is configured and enabled. */
|
/** True once a download client is configured and enabled. */
|
||||||
@state() private canDownload = false;
|
@state() private canDownload = false;
|
||||||
|
|
||||||
/** True when this album is already on the wanted list. */
|
/** True when this album already has a request. */
|
||||||
@state() private isWanted = false;
|
@state() private isRequested = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library to attach downloads/wants to. The library-filter UI that
|
* Library to attach downloads/requests to. The library-filter UI that
|
||||||
* would normally set libraryStore's selection isn't mounted anywhere
|
* would normally set libraryStore's selection isn't mounted anywhere
|
||||||
* currently, so that selection is always null here — falling back to
|
* currently, so that selection is always null here — falling back to
|
||||||
* `?? 0` would send a library id that doesn't exist and fail the
|
* `?? 0` would send a library id that doesn't exist and fail the
|
||||||
@@ -496,12 +496,12 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
// so this tracks the provider list rather than assuming.
|
// so this tracks the provider list rather than assuming.
|
||||||
this.downloadUnsub = downloadStore.subscribe(() => {
|
this.downloadUnsub = downloadStore.subscribe(() => {
|
||||||
this.canDownload = downloadStore.available;
|
this.canDownload = downloadStore.available;
|
||||||
this.syncWanted();
|
this.syncRequested();
|
||||||
});
|
});
|
||||||
|
|
||||||
void downloadStore.init().then(() => {
|
void downloadStore.init().then(() => {
|
||||||
this.canDownload = downloadStore.available;
|
this.canDownload = downloadStore.available;
|
||||||
this.syncWanted();
|
this.syncRequested();
|
||||||
});
|
});
|
||||||
|
|
||||||
void this.resolveTargetLibraryId();
|
void this.resolveTargetLibraryId();
|
||||||
@@ -1537,62 +1537,62 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds the album to the wanted list, which is the answer to "look
|
* Adds the album to the requests list, which is the answer to "look
|
||||||
* for it, but not right now".
|
* for it, but not right now".
|
||||||
*
|
*
|
||||||
* Unlike the download button this shows whether or not a client is
|
* Unlike the download button this shows whether or not a client is
|
||||||
* connected: wanting something is a durable statement about the
|
* connected: requesting something is a durable statement about the
|
||||||
* library, and it stays true — and stays queued — until a client
|
* library, and it stays true — and stays queued — until a client
|
||||||
* exists to act on it.
|
* exists to act on it.
|
||||||
*/
|
*/
|
||||||
private renderWantAction() {
|
private renderWantAction() {
|
||||||
if (!this.releaseGroupMBID) return nothing;
|
if (!this.releaseGroupMBID) return nothing;
|
||||||
|
|
||||||
const want = downloadStore.wantFor(this.releaseGroupMBID);
|
const request = downloadStore.requestFor(this.releaseGroupMBID);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<wa-button
|
<wa-button
|
||||||
size="small"
|
size="small"
|
||||||
appearance=${this.isWanted ? 'filled' : 'outlined'}
|
appearance=${this.isRequested ? 'filled' : 'outlined'}
|
||||||
@click=${() => void this.toggleWanted(want?.id)}
|
@click=${() => void this.toggleRequested(request?.id)}
|
||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="start"
|
slot="start"
|
||||||
name=${this.isWanted ? 'bookmark-check' : 'bookmark'}
|
name=${this.isRequested ? 'bookmark-check' : 'bookmark'}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
${this.isWanted ? 'Wanted' : 'Want this'}
|
${this.isRequested ? 'Wanted' : 'Want this'}
|
||||||
</wa-button>
|
</wa-button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves the library to attach downloads/wants to. */
|
/** Resolves the library to attach downloads/requests to. */
|
||||||
private async resolveTargetLibraryId(): Promise<void> {
|
private async resolveTargetLibraryId(): Promise<void> {
|
||||||
this.targetLibraryId = await libraryStore.getDefaultLibraryId();
|
this.targetLibraryId = await libraryStore.getDefaultLibraryId();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reflects the store's view of whether this album is wanted. */
|
/** Reflects the store's view of whether this album is requested. */
|
||||||
private syncWanted(): void {
|
private syncRequested(): void {
|
||||||
this.isWanted = this.releaseGroupMBID
|
this.isRequested = this.releaseGroupMBID
|
||||||
? downloadStore.isWanted(this.releaseGroupMBID)
|
? downloadStore.isRequested(this.releaseGroupMBID)
|
||||||
: false;
|
: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async toggleWanted(wantId: number | undefined): Promise<void> {
|
private async toggleRequested(requestId: number | undefined): Promise<void> {
|
||||||
if (!this.releaseGroupMBID) return;
|
if (!this.releaseGroupMBID) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (wantId) {
|
if (requestId) {
|
||||||
await downloadStore.removeWant(wantId);
|
await downloadStore.removeRequest(requestId);
|
||||||
} else {
|
} else {
|
||||||
if (!this.targetLibraryId) {
|
if (!this.targetLibraryId) {
|
||||||
await this.resolveTargetLibraryId();
|
await this.resolveTargetLibraryId();
|
||||||
}
|
}
|
||||||
if (!this.targetLibraryId) {
|
if (!this.targetLibraryId) {
|
||||||
console.error('Could not update the wanted list: no library available');
|
console.error('Could not update the requests list: no library available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await downloadStore.addWant({
|
await downloadStore.addRequest({
|
||||||
mbid: this.releaseGroupMBID,
|
mbid: this.releaseGroupMBID,
|
||||||
entity: 'release-group',
|
entity: 'release-group',
|
||||||
libraryId: this.targetLibraryId,
|
libraryId: this.targetLibraryId,
|
||||||
@@ -1600,13 +1600,13 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
title: this.albumName,
|
title: this.albumName,
|
||||||
scope: 'future',
|
scope: 'future',
|
||||||
secondary: false,
|
secondary: false,
|
||||||
} as download.WantRequest);
|
} as download.RequestInput);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Could not update the wanted list:', err);
|
console.error('Could not update the requests list:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncWanted();
|
this.syncRequested();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openPicker(): Promise<void> {
|
private async openPicker(): Promise<void> {
|
||||||
|
|||||||
@@ -846,8 +846,8 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
* background discography fetch never signals readiness. */
|
* background discography fetch never signals readiness. */
|
||||||
private discogFallbackTimer?: number;
|
private discogFallbackTimer?: number;
|
||||||
|
|
||||||
/** Unsubscribe handle for the wanted list. */
|
/** Unsubscribe handle for the requests list. */
|
||||||
private unsubWanted: (() => void) | null = null;
|
private unsubRequests: (() => void) | null = null;
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
@@ -855,10 +855,10 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
void this.loadAllData();
|
void this.loadAllData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the follow button in step with the wanted list, which a
|
// Keep the follow button in step with the requests list, which a
|
||||||
// background reconcile pass can change without this page doing
|
// background reconcile pass can change without this page doing
|
||||||
// anything.
|
// anything.
|
||||||
this.unsubWanted = downloadStore.subscribe(() => this.requestUpdate());
|
this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate());
|
||||||
void downloadStore.init().then(() => this.requestUpdate());
|
void downloadStore.init().then(() => this.requestUpdate());
|
||||||
|
|
||||||
// A background discography fetch (top tracks / top releases for an
|
// A background discography fetch (top tracks / top releases for an
|
||||||
@@ -897,8 +897,8 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.unsubWanted?.();
|
this.unsubRequests?.();
|
||||||
this.unsubWanted = null;
|
this.unsubRequests = null;
|
||||||
this.unsubDiscogReady?.();
|
this.unsubDiscogReady?.();
|
||||||
this.unsubSimilarReady?.();
|
this.unsubSimilarReady?.();
|
||||||
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
|
||||||
@@ -1896,50 +1896,50 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes to an artist: their new releases go on the wanted list
|
* Subscribes to an artist: their new releases go on the requests
|
||||||
* as they come out.
|
* list as they come out.
|
||||||
*
|
*
|
||||||
* The default is new releases only. Following an artist should not
|
* The default is new releases only. Following an artist should not
|
||||||
* silently queue forty albums — someone who wants the back
|
* silently queue forty albums — someone who wants the back
|
||||||
* catalogue can widen it from the wanted list, and will not be
|
* catalogue can widen it from the requests list, and will not be
|
||||||
* surprised by having done so.
|
* surprised by having done so.
|
||||||
*/
|
*/
|
||||||
private renderFollowAction() {
|
private renderFollowAction() {
|
||||||
if (!this.artistMBID) return nothing;
|
if (!this.artistMBID) return nothing;
|
||||||
|
|
||||||
const want = downloadStore.wantFor(this.artistMBID);
|
const request = downloadStore.requestFor(this.artistMBID);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="artist-follow">
|
<div class="artist-follow">
|
||||||
<wa-button
|
<wa-button
|
||||||
size="small"
|
size="small"
|
||||||
appearance=${want ? 'filled' : 'outlined'}
|
appearance=${request ? 'filled' : 'outlined'}
|
||||||
@click=${() => void this.toggleFollow(want?.id)}
|
@click=${() => void this.toggleFollow(request?.id)}
|
||||||
>
|
>
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="start"
|
slot="start"
|
||||||
name=${want ? 'bookmark-check' : 'bookmark'}
|
name=${request ? 'bookmark-check' : 'bookmark'}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
${want ? 'Following' : 'Follow for new releases'}
|
${request ? 'Following' : 'Follow for new releases'}
|
||||||
</wa-button>
|
</wa-button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async toggleFollow(wantId: number | undefined): Promise<void> {
|
private async toggleFollow(requestId: number | undefined): Promise<void> {
|
||||||
if (!this.artistMBID) return;
|
if (!this.artistMBID) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (wantId) {
|
if (requestId) {
|
||||||
await downloadStore.removeWant(wantId);
|
await downloadStore.removeRequest(requestId);
|
||||||
} else {
|
} else {
|
||||||
const libraryId = await libraryStore.getDefaultLibraryId();
|
const libraryId = await libraryStore.getDefaultLibraryId();
|
||||||
if (!libraryId) {
|
if (!libraryId) {
|
||||||
console.error('Could not update the wanted list: no library available');
|
console.error('Could not update the requests list: no library available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await downloadStore.addWant({
|
await downloadStore.addRequest({
|
||||||
mbid: this.artistMBID,
|
mbid: this.artistMBID,
|
||||||
entity: 'artist',
|
entity: 'artist',
|
||||||
libraryId,
|
libraryId,
|
||||||
@@ -1950,7 +1950,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
} as never);
|
} as never);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Could not update the wanted list:', err);
|
console.error('Could not update the requests list:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { designTokens } from '../../styles/tokens.css';
|
|||||||
|
|
||||||
import type { DragActiveDetail } from '@utils/drag-controller';
|
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||||
|
|
||||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'wanted' | 'autotag' | 'jobs' | 'settings';
|
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: View;
|
id: View;
|
||||||
@@ -149,7 +149,7 @@ export class AppSidebar extends LitElement {
|
|||||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||||
{ id: 'wanted', label: 'Wanted', icon: 'bookmark' },
|
{ id: 'downloads', label: 'Downloads', icon: 'bookmark' },
|
||||||
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
|
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
|
||||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
||||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
||||||
|
|||||||
@@ -1,386 +0,0 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
|
||||||
import { customElement, state } from 'lit/decorators.js';
|
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|
||||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
|
||||||
import { downloadStore } from '@store/download-store';
|
|
||||||
import type { Want, WantSummary } from '@store/download-store';
|
|
||||||
import { libraryStore } from '@store/library-store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The wanted list: music the user has said they want but does not have.
|
|
||||||
*
|
|
||||||
* The list is the durable thing here, not the downloads it produces.
|
|
||||||
* Something unfindable today stays on the list and is retried on a
|
|
||||||
* backoff, so this view is mostly about making the waiting legible —
|
|
||||||
* what is being looked for, when it was last tried, and why it has not
|
|
||||||
* turned up. A row is not a failure just because it is still here.
|
|
||||||
*/
|
|
||||||
@customElement('wanted-view')
|
|
||||||
export class WantedView extends LitElement {
|
|
||||||
@state() private wants: Want[] = [];
|
|
||||||
|
|
||||||
@state() private checking = false;
|
|
||||||
|
|
||||||
@state() private lastSummary: WantSummary | null = null;
|
|
||||||
|
|
||||||
private unsubscribe: (() => void) | null = null;
|
|
||||||
|
|
||||||
static override styles = [
|
|
||||||
designTokens,
|
|
||||||
css`
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--yj-text-primary, #fff);
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
margin: 0 0 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--yj-text-secondary, #b3b3b3);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 24px 0 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--yj-text-secondary, #b3b3b3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--yj-bg-surface, #181818);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row + .row {
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--yj-text-primary, #fff);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--yj-text-tertiary, #888);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
|
||||||
color: var(--yj-text-secondary, #b3b3b3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
padding: 40px 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--yj-text-tertiary, #888);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--yj-text-secondary, #b3b3b3);
|
|
||||||
margin: 8px 0 0;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
|
|
||||||
this.unsubscribe = downloadStore.subscribe(() => {
|
|
||||||
this.wants = downloadStore.wants;
|
|
||||||
});
|
|
||||||
|
|
||||||
void downloadStore.init().then(() => {
|
|
||||||
this.wants = downloadStore.wants;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
|
|
||||||
this.unsubscribe?.();
|
|
||||||
this.unsubscribe = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
|
||||||
const subscriptions = this.wants.filter((w) => w.entity === 'artist');
|
|
||||||
const wanted = this.wants.filter(
|
|
||||||
(w) => w.entity !== 'artist' && w.state === 'wanted',
|
|
||||||
);
|
|
||||||
const paused = this.wants.filter((w) => w.state === 'paused');
|
|
||||||
const satisfied = this.wants.filter((w) => w.state === 'satisfied');
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<header>
|
|
||||||
<h1>Wanted</h1>
|
|
||||||
<wa-button
|
|
||||||
size="small"
|
|
||||||
appearance="outlined"
|
|
||||||
?disabled=${this.checking}
|
|
||||||
@click=${() => void this.checkNow()}
|
|
||||||
>
|
|
||||||
<wa-icon slot="start" name="rotate"></wa-icon>
|
|
||||||
${this.checking ? 'Checking…' : 'Check now'}
|
|
||||||
</wa-button>
|
|
||||||
${satisfied.length > 0
|
|
||||||
? html`
|
|
||||||
<wa-button
|
|
||||||
size="small"
|
|
||||||
appearance="plain"
|
|
||||||
@click=${() =>
|
|
||||||
void downloadStore.clearSatisfiedWants()}
|
|
||||||
>
|
|
||||||
Clear found
|
|
||||||
</wa-button>
|
|
||||||
`
|
|
||||||
: nothing}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p class="subtitle">
|
|
||||||
Music you want but do not have. Anything that cannot be found
|
|
||||||
stays here and is looked for again later.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
${this.renderSummary()}
|
|
||||||
${this.wants.length === 0 ? this.renderEmpty() : nothing}
|
|
||||||
${this.renderSection(
|
|
||||||
'Following',
|
|
||||||
subscriptions,
|
|
||||||
(w) => this.renderSubscription(w),
|
|
||||||
)}
|
|
||||||
${this.renderSection('Looking for', wanted, (w) => this.renderWant(w))}
|
|
||||||
${this.renderSection('Paused', paused, (w) => this.renderWant(w))}
|
|
||||||
${this.renderSection('Found', satisfied, (w) => this.renderWant(w))}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderEmpty() {
|
|
||||||
return html`
|
|
||||||
<div class="empty">
|
|
||||||
Nothing wanted yet. Use “Want this” on an album or artist to
|
|
||||||
add it here.
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderSummary() {
|
|
||||||
if (!this.lastSummary) return nothing;
|
|
||||||
|
|
||||||
const s = this.lastSummary;
|
|
||||||
|
|
||||||
const parts = [
|
|
||||||
s.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
|
|
||||||
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
|
|
||||||
s.started > 0 ? `${s.started} downloading` : '',
|
|
||||||
s.attempted > 0 ? `${s.attempted} searched for` : '',
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<p class="summary">
|
|
||||||
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
|
|
||||||
</p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderSection(
|
|
||||||
title: string,
|
|
||||||
items: Want[],
|
|
||||||
renderer: (want: Want) => unknown,
|
|
||||||
) {
|
|
||||||
if (items.length === 0) return nothing;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<h2>${title}</h2>
|
|
||||||
${items.map((want) => renderer(want))}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An artist row is a subscription, not a queued download, so it
|
|
||||||
* shows what it covers rather than a retry count — the albums it
|
|
||||||
* produced appear in their own section.
|
|
||||||
*/
|
|
||||||
private renderSubscription(want: Want) {
|
|
||||||
return html`
|
|
||||||
<div class="row">
|
|
||||||
<wa-icon name="user-group"></wa-icon>
|
|
||||||
<div class="row-main">
|
|
||||||
<div class="title">${want.artist || want.title || want.mbid}</div>
|
|
||||||
<div class="detail">
|
|
||||||
${want.scope === 'all'
|
|
||||||
? 'Whole discography, plus new releases'
|
|
||||||
: 'New releases only'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="badge">Following</span>
|
|
||||||
<div class="actions">
|
|
||||||
<wa-button
|
|
||||||
size="small"
|
|
||||||
appearance="plain"
|
|
||||||
@click=${() => void this.toggleScope(want)}
|
|
||||||
>
|
|
||||||
${want.scope === 'all' ? 'New only' : 'Everything'}
|
|
||||||
</wa-button>
|
|
||||||
${this.renderRemove(want)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderWant(want: Want) {
|
|
||||||
return html`
|
|
||||||
<div class="row">
|
|
||||||
<wa-icon
|
|
||||||
name=${want.entity === 'recording' ? 'music' : 'compact-disc'}
|
|
||||||
></wa-icon>
|
|
||||||
<div class="row-main">
|
|
||||||
<div class="title">
|
|
||||||
${want.artist ? `${want.artist} — ` : ''}${want.title ||
|
|
||||||
want.mbid}
|
|
||||||
</div>
|
|
||||||
<div class="detail">${wantDetail(want)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
${want.state === 'satisfied'
|
|
||||||
? nothing
|
|
||||||
: html`
|
|
||||||
<wa-button
|
|
||||||
size="small"
|
|
||||||
appearance="plain"
|
|
||||||
@click=${() =>
|
|
||||||
void downloadStore.pauseWant(
|
|
||||||
want.id,
|
|
||||||
want.state !== 'paused',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
${want.state === 'paused' ? 'Resume' : 'Pause'}
|
|
||||||
</wa-button>
|
|
||||||
`}
|
|
||||||
${this.renderRemove(want)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderRemove(want: Want) {
|
|
||||||
return html`
|
|
||||||
<wa-button
|
|
||||||
size="small"
|
|
||||||
appearance="plain"
|
|
||||||
@click=${() => void downloadStore.removeWant(want.id)}
|
|
||||||
>
|
|
||||||
<wa-icon name="xmark"></wa-icon>
|
|
||||||
</wa-button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Widens or narrows what an artist subscription covers. */
|
|
||||||
private async toggleScope(want: Want): Promise<void> {
|
|
||||||
try {
|
|
||||||
const libraryId =
|
|
||||||
want.libraryId || (await libraryStore.getDefaultLibraryId());
|
|
||||||
if (!libraryId) {
|
|
||||||
console.error(
|
|
||||||
'Could not change what this subscription covers: no library available',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadStore.addWant({
|
|
||||||
mbid: want.mbid,
|
|
||||||
entity: 'artist',
|
|
||||||
libraryId,
|
|
||||||
artist: want.artist,
|
|
||||||
title: want.title,
|
|
||||||
scope: want.scope === 'all' ? 'future' : 'all',
|
|
||||||
secondary: want.secondary,
|
|
||||||
} as never);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Could not change what this subscription covers:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkNow(): Promise<void> {
|
|
||||||
this.checking = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.lastSummary = await downloadStore.reconcileWanted();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Could not check the wanted list:', err);
|
|
||||||
} finally {
|
|
||||||
this.checking = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The second line of a want row: what is happening, in the user's terms.
|
|
||||||
*
|
|
||||||
* A want that has been tried and not found is reported as still being
|
|
||||||
* looked for rather than as an error, because that is what it is — the
|
|
||||||
* retry is already scheduled and there is nothing for the user to do.
|
|
||||||
*/
|
|
||||||
function wantDetail(want: Want): string {
|
|
||||||
if (want.state === 'satisfied') return 'In your library';
|
|
||||||
if (want.state === 'paused') return 'Paused';
|
|
||||||
|
|
||||||
if (want.attempts === 0) return 'Not looked for yet';
|
|
||||||
|
|
||||||
const reason = want.lastError ? ` — ${want.lastError}` : '';
|
|
||||||
|
|
||||||
return `Looked for ${want.attempts} time${want.attempts === 1 ? '' : 's'}${reason}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
'wanted-view': WantedView;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -71,7 +71,7 @@ export const Events = {
|
|||||||
AlbumReleasesReady: "AlbumReleasesReady",
|
AlbumReleasesReady: "AlbumReleasesReady",
|
||||||
DownloadProvidersChanged: "DownloadProvidersChanged",
|
DownloadProvidersChanged: "DownloadProvidersChanged",
|
||||||
DownloadsChanged: "DownloadsChanged",
|
DownloadsChanged: "DownloadsChanged",
|
||||||
WantedListChanged: "WantedListChanged",
|
RequestsChanged: "RequestsChanged",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type EventName = (typeof Events)[keyof typeof Events];
|
export type EventName = (typeof Events)[keyof typeof Events];
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import {
|
import {
|
||||||
AddProvider,
|
AddProvider,
|
||||||
AddWant,
|
AddRequest,
|
||||||
Cancel,
|
Cancel,
|
||||||
Candidates,
|
Candidates,
|
||||||
ClearFinished,
|
ClearFinished,
|
||||||
ClearSatisfiedWants,
|
ClearSatisfiedRequests,
|
||||||
DeleteProvider,
|
DeleteProvider,
|
||||||
ImportExternalWants,
|
ImportExternalRequests,
|
||||||
|
ListDownloads,
|
||||||
ListProviders,
|
ListProviders,
|
||||||
ListRequests,
|
ListRequests,
|
||||||
ListWants,
|
PauseRequest,
|
||||||
PauseWant,
|
|
||||||
Pick,
|
Pick,
|
||||||
ProviderKinds,
|
ProviderKinds,
|
||||||
ReconcileWanted,
|
ReconcileRequests,
|
||||||
RemoveWant,
|
RemoveRequest,
|
||||||
Start,
|
StartDownload,
|
||||||
TestProvider,
|
TestProvider,
|
||||||
UpdateProvider,
|
UpdateProvider,
|
||||||
} from '@go/download/Service';
|
} from '@go/download/Service';
|
||||||
@@ -26,32 +26,32 @@ import { Events } from '../events';
|
|||||||
export type DownloadCandidate = download.Candidate;
|
export type DownloadCandidate = download.Candidate;
|
||||||
export type DownloadProvider = download.Config;
|
export type DownloadProvider = download.Config;
|
||||||
export type DownloadDescriptor = download.Descriptor;
|
export type DownloadDescriptor = download.Descriptor;
|
||||||
export type DownloadRequest = download.RequestView;
|
export type DownloadView = download.DownloadView;
|
||||||
export type ProviderField = download.Field;
|
export type ProviderField = download.Field;
|
||||||
export type Want = download.Want;
|
export type Request = download.Request;
|
||||||
export type WantSummary = download.Summary;
|
export type RequestSummary = download.Summary;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a want's MBID names. Mirrors backend/download.Entity — the
|
* What a request's MBID names. Mirrors backend/download.Entity — the
|
||||||
* wanted list makes no other type distinction, because an MBID plus
|
* request list makes no other type distinction, because an MBID plus
|
||||||
* what it names is the whole of a want.
|
* what it names is the whole of a request.
|
||||||
*/
|
*/
|
||||||
export type WantEntity = 'artist' | 'release-group' | 'release' | 'recording';
|
export type RequestEntity = 'artist' | 'release-group' | 'release' | 'recording';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a want sits. There is deliberately no "failed": an attempt can
|
* Where a request sits. There is deliberately no "failed": an attempt can
|
||||||
* fail, a want cannot — something unfindable today is still wanted.
|
* fail, a request cannot — something unfindable today is still requested.
|
||||||
*/
|
*/
|
||||||
export type WantState = 'wanted' | 'satisfied' | 'paused';
|
export type RequestState = 'wanted' | 'satisfied' | 'paused';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How much of an artist's output a subscription covers. 'future' is the
|
* How much of an artist's output a subscription covers. 'future' is the
|
||||||
* default so subscribing does not silently queue a back catalogue.
|
* default so subscribing does not silently queue a back catalogue.
|
||||||
*/
|
*/
|
||||||
export type WantScope = 'future' | 'all';
|
export type RequestScope = 'future' | 'all';
|
||||||
|
|
||||||
/** Lifecycle states a request can be in. Mirrors backend/download.State. */
|
/** Lifecycle states a download can be in. Mirrors backend/download.State. */
|
||||||
export type DownloadState =
|
export type DownloadLifecycleState =
|
||||||
| 'searching'
|
| 'searching'
|
||||||
| 'found'
|
| 'found'
|
||||||
| 'queued'
|
| 'queued'
|
||||||
@@ -71,12 +71,12 @@ const TERMINAL_STATES: ReadonlySet<string> = new Set([
|
|||||||
'failed',
|
'failed',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isRequestTerminal(request: DownloadRequest): boolean {
|
export function isDownloadTerminal(view: DownloadView): boolean {
|
||||||
return TERMINAL_STATES.has(request.state);
|
return TERMINAL_STATES.has(view.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable label for a request state. Kept here rather than in the
|
* Human-readable label for a download state. Kept here rather than in the
|
||||||
* components so the downloads list and the picker never disagree about
|
* components so the downloads list and the picker never disagree about
|
||||||
* what a state is called.
|
* what a state is called.
|
||||||
*/
|
*/
|
||||||
@@ -171,7 +171,7 @@ export function formatBytes(bytes: number): string {
|
|||||||
* Per-transfer progress deliberately does not flow through here — that
|
* Per-transfer progress deliberately does not flow through here — that
|
||||||
* lives in the jobs registry, which already coalesces high-frequency
|
* lives in the jobs registry, which already coalesces high-frequency
|
||||||
* updates into one event. This store handles the coarse changes: which
|
* updates into one event. This store handles the coarse changes: which
|
||||||
* providers exist, which requests exist, and what the user is being
|
* providers exist, which downloads exist, and what the user is being
|
||||||
* asked to choose between.
|
* asked to choose between.
|
||||||
*/
|
*/
|
||||||
class DownloadStore {
|
class DownloadStore {
|
||||||
@@ -179,9 +179,9 @@ class DownloadStore {
|
|||||||
|
|
||||||
private descriptorsValue: DownloadDescriptor[] = [];
|
private descriptorsValue: DownloadDescriptor[] = [];
|
||||||
|
|
||||||
private requestsValue: DownloadRequest[] = [];
|
private downloadsValue: DownloadView[] = [];
|
||||||
|
|
||||||
private wantsValue: Want[] = [];
|
private requestsValue: Request[] = [];
|
||||||
|
|
||||||
private subscribers = new Set<Subscriber>();
|
private subscribers = new Set<Subscriber>();
|
||||||
|
|
||||||
@@ -195,21 +195,21 @@ class DownloadStore {
|
|||||||
});
|
});
|
||||||
|
|
||||||
EventsOn(Events.DownloadsChanged, () => {
|
EventsOn(Events.DownloadsChanged, () => {
|
||||||
void this.refreshRequests();
|
void this.refreshDownloads();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The wanted list changes on its own — a background reconcile
|
// The request list changes on its own — a background reconcile
|
||||||
// pass expands an artist, retires something the library gained,
|
// pass expands an artist, retires something the library gained,
|
||||||
// or starts a download nobody asked for just now. So it is
|
// or starts a download nobody asked for just now. So it is
|
||||||
// event-driven rather than fetched once on mount.
|
// event-driven rather than fetched once on mount.
|
||||||
EventsOn(Events.WantedListChanged, () => {
|
EventsOn(Events.RequestsChanged, () => {
|
||||||
void this.refreshWants();
|
void this.refreshRequests();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads providers and requests once. Safe to call from every
|
* Loads providers, downloads and requests once. Safe to call from
|
||||||
* component's connectedCallback — subsequent calls are no-ops.
|
* every component's connectedCallback — subsequent calls are no-ops.
|
||||||
*/
|
*/
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
@@ -219,8 +219,8 @@ class DownloadStore {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.refreshDescriptors(),
|
this.refreshDescriptors(),
|
||||||
this.refreshProviders(),
|
this.refreshProviders(),
|
||||||
|
this.refreshDownloads(),
|
||||||
this.refreshRequests(),
|
this.refreshRequests(),
|
||||||
this.refreshWants(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,12 +238,12 @@ class DownloadStore {
|
|||||||
return this.descriptorsValue;
|
return this.descriptorsValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
get requests(): DownloadRequest[] {
|
get downloads(): DownloadView[] {
|
||||||
return this.requestsValue;
|
return this.downloadsValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeRequests(): DownloadRequest[] {
|
get activeDownloads(): DownloadView[] {
|
||||||
return this.requestsValue.filter((r) => !isRequestTerminal(r));
|
return this.downloadsValue.filter((d) => !isDownloadTerminal(d));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -294,9 +294,9 @@ class DownloadStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshRequests(): Promise<void> {
|
async refreshDownloads(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.requestsValue = (await ListRequests(50)) ?? [];
|
this.downloadsValue = (await ListDownloads(50)) ?? [];
|
||||||
this.notify();
|
this.notify();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load downloads:', err);
|
console.error('Failed to load downloads:', err);
|
||||||
@@ -346,7 +346,7 @@ class DownloadStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Requests
|
// Downloads (one search+grab attempt)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -355,94 +355,102 @@ class DownloadStore {
|
|||||||
* the picker or just show progress.
|
* the picker or just show progress.
|
||||||
*/
|
*/
|
||||||
async start(request: download.SearchRequest): Promise<download.StartResult> {
|
async start(request: download.SearchRequest): Promise<download.StartResult> {
|
||||||
const result = await Start(request);
|
const result = await StartDownload(request);
|
||||||
|
|
||||||
await this.refreshRequests();
|
await this.refreshDownloads();
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async pick(requestId: string, candidateId: string): Promise<void> {
|
async pick(downloadId: string, candidateId: string): Promise<void> {
|
||||||
await Pick(requestId, candidateId);
|
await Pick(downloadId, candidateId);
|
||||||
await this.refreshRequests();
|
await this.refreshDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(requestId: string): Promise<void> {
|
async cancel(downloadId: string): Promise<void> {
|
||||||
await Cancel(requestId);
|
await Cancel(downloadId);
|
||||||
await this.refreshRequests();
|
await this.refreshDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
async candidates(requestId: string): Promise<DownloadCandidate[]> {
|
async candidates(downloadId: string): Promise<DownloadCandidate[]> {
|
||||||
return (await Candidates(requestId)) ?? [];
|
return (await Candidates(downloadId)) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearFinished(): Promise<void> {
|
async clearFinished(): Promise<void> {
|
||||||
await ClearFinished();
|
await ClearFinished();
|
||||||
await this.refreshRequests();
|
await this.refreshDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Wanted list
|
// Requests (durable "I asked for this")
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
get wants(): Want[] {
|
get requests(): Request[] {
|
||||||
return this.wantsValue;
|
return this.requestsValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wants still being looked for. */
|
/** Requests still being looked for. */
|
||||||
get activeWants(): Want[] {
|
get activeRequests(): Request[] {
|
||||||
return this.wantsValue.filter((w) => w.state === 'wanted');
|
return this.requestsValue.filter((r) => r.state === 'wanted');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Artist subscriptions, which expand rather than download. */
|
/** Artist subscriptions, which expand rather than download. */
|
||||||
get subscriptions(): Want[] {
|
get subscriptions(): Request[] {
|
||||||
return this.wantsValue.filter((w) => w.entity === 'artist');
|
return this.requestsValue.filter((r) => r.entity === 'artist');
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshWants(): Promise<void> {
|
async refreshRequests(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.wantsValue = (await ListWants()) ?? [];
|
this.requestsValue = (await ListRequests()) ?? [];
|
||||||
this.notify();
|
this.notify();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load the wanted list:', err);
|
console.error('Failed to load the requests list:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when this MBID is already on the list. */
|
/**
|
||||||
isWanted(mbid: string): boolean {
|
* True when this MBID is already requested.
|
||||||
|
*
|
||||||
|
* Checked against the locally cached request list rather than the
|
||||||
|
* `IsRequested` RPC: the list is already kept current via
|
||||||
|
* `RequestsChanged`, and a local lookup keeps this usable
|
||||||
|
* synchronously from render — the same shape callers relied on
|
||||||
|
* before the rename.
|
||||||
|
*/
|
||||||
|
isRequested(mbid: string): boolean {
|
||||||
const needle = mbid.trim().toLowerCase();
|
const needle = mbid.trim().toLowerCase();
|
||||||
|
|
||||||
return this.wantsValue.some((w) => w.mbid === needle);
|
return this.requestsValue.some((r) => r.mbid === needle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The want for an MBID, if it is on the list. */
|
/** The request for an MBID, if one exists. */
|
||||||
wantFor(mbid: string): Want | undefined {
|
requestFor(mbid: string): Request | undefined {
|
||||||
const needle = mbid.trim().toLowerCase();
|
const needle = mbid.trim().toLowerCase();
|
||||||
|
|
||||||
return this.wantsValue.find((w) => w.mbid === needle);
|
return this.requestsValue.find((r) => r.mbid === needle);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addWant(want: download.WantRequest): Promise<number> {
|
async addRequest(request: download.RequestInput): Promise<number> {
|
||||||
const id = await AddWant(want);
|
const id = await AddRequest(request);
|
||||||
|
|
||||||
await this.refreshWants();
|
await this.refreshRequests();
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeWant(id: number): Promise<void> {
|
async removeRequest(id: number): Promise<void> {
|
||||||
await RemoveWant(id);
|
await RemoveRequest(id);
|
||||||
await this.refreshWants();
|
await this.refreshRequests();
|
||||||
}
|
}
|
||||||
|
|
||||||
async pauseWant(id: number, paused: boolean): Promise<void> {
|
async pauseRequest(id: number, paused: boolean): Promise<void> {
|
||||||
await PauseWant(id, paused);
|
await PauseRequest(id, paused);
|
||||||
await this.refreshWants();
|
await this.refreshRequests();
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearSatisfiedWants(): Promise<void> {
|
async clearSatisfiedRequests(): Promise<void> {
|
||||||
await ClearSatisfiedWants();
|
await ClearSatisfiedRequests();
|
||||||
await this.refreshWants();
|
await this.refreshRequests();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -450,22 +458,22 @@ class DownloadStore {
|
|||||||
* with what the pass did so the UI can say something concrete
|
* with what the pass did so the UI can say something concrete
|
||||||
* rather than just stopping its spinner.
|
* rather than just stopping its spinner.
|
||||||
*/
|
*/
|
||||||
async reconcileWanted(): Promise<WantSummary> {
|
async reconcileRequests(): Promise<RequestSummary> {
|
||||||
const summary = await ReconcileWanted();
|
const summary = await ReconcileRequests();
|
||||||
|
|
||||||
await Promise.all([this.refreshWants(), this.refreshRequests()]);
|
await Promise.all([this.refreshRequests(), this.refreshDownloads()]);
|
||||||
|
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adopts a provider's own list, e.g. Lidarr's monitored artists. */
|
/** Adopts a provider's own list, e.g. Lidarr's monitored artists. */
|
||||||
async importExternalWants(
|
async importExternalRequests(
|
||||||
providerId: number,
|
providerId: number,
|
||||||
libraryId: number,
|
libraryId: number,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const count = await ImportExternalWants(providerId, libraryId);
|
const count = await ImportExternalRequests(providerId, libraryId);
|
||||||
|
|
||||||
await this.refreshWants();
|
await this.refreshRequests();
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -1,8 +1,11 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {download} from '../models';
|
||||||
import {tracklist} from '../models';
|
import {tracklist} from '../models';
|
||||||
import {context} from '../models';
|
import {context} from '../models';
|
||||||
|
|
||||||
|
export function GetDownloadPreferences():Promise<download.AutoDownloadPrefs>;
|
||||||
|
|
||||||
export function GetFavoritesIconStyle():Promise<string>;
|
export function GetFavoritesIconStyle():Promise<string>;
|
||||||
|
|
||||||
export function GetFavoritesPlaylistID():Promise<number>;
|
export function GetFavoritesPlaylistID():Promise<number>;
|
||||||
@@ -29,6 +32,8 @@ export function Save():Promise<void>;
|
|||||||
|
|
||||||
export function SetContext(arg1:context.Context):Promise<void>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
|
export function SetDownloadPreferences(arg1:download.AutoDownloadPrefs):Promise<void>;
|
||||||
|
|
||||||
export function SetFavoritesIconStyle(arg1:string):Promise<void>;
|
export function SetFavoritesIconStyle(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetFavoritesPlaylistID(arg1:number):Promise<void>;
|
export function SetFavoritesPlaylistID(arg1:number):Promise<void>;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function GetDownloadPreferences() {
|
||||||
|
return window['go']['config']['Config']['GetDownloadPreferences']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetFavoritesIconStyle() {
|
export function GetFavoritesIconStyle() {
|
||||||
return window['go']['config']['Config']['GetFavoritesIconStyle']();
|
return window['go']['config']['Config']['GetFavoritesIconStyle']();
|
||||||
}
|
}
|
||||||
@@ -54,6 +58,10 @@ export function SetContext(arg1) {
|
|||||||
return window['go']['config']['Config']['SetContext'](arg1);
|
return window['go']['config']['Config']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetDownloadPreferences(arg1) {
|
||||||
|
return window['go']['config']['Config']['SetDownloadPreferences'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetFavoritesIconStyle(arg1) {
|
export function SetFavoritesIconStyle(arg1) {
|
||||||
return window['go']['config']['Config']['SetFavoritesIconStyle'](arg1);
|
return window['go']['config']['Config']['SetFavoritesIconStyle'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-11
@@ -5,7 +5,7 @@ import {context} from '../models';
|
|||||||
|
|
||||||
export function AddProvider(arg1:string,arg2:string,arg3:Record<string, string>):Promise<number>;
|
export function AddProvider(arg1:string,arg2:string,arg3:Record<string, string>):Promise<number>;
|
||||||
|
|
||||||
export function AddWant(arg1:download.WantRequest):Promise<number>;
|
export function AddRequest(arg1:download.RequestInput):Promise<number>;
|
||||||
|
|
||||||
export function Cancel(arg1:string):Promise<void>;
|
export function Cancel(arg1:string):Promise<void>;
|
||||||
|
|
||||||
@@ -13,35 +13,37 @@ export function Candidates(arg1:string):Promise<Array<download.Candidate>>;
|
|||||||
|
|
||||||
export function ClearFinished():Promise<void>;
|
export function ClearFinished():Promise<void>;
|
||||||
|
|
||||||
export function ClearSatisfiedWants():Promise<void>;
|
export function ClearSatisfiedRequests():Promise<void>;
|
||||||
|
|
||||||
export function DeleteProvider(arg1:number):Promise<void>;
|
export function DeleteProvider(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function ImportExternalWants(arg1:number,arg2:number):Promise<number>;
|
export function ImportExternalRequests(arg1:number,arg2:number):Promise<number>;
|
||||||
|
|
||||||
export function IsWanted(arg1:string,arg2:number):Promise<boolean>;
|
export function IsRequested(arg1:string,arg2:number):Promise<boolean>;
|
||||||
|
|
||||||
|
export function ListDownloads(arg1:number):Promise<Array<download.DownloadView>>;
|
||||||
|
|
||||||
export function ListProviders():Promise<Array<download.Config>>;
|
export function ListProviders():Promise<Array<download.Config>>;
|
||||||
|
|
||||||
export function ListRequests(arg1:number):Promise<Array<download.RequestView>>;
|
export function ListRequests():Promise<Array<download.Request>>;
|
||||||
|
|
||||||
export function ListWants():Promise<Array<download.Want>>;
|
export function PauseRequest(arg1:number,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
export function PauseWant(arg1:number,arg2:boolean):Promise<void>;
|
|
||||||
|
|
||||||
export function Pick(arg1:string,arg2:string):Promise<void>;
|
export function Pick(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function ProviderKinds():Promise<Array<download.Descriptor>>;
|
export function ProviderKinds():Promise<Array<download.Descriptor>>;
|
||||||
|
|
||||||
export function ReconcileWanted():Promise<download.Summary>;
|
export function ReconcileRequests():Promise<download.Summary>;
|
||||||
|
|
||||||
export function RemoveWant(arg1:number):Promise<void>;
|
export function RemoveRequest(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetContext(arg1:context.Context):Promise<void>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
|
export function SetPreferences(arg1:download.AutoDownloadPrefs):Promise<void>;
|
||||||
|
|
||||||
export function SetReconciler(arg1:download.Reconciler):Promise<void>;
|
export function SetReconciler(arg1:download.Reconciler):Promise<void>;
|
||||||
|
|
||||||
export function Start(arg1:download.SearchRequest):Promise<download.StartResult>;
|
export function StartDownload(arg1:download.SearchRequest):Promise<download.StartResult>;
|
||||||
|
|
||||||
export function TestProvider(arg1:number):Promise<void>;
|
export function TestProvider(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export function AddProvider(arg1, arg2, arg3) {
|
|||||||
return window['go']['download']['Service']['AddProvider'](arg1, arg2, arg3);
|
return window['go']['download']['Service']['AddProvider'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddWant(arg1) {
|
export function AddRequest(arg1) {
|
||||||
return window['go']['download']['Service']['AddWant'](arg1);
|
return window['go']['download']['Service']['AddRequest'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Cancel(arg1) {
|
export function Cancel(arg1) {
|
||||||
@@ -22,36 +22,36 @@ export function ClearFinished() {
|
|||||||
return window['go']['download']['Service']['ClearFinished']();
|
return window['go']['download']['Service']['ClearFinished']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClearSatisfiedWants() {
|
export function ClearSatisfiedRequests() {
|
||||||
return window['go']['download']['Service']['ClearSatisfiedWants']();
|
return window['go']['download']['Service']['ClearSatisfiedRequests']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteProvider(arg1) {
|
export function DeleteProvider(arg1) {
|
||||||
return window['go']['download']['Service']['DeleteProvider'](arg1);
|
return window['go']['download']['Service']['DeleteProvider'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportExternalWants(arg1, arg2) {
|
export function ImportExternalRequests(arg1, arg2) {
|
||||||
return window['go']['download']['Service']['ImportExternalWants'](arg1, arg2);
|
return window['go']['download']['Service']['ImportExternalRequests'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IsWanted(arg1, arg2) {
|
export function IsRequested(arg1, arg2) {
|
||||||
return window['go']['download']['Service']['IsWanted'](arg1, arg2);
|
return window['go']['download']['Service']['IsRequested'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListDownloads(arg1) {
|
||||||
|
return window['go']['download']['Service']['ListDownloads'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListProviders() {
|
export function ListProviders() {
|
||||||
return window['go']['download']['Service']['ListProviders']();
|
return window['go']['download']['Service']['ListProviders']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListRequests(arg1) {
|
export function ListRequests() {
|
||||||
return window['go']['download']['Service']['ListRequests'](arg1);
|
return window['go']['download']['Service']['ListRequests']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListWants() {
|
export function PauseRequest(arg1, arg2) {
|
||||||
return window['go']['download']['Service']['ListWants']();
|
return window['go']['download']['Service']['PauseRequest'](arg1, arg2);
|
||||||
}
|
|
||||||
|
|
||||||
export function PauseWant(arg1, arg2) {
|
|
||||||
return window['go']['download']['Service']['PauseWant'](arg1, arg2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Pick(arg1, arg2) {
|
export function Pick(arg1, arg2) {
|
||||||
@@ -62,24 +62,28 @@ export function ProviderKinds() {
|
|||||||
return window['go']['download']['Service']['ProviderKinds']();
|
return window['go']['download']['Service']['ProviderKinds']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReconcileWanted() {
|
export function ReconcileRequests() {
|
||||||
return window['go']['download']['Service']['ReconcileWanted']();
|
return window['go']['download']['Service']['ReconcileRequests']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RemoveWant(arg1) {
|
export function RemoveRequest(arg1) {
|
||||||
return window['go']['download']['Service']['RemoveWant'](arg1);
|
return window['go']['download']['Service']['RemoveRequest'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetContext(arg1) {
|
export function SetContext(arg1) {
|
||||||
return window['go']['download']['Service']['SetContext'](arg1);
|
return window['go']['download']['Service']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetPreferences(arg1) {
|
||||||
|
return window['go']['download']['Service']['SetPreferences'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetReconciler(arg1) {
|
export function SetReconciler(arg1) {
|
||||||
return window['go']['download']['Service']['SetReconciler'](arg1);
|
return window['go']['download']['Service']['SetReconciler'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Start(arg1) {
|
export function StartDownload(arg1) {
|
||||||
return window['go']['download']['Service']['Start'](arg1);
|
return window['go']['download']['Service']['StartDownload'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestProvider(arg1) {
|
export function TestProvider(arg1) {
|
||||||
|
|||||||
+146
-125
@@ -297,12 +297,31 @@ export namespace autotagservice {
|
|||||||
|
|
||||||
export namespace download {
|
export namespace download {
|
||||||
|
|
||||||
|
export class AutoDownloadPrefs {
|
||||||
|
minSizeMb: number;
|
||||||
|
maxSizeMb: number;
|
||||||
|
preferredSizeMb: number;
|
||||||
|
allowedFormats: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutoDownloadPrefs(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.minSizeMb = source["minSizeMb"];
|
||||||
|
this.maxSizeMb = source["maxSizeMb"];
|
||||||
|
this.preferredSizeMb = source["preferredSizeMb"];
|
||||||
|
this.allowedFormats = source["allowedFormats"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class QualityScore {
|
export class QualityScore {
|
||||||
overall: number;
|
overall: number;
|
||||||
formatRank: number;
|
formatRank: number;
|
||||||
bitrate: number;
|
bitrate: number;
|
||||||
health: number;
|
health: number;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
sizeFit: number;
|
||||||
mixed: boolean;
|
mixed: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -316,6 +335,7 @@ export namespace download {
|
|||||||
this.bitrate = source["bitrate"];
|
this.bitrate = source["bitrate"];
|
||||||
this.health = source["health"];
|
this.health = source["health"];
|
||||||
this.priority = source["priority"];
|
this.priority = source["priority"];
|
||||||
|
this.sizeFit = source["sizeFit"];
|
||||||
this.mixed = source["mixed"];
|
this.mixed = source["mixed"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,30 +554,9 @@ export namespace download {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class ExpectedTrack {
|
export class DownloadItem {
|
||||||
position: number;
|
|
||||||
discNumber: number;
|
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
lengthMillis: number;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new ExpectedTrack(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.position = source["position"];
|
|
||||||
this.discNumber = source["discNumber"];
|
|
||||||
this.title = source["title"];
|
|
||||||
this.artist = source["artist"];
|
|
||||||
this.lengthMillis = source["lengthMillis"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Item {
|
|
||||||
id: string;
|
id: string;
|
||||||
requestId: string;
|
downloadId: string;
|
||||||
providerId: number;
|
providerId: number;
|
||||||
transportId?: number;
|
transportId?: number;
|
||||||
externalId?: string;
|
externalId?: string;
|
||||||
@@ -570,13 +569,13 @@ export namespace download {
|
|||||||
updatedAt: time.Time;
|
updatedAt: time.Time;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Item(source);
|
return new DownloadItem(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.id = source["id"];
|
this.id = source["id"];
|
||||||
this.requestId = source["requestId"];
|
this.downloadId = source["downloadId"];
|
||||||
this.providerId = source["providerId"];
|
this.providerId = source["providerId"];
|
||||||
this.transportId = source["transportId"];
|
this.transportId = source["transportId"];
|
||||||
this.externalId = source["externalId"];
|
this.externalId = source["externalId"];
|
||||||
@@ -607,26 +606,32 @@ export namespace download {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ExpectedTrack {
|
||||||
|
position: number;
|
||||||
export class Reconciler {
|
discNumber: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
lengthMillis: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Reconciler(source);
|
return new ExpectedTrack(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.position = source["position"];
|
||||||
|
this.discNumber = source["discNumber"];
|
||||||
|
this.title = source["title"];
|
||||||
|
this.artist = source["artist"];
|
||||||
|
this.lengthMillis = source["lengthMillis"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class RequestView {
|
export class DownloadView {
|
||||||
id: string;
|
id: string;
|
||||||
releaseMbid?: string;
|
releaseMbid?: string;
|
||||||
releaseGroupMbid?: string;
|
releaseGroupMbid?: string;
|
||||||
recordingMbid?: string;
|
recordingMbid?: string;
|
||||||
wantId?: number;
|
requestId?: number;
|
||||||
source?: string;
|
source?: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
album: string;
|
album: string;
|
||||||
@@ -636,10 +641,10 @@ export namespace download {
|
|||||||
createdAt: time.Time;
|
createdAt: time.Time;
|
||||||
state: string;
|
state: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
items: Item[];
|
items: DownloadItem[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RequestView(source);
|
return new DownloadView(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
@@ -648,7 +653,7 @@ export namespace download {
|
|||||||
this.releaseMbid = source["releaseMbid"];
|
this.releaseMbid = source["releaseMbid"];
|
||||||
this.releaseGroupMbid = source["releaseGroupMbid"];
|
this.releaseGroupMbid = source["releaseGroupMbid"];
|
||||||
this.recordingMbid = source["recordingMbid"];
|
this.recordingMbid = source["recordingMbid"];
|
||||||
this.wantId = source["wantId"];
|
this.requestId = source["requestId"];
|
||||||
this.source = source["source"];
|
this.source = source["source"];
|
||||||
this.artist = source["artist"];
|
this.artist = source["artist"];
|
||||||
this.album = source["album"];
|
this.album = source["album"];
|
||||||
@@ -658,7 +663,7 @@ export namespace download {
|
|||||||
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
||||||
this.state = source["state"];
|
this.state = source["state"];
|
||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
this.items = this.convertValues(source["items"], Item);
|
this.items = this.convertValues(source["items"], DownloadItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -679,6 +684,108 @@ export namespace download {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export class Reconciler {
|
||||||
|
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Reconciler(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Request {
|
||||||
|
id: number;
|
||||||
|
mbid: string;
|
||||||
|
entity: string;
|
||||||
|
libraryId: number;
|
||||||
|
artist: string;
|
||||||
|
title: string;
|
||||||
|
scope: string;
|
||||||
|
secondary: boolean;
|
||||||
|
state: string;
|
||||||
|
parentId?: number;
|
||||||
|
attempts: number;
|
||||||
|
lastError?: string;
|
||||||
|
lastTriedAt?: time.Time;
|
||||||
|
nextTryAt?: time.Time;
|
||||||
|
externalIds?: Record<string, string>;
|
||||||
|
createdAt: time.Time;
|
||||||
|
updatedAt: time.Time;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Request(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.mbid = source["mbid"];
|
||||||
|
this.entity = source["entity"];
|
||||||
|
this.libraryId = source["libraryId"];
|
||||||
|
this.artist = source["artist"];
|
||||||
|
this.title = source["title"];
|
||||||
|
this.scope = source["scope"];
|
||||||
|
this.secondary = source["secondary"];
|
||||||
|
this.state = source["state"];
|
||||||
|
this.parentId = source["parentId"];
|
||||||
|
this.attempts = source["attempts"];
|
||||||
|
this.lastError = source["lastError"];
|
||||||
|
this.lastTriedAt = this.convertValues(source["lastTriedAt"], time.Time);
|
||||||
|
this.nextTryAt = this.convertValues(source["nextTryAt"], time.Time);
|
||||||
|
this.externalIds = source["externalIds"];
|
||||||
|
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
||||||
|
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class RequestInput {
|
||||||
|
mbid: string;
|
||||||
|
entity: string;
|
||||||
|
libraryId: number;
|
||||||
|
artist: string;
|
||||||
|
title: string;
|
||||||
|
scope: string;
|
||||||
|
secondary: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RequestInput(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.mbid = source["mbid"];
|
||||||
|
this.entity = source["entity"];
|
||||||
|
this.libraryId = source["libraryId"];
|
||||||
|
this.artist = source["artist"];
|
||||||
|
this.title = source["title"];
|
||||||
|
this.scope = source["scope"];
|
||||||
|
this.secondary = source["secondary"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class SearchRequest {
|
export class SearchRequest {
|
||||||
libraryId: number;
|
libraryId: number;
|
||||||
releaseMbid: string;
|
releaseMbid: string;
|
||||||
@@ -722,7 +829,7 @@ export namespace download {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StartResult {
|
export class StartResult {
|
||||||
requestId: string;
|
downloadId: string;
|
||||||
candidates: Candidate[];
|
candidates: Candidate[];
|
||||||
autoPicked: boolean;
|
autoPicked: boolean;
|
||||||
|
|
||||||
@@ -732,7 +839,7 @@ export namespace download {
|
|||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.requestId = source["requestId"];
|
this.downloadId = source["downloadId"];
|
||||||
this.candidates = this.convertValues(source["candidates"], Candidate);
|
this.candidates = this.convertValues(source["candidates"], Candidate);
|
||||||
this.autoPicked = source["autoPicked"];
|
this.autoPicked = source["autoPicked"];
|
||||||
}
|
}
|
||||||
@@ -775,92 +882,6 @@ export namespace download {
|
|||||||
this.synced = source["synced"];
|
this.synced = source["synced"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Want {
|
|
||||||
id: number;
|
|
||||||
mbid: string;
|
|
||||||
entity: string;
|
|
||||||
libraryId: number;
|
|
||||||
artist: string;
|
|
||||||
title: string;
|
|
||||||
scope: string;
|
|
||||||
secondary: boolean;
|
|
||||||
state: string;
|
|
||||||
parentId?: number;
|
|
||||||
attempts: number;
|
|
||||||
lastError?: string;
|
|
||||||
lastTriedAt?: time.Time;
|
|
||||||
nextTryAt?: time.Time;
|
|
||||||
externalIds?: Record<string, string>;
|
|
||||||
createdAt: time.Time;
|
|
||||||
updatedAt: time.Time;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new Want(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.id = source["id"];
|
|
||||||
this.mbid = source["mbid"];
|
|
||||||
this.entity = source["entity"];
|
|
||||||
this.libraryId = source["libraryId"];
|
|
||||||
this.artist = source["artist"];
|
|
||||||
this.title = source["title"];
|
|
||||||
this.scope = source["scope"];
|
|
||||||
this.secondary = source["secondary"];
|
|
||||||
this.state = source["state"];
|
|
||||||
this.parentId = source["parentId"];
|
|
||||||
this.attempts = source["attempts"];
|
|
||||||
this.lastError = source["lastError"];
|
|
||||||
this.lastTriedAt = this.convertValues(source["lastTriedAt"], time.Time);
|
|
||||||
this.nextTryAt = this.convertValues(source["nextTryAt"], time.Time);
|
|
||||||
this.externalIds = source["externalIds"];
|
|
||||||
this.createdAt = this.convertValues(source["createdAt"], time.Time);
|
|
||||||
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
|
|
||||||
}
|
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
|
||||||
if (!a) {
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
if (a.slice && a.map) {
|
|
||||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
|
||||||
} else if ("object" === typeof a) {
|
|
||||||
if (asMap) {
|
|
||||||
for (const key of Object.keys(a)) {
|
|
||||||
a[key] = new classs(a[key]);
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
return new classs(a);
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class WantRequest {
|
|
||||||
mbid: string;
|
|
||||||
entity: string;
|
|
||||||
libraryId: number;
|
|
||||||
artist: string;
|
|
||||||
title: string;
|
|
||||||
scope: string;
|
|
||||||
secondary: boolean;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new WantRequest(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.mbid = source["mbid"];
|
|
||||||
this.entity = source["entity"];
|
|
||||||
this.libraryId = source["libraryId"];
|
|
||||||
this.artist = source["artist"];
|
|
||||||
this.title = source["title"];
|
|
||||||
this.scope = source["scope"];
|
|
||||||
this.secondary = source["secondary"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user