feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s

Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
2026-08-06 17:12:01 -04:00
co-authored by Claude Sonnet 5
parent d0d86f85d5
commit e190fd75b9
165 changed files with 31088 additions and 5192 deletions
+25 -6
View File
@@ -4,7 +4,7 @@ name: Search index maintenance
# trigger below runs the same command:
#
# no completed import -> build (first run, or resume a partial one)
# import older than 3mo -> rebuild (re-import from the newest dump)
# import older than 6mo -> rebuild (re-import from the newest dump)
# otherwise -> refresh (fold in new incremental listens)
#
# A refresh is cheap and no-ops when nothing new has been published, so
@@ -13,7 +13,7 @@ on:
push:
branches: [main]
schedule:
# Weekly update pass. The 3-month rebuild is triggered by the same
# Weekly update pass. The 6-month rebuild is triggered by the same
# command when it notices the import has aged out.
- cron: '0 4 * * 1'
workflow_dispatch:
@@ -91,7 +91,12 @@ jobs:
- name: Build tools
working-directory: /src
run: go build -o /usr/local/bin/ ./cmd/indexbuild ./cmd/indexexport
# The dump importer is behind the `indexbuild` tag so it is not
# linked into the app binary; cmd/indexbuild carries the same tag
# and will not build without it.
run: |
go build -tags indexbuild -o /usr/local/bin/ ./cmd/indexbuild
go build -o /usr/local/bin/ ./cmd/indexexport
- name: Maintain index
id: maintain
@@ -122,12 +127,26 @@ jobs:
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
run: |
set -eu
version="$(date -u +%Y%m%d)"
base="${SERVER_URL}/api/packages/${OWNER}/generic/yellowjacket-core-index/${version}"
pkg="${SERVER_URL}/api/packages/${OWNER}/generic/yellowjacket-core-index"
# Published twice: under a dated version for history, and under
# the fixed "latest" version the client fetches. Clients cannot
# discover the newest dated version on their own — the package
# listing API requires a token, while a plain file GET does not
# — so "latest" is what makes an anonymous first run possible.
#
# A generic package rejects re-uploading a filename that already
# exists, so "latest" is deleted before being rewritten. It is
# absent on the very first publish, hence the tolerated 404.
curl --silent --show-error --user "${OWNER}:${PACKAGE_TOKEN}" \
--request DELETE "${pkg}/latest" || true
for version in "$(date -u +%Y%m%d)" latest; do
for f in core-index.db.zst core-index.db.zst.sha256; do
echo "Uploading $f -> $version"
curl --fail-with-body --user "${OWNER}:${PACKAGE_TOKEN}" \
--upload-file "/tmp/$f" "${base}/${f}"
--upload-file "/tmp/$f" "${pkg}/${version}/${f}"
done
done
- name: Summary
+140
View File
@@ -0,0 +1,140 @@
# Notes
Gotchas, measured facts, and things already considered and rejected.
Measurements carry the date they were taken — several of these are
properties of someone else's server and can change.
## MetaBrainz caps a client at ~2 MB/s (measured 2026-07-29)
`data.metabrainz.org` serves a single client at roughly 2.1 MB/s, and
**concurrency does not help**: one Range stream and four concurrent
lanes delivered 32 MB at 2,111,195 B/s and 2,209,000 B/s respectively,
while the same machine pulled 66.9 MB/s from a CDN. One of the four
lanes starved to 0.5 MB/s. The lanes divide a fixed cap; they do not
raise it.
Consequences:
- No client-side concurrency change will speed up a dump download.
Pushing harder earns 503s (the reason `dumpLanes` is 4).
- Stage 1 of a full import costs ~11.8 h at best (89 GB after column
projection). Before projection it was 205 GB — about 27 h.
This is the entire reason the catalog is built centrally and shipped as
an artifact rather than derived per install.
## Further stage-1 reductions, not yet taken
Both are CI-side options; neither is safe as a silent client default
because each changes *what gets counted*.
- **Project `recording_mbid` only** (24.1% of row-group bytes instead of
43.4%): ~49 GB, ~6.5 h. `canonical_musicbrainz_data.csv` already
carries `recording_mbid`, `release_mbid`, `artist_mbids` and
`release_group_mbid`, so release/RG/artist counts can be rolled up
locally. Cost: listens with no recording MBID are dropped, and artist
totals become "sum of their recordings" rather than direct attribution.
- **Stride-sample members** (1-in-4): ~12 GB, ~1.6 h. The dump is flat
numbered members (`0.parquet`, …). Sampling is viable because the
counts only feed a *ranking* for a top-N cut. Must be a stride, never
a prefix — if members are time-ordered a prefix biases hard toward one
era.
## Incremental dump retention is 30 days (measured 2026-07-29)
The incremental directory held 30 dumps (series 25792610), and full
dumps land roughly monthly. An artifact older than ~30 days cannot be
topped up: the dailies bridging the gap are gone. That is a permanent
undercount of that window, not corruption — but it pins the artifact
republish cadence at monthly.
## Anonymous package download is UNVERIFIED
The client fetches the artifact from a fixed `latest` URL because Gitea's
package *listing* API requires a token while a plain file GET appears not
to — a probe of the not-yet-published artifact returned 404 rather than
401. **That is suggestive, not proof.** No artifact has been published
yet to test against. Confirm before relying on it.
Also worth deciding deliberately: every install pulling from a personal
Gitea makes its bandwidth and uptime a user-facing dependency.
## No migration chain
`applySchema` creates the whole schema from `sql/schemas/*.sql` on every
open; all DDL is `IF NOT EXISTS`. A database written by an older build is
not supported and there is no upgrade path by design.
Two things this replaced, worth not reintroducing:
- The 48-step chain was ~3,700 of `database.go`'s 4,061 lines, plus
helpers that existed only to serve it (`backupDatabase`,
`readLibraryDirFromTOML`, `isDuplicateColumnErr`, …).
- `sql/schemas/` had drifted badly from the real schema — it still
described a `genre_recordings` table that migrations had renamed, and
omitted `explore_index`, `http_cache`, `artist_images`,
`similar_artist_map`, `release_to_rg`, `lyrics_index` and
`artist_metadata` entirely. sqlc reads that directory, so it had been
generating against a stale schema and silently missed columns such as
`audio_files.modified_at`.
**When regenerating schema files from a live database, remember the seed
rows.** `file_types` (the four supported extensions), `player_state` and
`queue` each carry `INSERT OR IGNORE` rows that `sqlite_master` does not
contain. Dropping them breaks every audio-file foreign key.
## ANALYZE runs after the catalog merge, not at schema creation
The old migration 45 ran `ANALYZE` once. With the migration chain gone
there is no equivalent moment — an empty database has nothing to measure
— so it runs at the end of the artifact import instead
(`SearchIndex.analyzeIndex`). Without current statistics the planner
mis-estimates the partial expression indexes on `explore_index`
(`idx_explore_title_lower`, `idx_explore_artist_lower`) and scans a
million rows for queries that should seek.
If another path ever populates the catalog, it needs the same call.
## Writers, not readers, are responsible for name quality
`resolveArtistName` falls back to returning the artist MBID when it
cannot find a name. That is fine for a one-off render but must never be
persisted — an MBID stored as a title is unsearchable and shows as a
UUID in the UI.
This used to be defended at every read (`title != mbid` predicates) and
in the upsert's conflict rules. Those defenses are gone; `AddFromCache`
now refuses to write a name equal to the MBID and lets the upsert's
"non-empty wins" rule fill it in when a real name arrives.
`TestAddFromCacheNeverStoresMBIDAsName` guards this.
## Attached databases are invisible to the read pool
`database.DB` holds two handles: a single-writer connection and a
separate query-only pool. `ATTACH` binds to one connection, so anything
touching an attached database must use `ExecContext`/`QueryRowWriter`
(the writer) — `QueryContext` routes to the pool, where the attachment
does not exist and the query fails with "no such table".
## FTS triggers are defined in Go, not in the schema
`explore_index`'s three FTS sync triggers live in
`exploreIndexFTSTriggers` in `database.go` rather than in
`sql/schemas/explore_index.sql`, because the bulk-load path drops and
recreates them (`SuspendExploreIndexFTS`). Defining them in both places
would be two copies free to drift.
Bulk loads must suspend them: measured on a real import, assembly runs at
~31 rows/s with the triggers attached and ~4,700 rows/s without.
## Explore "library only" toggle was removed (2026-08-06)
The Explore UI used to have a "library only" mode toggle
(`frontend/src/store/explore-settings.ts`, `explore:libraryOnly` in
localStorage) that filtered the Explore UI to owned content only. It was
removed outright — the app now always shows full (network-enriched)
Explore data. If offline/library-only mode is wanted again, it should be
built from scratch rather than restored; the old implementation gated
several component code paths in ad hoc ways. A separate
`deep_catalog_enabled` backend flag briefly existed for the same idea and
was removed earlier, when the dump importer left the app binary.
@@ -1,8 +1,27 @@
# 001 — Ship a prebuilt "core" explore index
**Status:** pending
**Branch:** wip
**Status:** complete
**Branch:** cleanup/fresh-start-schema
**Created:** 2026-07-25
**Completed:** 2026-07-30
## Outcome
A fresh install downloads a 70.6 MB artifact and merges 1,076,133 rows
in ~43 s, instead of streaming 205 GB over ~27 h. The dump importer that
produces the artifact left the app binary entirely — it is behind the
`indexbuild` build tag and runs only in CI.
Phase 5 landed differently than planned: rather than a user-facing
setting gating the deep import, the deep import is simply not in the
app. `deep_catalog_enabled` existed briefly and was removed with it.
Two things remain unverified or undone, both recorded in
`.planning/NOTES.md`: anonymous package download on git.ljones.me has
not been confirmed against a real published artifact, and installs whose
index was built by older code (no `listens_applied_series`) have no
rescue path — though with no migration chain, those databases are now
unsupported anyway.
## Problem
@@ -250,17 +269,96 @@ egress if it is self-hosted on a home connection.
columns dropped, metadata stamped, vacuumed. Verified against a
synthetic index: no personal columns leak, no orphaned rows, caps
respected.
3. **One real build.** Run `indexbuild` against a persistent volume
until it converges. This yields the first genuine dump-built index and
with it true row counts, on-disk size, real FTS share, and
`release_to_rg` size. **Every tier number above is still an
extrapolation from synthetic rows until this exists.**
4. ⬜ **Import path.** First-run download + attach + upsert, with checksum
verification, resumability, and clean degradation on failure. Report
it as a job in the Jobs panel — the plumbing for that already exists.
5. ⬜ **Gate the dump build.** Add the setting that makes the full import
opt-in, so a shipped core index isn't immediately followed by the
multi-GB download it was meant to replace.
3. **One real build.** Superseded by a real dump-built index that
already existed on the dev machine (`dump_import_done` 2026-07-17).
Measured 2026-07-29 — these replace every extrapolation above:
| | rows | on disk |
|---|---|---|
| `explore_index` | 2,052,168 (227,359 artists / 400,675 RGs / 1,424,134 recordings) | 383 MB |
| its indexes | | 395 MB |
| FTS | | 80 MB |
187 B/row for the shippable table, 418 B/row all-in — so the ~900 MB
full-budget estimate was right. Two real exports:
| tier | rows | artifact | zstd -19 |
|---|---|---|---|
| 50K artists (default) | 1,076,133 | 191.5 MB | **70.6 MB** |
| 25K artists / 10 RG / 20 rec | 620,973 | 110.6 MB | **37.8 MB** |
`release_to_rg` was empty in that index — it predates the code that
persists it — so its size is still unmeasured.
4. ✅ **Import path.** `backend/explore/artifactfetch.go` (download,
Range-resume, sha256, zstd) and `artifactimport.go` (validate, ATTACH,
batched merge, FTS rebuild, meta stamping). Reported in the Jobs panel
under its own two stages. Measured end to end on the real 50K-artist
artifact against a disk-backed DB: **1,076,133 rows merged in 43.2s**
(24,900 rows/s), yielding a 455 MB `yj.db`, FTS populated and
searchable. In-memory the same merge runs in 28.3s.
5. ✅ **Gate the dump build.** `deep_catalog_enabled` in
`explore_index_meta` (beside `index_build_paused` — it is build state,
read at one decision point). Off by default; exposed as
`DeepCatalogEnabled` / `SetDeepCatalogEnabled` on the explore Service.
An interrupted dump import resumes regardless of the setting, so the
gate never discards a checkpoint that already cost hours.
## Measured 2026-07-29: why the client cannot fix this itself
`data.metabrainz.org` caps a client at ~2.1 MB/s. One Range stream and
four concurrent Range lanes both delivered 32 MB at the same aggregate
rate (2,111,195 B/s vs 2,209,000 B/s) while the same machine pulled
66.9 MB/s from a CDN. **Parallelism buys nothing** — the four lanes just
divide the same cap, and one of them starved to 0.5 MB/s.
So stage 1 costs, unavoidably:
| | bytes | wall clock |
|---|---|---|
| Whole tar (what shipped before column projection) | 205 GB | ~27 h |
| Column projection, 3 columns (43.4%) | 89 GB | ~11.8 h |
| `recording_mbid` only (24.1%), rolled up via canonical | 49 GB | ~6.5 h |
| + 1-in-4 member stride sample | 12 GB | ~1.6 h |
The last two are CI-side options, not client defaults: recording-only
drops listens carrying no recording MBID and re-derives artist totals as
a sum over recordings, and sampling trades exact counts for a ranking.
Both are only safe because the selection they feed is a top-N cut.
## Distribution: the "latest" version trick
The client cannot enumerate package versions — Gitea's package listing
API requires a token, while an anonymous file GET does not (a probe of a
non-existent artifact returns 404, not 401). So `index-artifact.yml`
publishes each artifact twice: under a dated version for history, and
under a fixed `latest` version that the client fetches from a
predictable URL. Generic packages reject overwriting an existing
filename, so `latest` is DELETEd before each rewrite.
⚠️ **Unverified:** that anonymous package *download* is actually enabled
on git.ljones.me. The 404-vs-401 probe is suggestive, not proof — no
artifact has been published yet to test against. Confirm before relying
on it, and note that every install pulling from a personal Gitea makes
its bandwidth and uptime a user-facing dependency.
## Incremental retention bounds artifact staleness
The incremental dump directory holds 30 dumps (series 25792610 as of
2026-07-29) and full dumps land roughly monthly. An artifact older than
~30 days therefore cannot be topped up: the dailies bridging the gap are
gone. That is a permanent undercount of that window's listens, not
corruption — but it pins the republish cadence at monthly.
## Upgrade path for indexes built by older code
The dev machine's index has `dump_import_done` set but **no**
`listens_applied_series` and an empty `release_to_rg`, because it was
built before the code that writes them. That combination is a dead end:
`RefreshListenCounts` bails with "no baseline series recorded", and
`runDumpBuild` short-circuits on the done marker, so popularity can
never update again. Current code writes both, so this affects only
pre-existing installs — but the artifact import is the natural place to
rescue them, since merging one stamps a fresh baseline series.
6. ✅ **CI wiring.** `.gitea/workflows/index-artifact.yml` — push +
weekly cron + manual, concurrency-guarded, publishes only when
`complete && changed` so identical artifacts don't accumulate.
@@ -0,0 +1,155 @@
# 002 — Data lifecycle architecture
**Status:** completed (first tranche); follow-ups tracked below
**Branch:** main
**Created:** 2026-07-26
## Problem
An audit of asset and row cleanup found five leaks, four of which shared
one root cause: **deletion logic was hand-written per call site and lived
far from the thing being deleted.** `RemoveLibrary` knew about ten tables
because someone enumerated them once; migration 32 added an eleventh and
nothing noticed. Files written by `explore` had no cleanup counterpart
anywhere. A function that evicted expired cache rows was written and
never called.
Findings, in severity order:
1. **`RemoveLibrary` was broken for any scanned library.** `tagging_items`
holds `FOREIGN KEY(library_id) REFERENCES libraries(id)` with no
`ON DELETE` clause and was never cleared, so `DELETE FROM libraries`
failed with `FOREIGN KEY constraint failed (787)` and rolled back the
whole removal. Every scanned library has `tagging_items` rows (the
scan upserts one per album folder), so this fired on essentially every
real removal. `RemoveLibrary` had zero test coverage.
2. **Artist images were never deleted by anything.** No `os.Remove` in
`explore`, no `DELETE FROM artist_images` in the codebase. Unbounded
in the number of artists ever browsed in Explore, most of whom are not
in the library.
3. **Cover art size variants leaked on removal.** Only the base
`cover_art.file_path` was unlinked; the `_sm/_md/_lg` files beside it
are derived filenames, not rows, so three files per cover survived.
4. **`http_cache` was never pruned.** `Cache.Evict()` existed with no
callers. Reads filter on `expires_at`, so expired rows were inert but
accumulated for the life of the install.
5. **Cover-art proxy cache was never pruned.** No eviction, no size cap.
## Approach
Rather than patch five holes, classify the data so the *class* of bug
becomes hard to write. Everything persisted falls on two axes —
regenerability and cost of regeneration — which collapse to four kinds:
| Kind | Regenerable? | Deletion policy |
|---|---|---|
| **Owned** — projection of the user's files | Yes, by rescan | Follows the files |
| **Authored** — user-created, no other copy | **No** | Explicit user action only |
| **Derived** — computed from owned | Yes, cheaply | Free; must never block owned deletion |
| **Cache** — network or dump sourced | Yes, expensively | TTL/age eviction, never cascade |
The classification is not just vocabulary — it produces the right fix for
each finding. Finding 1 is derived data acting as a referential parent of
owned data, which the taxonomy makes categorically illegal. Finding 2 is
cache data that never needed owner-linked cleanup at all; it wants age
eviction. Finding 3 is derived data that must be swept against a live set
rather than tracked individually.
A Go interface was considered and rejected: the only polymorphic consumer
is the janitor, the substrates have nothing in common (SQL rows, an FTS
virtual table, a view, three directories of JPEGs, a 900 MB index), and
provenance is a static fact better enforced by package boundaries than by
methods an implementation may lie about. A declarative catalog gets the
same benefit for a tenth of the cost.
## What shipped
**`backend/datamap`** — the catalog. Every table, view, and asset
directory declared with its `Kind`, its `Lifetime` (`cascade`, `set-null`,
`swept`, `retained`), and a note explaining the classification. Plain data
with no service dependencies, so tests can assert it against a live
schema. FTS5 shadow tables resolve to their parent.
Tests that give it teeth (`backend/datamap/datamap_test.go`):
- `TestCatalogCoversSchema` — every table in `sqlite_master` is claimed by
exactly one entry. **A new table fails the build until somebody states
what it is and how it dies.**
- `TestCatalogHasNoStaleEntries` — the reverse, catching drift.
- `TestNoActionForeignKeysAreDeclaredSwept` — a `NO ACTION` foreign key
blocks its parent's deletion, so its table must declare `swept`. This is
the exact shape of finding 1, now caught at CI time.
- `TestLifetimesMatchSchema` — declared cascade/set-null must match what
SQLite actually enforces.
- `TestAuthoredCascadesAreDeliberate` — authored data is unrecoverable, so
a cascade onto it needs an explicit exemption.
**`backend/maintenance`** — the janitor. A registry of named jobs with
per-job minimum intervals, run at startup-idle and on a 6h tick. Policies
follow the taxonomy: derived data sweeps against a live set, cache data
ages out. Registered in one place (`app.go: startJanitor`) so the full set
of janitorial work is a single visible list.
Jobs: `http-cache-evict` (6h), `covers-sweep` (24h, live set from
`cover_art` expanded via `CoverArtFileSet`), `artist-images-sweep` (24h,
keeps art for library artists indefinitely, evicts browsed-artist art
after 90d), `cover-art-proxy-sweep` (24h, 30d age eviction).
The covers sweep refuses to act on an empty live set — that means the
query failed to see the table, not that every cover is garbage.
**Leak tests** (`backend/library/leak_test.go`) — driven by the catalog
rather than a hardcoded list, so new tables are covered the moment they
are catalogued:
- `TestRemoveLibraryLeavesNoOwnedOrDerivedRows` — removing the only
library leaves no owned or derived rows, except those in
`staleTolerated` with a written reason.
- `TestRemoveLibraryPreservesAuthoredData` — authored data survives.
- `TestSweptTablesAreActuallySwept` — a table declaring `swept` that
nothing sweeps is caught.
All three were verified to fail when the finding-1 fix is reverted.
**Fixes**`tagging_items` cleared inside the removal transaction
(`crud.go` step 17); `CoverArtFileSet` expands originals to variants and
the legacy `_thumb` name; `Cache.Evict` logic moved into a registered job.
**Incidental:** `Library.emit``runtime.EventsEmit` calls `log.Fatalf`
on a context without a Wails runtime, which killed the test binary and
made the whole package untestable. All ten emits in the package now route
through a nil-safe helper. This also removes a real crash risk for
background workers that outlive their context.
## Follow-ups
**`audio_files` is a mixed-kind table.** `play_count`, `last_played`, and
`tag_status` are *authored* data living in an *owned* table. Orphan
cleanup treats the whole row as regenerable, which is why renaming a file
destroys its play count — the row is deleted and re-imported fresh. This
is the strongest argument for splitting authored per-track state into its
own table keyed by something more stable than a path. Related: an
audio-stream content hash (excluding tag blocks, so it survives
retagging) would let a rename be recognised as the same file. Deliberately
out of scope here; it is a schema change plus a rename-detection pass, not
a cleanup fix.
**Cascade adoption.** Fourteen of nineteen foreign keys are `NO ACTION`.
Converting them to `CASCADE` would delete a lot of hand-written orphan
sweeps, but SQLite cannot add `ON DELETE` via `ALTER TABLE` — each needs
the 12-step table rebuild. Note the ordering constraint: cascades delete
rows silently, so any code that collects file paths *before* deleting rows
(as `RemoveLibrary` does for cover art) breaks under cascade. Mark-and-
sweep must land first; the two compose, cascade plus path-collection does
not.
**Consolidate the ten orphan sweeps.** `DELETE ... WHERE id NOT IN (...)`
appears ten times across `crud.go`, `dbsync.go`, `smartplaylist.go`, and
`database.go`. One shared `sweepOrphans(tx)` would shrink the surface where
a new table can be forgotten. Worth doing opportunistically rather than as
a big-bang refactor.
**Storage settings pane.** The catalog knows every table and directory and
its kind; the janitor already computes bytes freed. A settings pane showing
per-kind disk usage with "clear cache" and "rebuild derived data" buttons
is now mostly a UI job.
@@ -0,0 +1,279 @@
# 003 — Download clients
**Status:** implemented (v1); follow-ups tracked below
**Branch:** main
**Created:** 2026-07-27
## Problem
YellowJacket can find music (`explore`), identify it (`autotag`), and
manage it (`library`) — but it can't acquire it. The one gap between
"you're missing this album" and "you own this album" is filled today by
the user alt-tabbing to some other tool.
The naive fix is an HTTP client for Soulseek and a shell-out to yt-dlp.
That produces two bespoke code paths with duplicated queueing, retry,
staging and import logic, and a third service means a third copy. The
services users want to connect are also not the same *kind* of thing —
some search, some transfer bytes, some are entire automation systems we
delegate to — so a single `DownloadClient` interface would be a lie that
every adapter partially implements.
## The role decomposition
Every candidate integration fills one or two of three roles:
| Service | Searches | Transports | Delegates |
|---|---|---|---|
| slskd (Soulseek) | ✅ | ✅ | |
| yt-dlp | ✅ | ✅ | |
| Lidarr | | | ✅ |
| Prowlarr | ✅ | | |
| qBittorrent / Transmission | | ✅ | |
| SABnzbd / NZBGet | | ✅ | |
So: three small interfaces, not one big one. A provider implements
whichever it supports and declares that in a capability struct, the same
way `jobs.Caps` lets the frontend render controls without switching on
`Kind`.
```go
// Searcher turns a request into ranked candidates.
type Searcher interface {
Search(ctx context.Context, req Request) ([]Candidate, error)
}
// Transporter moves a candidate's bytes to a local staging directory.
type Transporter interface {
Grab(ctx context.Context, c Candidate, dst string, p ProgressFunc) (Result, error)
Cancel(ctx context.Context, grabID string) error
}
// Delegator hands the whole request to an external manager and
// reports back when files land.
type Delegator interface {
Request(ctx context.Context, req Request) (string, error)
Poll(ctx context.Context, externalID string) (DelegateStatus, error)
}
```
A `Provider` is the registry entry: identity, config, health check, caps,
plus whichever of the three it satisfies. Search-only providers
(Prowlarr) are paired with a transport at grab time by protocol match
(`torrent` → qBittorrent, `usenet` → SABnzbd); providers that do both
are self-pairing.
## v1 decisions (settled)
- **On-demand only.** User-initiated "find this album" from an Explore
artist/album page or a missing-album row. No wanted list, no artist
monitoring, no quality-cutoff upgrades. The queue and pipeline built
here are exactly what monitoring would later sit on top of — see
Deferred.
- **Soulseek via slskd's REST API**, not a native protocol client. Same
adapter shape as everything else, no wire protocol, no credentials in
our process, fully testable against an `httptest` server. A native
provider can slot in behind `Searcher`/`Transporter` later with no
pipeline changes.
- **Stage → autotag → import.** Downloads land in a staging directory,
are matched against the intended release with the existing `autotag`
scorer, tagged, then moved into the library and scanned. Never write
into the library root directly.
- **All four provider families in v1**, sequenced so each phase proves a
different role shape (see Phases).
## Pipeline
```
Request (MBID-anchored where possible)
└─> fan-out Search across enabled providers (per-provider timeout)
└─> merge + rank Candidates
└─> user picks (or auto-pick above confidence threshold)
└─> Grab into staging/<request-id>/
└─> verify (audio decodes, expected track count)
└─> autotag against the intended release
└─> tagwriter writes tags
└─> move into library layout
└─> targeted incremental scan
```
The `Request` should carry a release-group or release MBID whenever the
user started from an Explore page, because that anchor is what makes the
autotag step reliable instead of a second guess. Free-text requests are
supported but flagged lower-confidence, and never auto-pick.
Staging lives under the user data dir, not the library. Partial grabs are
resumable where the provider supports it and swept on startup where it
doesn't.
## Candidate ranking
Two independent scores, kept separate:
1. **Match confidence** — does this candidate contain the release the
user asked for? Reuse `autotag`'s distance/alignment machinery on the
candidate's *filenames* (Soulseek gives paths, not tags), against the
expected tracklist from the explore index.
2. **Source quality** — format (FLAC > V0 > 320 > lower), bitrate,
completeness (file count vs. expected track count), source health
(slskd queue length and upload slots; seeders for torrents), and a
user-set per-provider priority.
Ranking presents both, because they trade off — a perfectly-matched
128kbps rip should lose to a well-matched FLAC, and the user should be
able to see why. Reusing `autotag.ScoreBreakdown`'s "explain the ranking"
pattern here is deliberate.
## Persistence
New tables (migration TBD, next free number):
- `download_providers` — id, kind, name, enabled, priority, config blob
(JSON), `created_at`. Non-secret config only.
- `download_requests` — id, source (`explore-album`, `explore-artist`,
`manual`), release_mbid / release_group_mbid, free-text query,
requested_at, state, resolved_download_id.
- `download_items` — one row per grab attempt: request_id, provider_id,
candidate JSON, state, bytes/total, staging path, error, timestamps.
**Secrets** (slskd API key, Lidarr/Prowlarr API keys, qBittorrent
password) do not go in the TOML config or the DB in plaintext. Use the OS
keyring where available with a clearly-labelled encrypted-file fallback,
and never log a config value from a provider's secret field. Open
question below on the exact library.
## Jobs integration
Add `jobs.KindDownload`. One job per request (not per file), with
`Stages` for search → grab → import so the existing detail panel renders
the pipeline for free. `Caps{Cancellable: true}`; pausable only for
providers that can resume. Per-provider concurrency caps and a global
cap, both configurable — hammering a Soulseek peer with eight parallel
transfers gets you queued or banned.
## Frontend
- New `download-providers` section in `config-page` (HTMX + templ, same
as existing settings) for provider CRUD, test-connection, priority.
- New `download-picker` Lit component: the ranked-candidate dialog,
invoked from Explore album/artist pages and from a missing-album row.
- `download-store.ts` subscribing to the existing `JobsChanged` event —
no new event channel needed for progress.
## Phases
Each phase is independently shippable and proves a distinct role shape.
1. **Core.** Interfaces, registry, `Request`/`Candidate`/`Result` types,
staging dir, ranking, the stage→autotag→import tail, jobs wiring,
schema, secret storage. Ships with a fake provider and full test
coverage of the pipeline. No real network.
2. **yt-dlp.** Subprocess provider: search + transport, no server for the
user to run, so it's the fastest path to an end-to-end working
feature. Proves the local-subprocess shape (binary discovery,
version checks, stdout progress parsing, sandboxing the arg list).
3. **slskd.** Remote search + transport over REST. Proves the remote
HTTP shape and is the highest-value source. This is where filename-
based match confidence earns its keep.
4. **Lidarr.** Delegate. Proves the fire-and-poll shape, where we don't
own the transfer and the "import" step is really "detect what Lidarr
already imported and reconcile".
5. **Prowlarr + qBittorrent/SABnzbd.** Proves split search/transport
pairing — the one case where two providers cooperate on a single
request.
## Risks and constraints
- **No bundled credentials, no default-on providers, no preconfigured
indexers.** Every provider is off until the user configures it. The
app ships the ability to connect to services the user already runs.
- **yt-dlp is a moving target.** Pin a minimum version, check it at
provider-enable time, and fail with a clear message rather than
parsing garbage output.
- **Filename-only matching is genuinely hard.** Soulseek results are
`\Music\Album (1997) [FLAC]\01 - Track.flac` at best. Budget real
effort for the path-parsing heuristics; `autotag/normalize.go` is the
starting point.
- **Partial and failed grabs must never reach the library.** The import
step is the only writer into library paths, and it runs after
verification. Staging sweep on startup.
- **Tests must not hit the network.** `httptest` servers for slskd/
Lidarr/Prowlarr, a stub binary for yt-dlp.
## Deferred
- Wanted list with background retry (the natural next plan).
- Artist monitoring + auto-grab of new releases — cheap once the wanted
list exists, because `explore`'s dump index already knows the full
discography and `library` already knows what's owned.
- Quality profiles and upgrade-if-better.
- Native Soulseek protocol client.
- Transmission/Deluge/NZBGet (same shape as their shipped siblings —
add on demand).
- Internet Archive / Bandcamp-collection providers: cheap REST adapters,
worth adding once the core is proven.
## Resolved questions
1. **Secret storage.** No keyring dependency was added. Credentials go
in a 0600 JSON file in the user data directory (`download-secrets.json`),
keyed by provider row ID. This is deliberately *not* encryption — a
key stored beside the data it unlocks protects nothing, and claiming
otherwise would be worse than being clear about it. What the file
mode buys is protection from other local users and from the config
file being pasted into a bug report. `SecretStore` is an interface so
an OS keyring backend can be added later without touching any
provider.
2. **Auto-pick.** Implemented behind `Downloads.AutoPick`, default off.
It requires an MBID-anchored request, match ≥ 0.85, quality ≥ 0.5,
and ≥ 0.08 of daylight over second place. Free-text requests can
never auto-pick, because there is no tracklist to be right about.
3. **Library layout.** Configurable path template, default
`{albumartist}/{album}/{track} {title}`. Segments are sanitized for
Windows-reserved characters and trailing dots/spaces so a library
synced between platforms does not produce unopenable files. Existing
files are never overwritten — a collision gets a numbered variant,
because the file already there may be a better copy the user owns.
4. **Entry point.** "Find this album" on the Explore album page, shown
only when a client is connected and the album is not already owned.
The artist-discography right-click is not wired up yet.
## What shipped
All five phases, ~4,500 lines with tests, `make lint` clean and the full
backend suite green (including under `-race`).
**Core** (`backend/download/`): `Searcher`/`Transporter`/`Delegator`
interfaces with capability-driven composition; `Request`/`Candidate`/
`Result` types; provider registry with self-registering adapters;
two-axis ranking; staging area with escape-guards and startup sweep;
verify → tag → import tail; jobs integration under `KindDownload`;
three tables catalogued in `datamap`.
**Providers**: yt-dlp (subprocess; assembles albums from per-track
searches, since a "full album" video cannot be imported as tracks),
slskd (remote search + transport, peer-health scoring, collects from the
daemon's own downloads folder), Lidarr (delegate; reconciles in place
rather than moving files out from under a system still managing them),
Prowlarr (search-only) paired at grab time with qBittorrent or SABnzbd.
**Frontend**: `download-store.ts`, `download-picker` + `candidate-row`
(two meters, not one blended score), `download-clients` settings section
rendering its forms from backend descriptors so a new adapter needs no
frontend change.
## Follow-ups
- **Resume across restart.** Live transfers are currently marked failed
on startup and their staging swept, because the transports do not
survive the process. slskd and qBittorrent can both resume in
principle; the item rows already carry what would be needed.
- ~~**Per-provider concurrency caps.**~~ Done in 004: per-kind defaults
(slskd 1, yt-dlp 2, torrent/usenet 4) with a per-provider override,
and the provider's slot is taken before the global one.
- **Prowlarr candidates score blind.** Indexer results carry no file
list, so match scoring has only the release title. Fetching the
torrent metadata before ranking would fix this and is the single
biggest ranking improvement available.
- ~~Wanted list, artist monitoring~~ — done in 004. Quality profiles
and upgrade-if-better remain deferred.
@@ -0,0 +1,163 @@
# 004 — Wanted list
**Status:** implemented
**Branch:** main
**Created:** 2026-07-29
**Follows:** 003-download-clients
## Problem
Plan 003 shipped a request as a heavyweight row: library, anchors,
cached tracklist, state machine, error text, cascading items. That is
the right shape for *one attempt to acquire something* and the wrong
shape for *the user wanting something*, and 003 used it for both.
The consequences showed up immediately. A request that found nothing was
marked `failed`, which is a lie — the album exists, no source had it
today. Retrying meant the user remembering to press a button. Wanting an
artist's future releases was not expressible at all. And a user who
acquired an album by other means kept a failed row about it forever.
## The model
A **want** is an MBID, what that MBID names, and retry bookkeeping.
That is all.
```
download_wants(mbid, entity, library_id, scope, secondary, state,
parent_id, attempts, last_error, next_try_at,
external_ids)
```
`entity` is the only type distinction, and it carries all the policy:
| entity | meaning |
|---|---|
| `artist` | a subscription. Never satisfied; each pass expands the discography into child wants |
| `release-group` | an album in the abstract — any release satisfies it |
| `release` | one specific edition |
| `recording` | one track |
`UNIQUE(mbid, library_id)` is load-bearing: it is what makes artist
expansion idempotent, so a subscription can re-run every pass and add
only what is genuinely new.
Requests did not go away — they became what they always were, the
ephemeral record of one attempt, with a nullable `want_id` back-link.
The lifetimes are now opposite and explicit: **a request is history, a
want is intent.**
### Nothing here fails
There is no `failed` want state. An attempt can fail; a want cannot. A
want that found nothing gets `attempts + 1`, a reason the user can read,
and a longer backoff — 6h doubling to a 7-day ceiling, jittered so a
list added in one sitting does not come due in one burst.
### Satisfaction is ownership, not download
A want retires when the *library* owns what it names, however it got
there — bought, ripped, copied in. Inferring satisfaction from our own
completed downloads would keep hunting for music already on disk.
### Artist scope defaults to `future`
Following an artist takes new releases only, and skips compilations,
live albums and remixes. `all` backfills the discography, and the user
can widen it from the wanted list. Subscribing should not silently queue
forty albums.
## The reconciler
A 6-hourly loop (plus on-demand, plus a 3-minute startup delay so the
explore index has loaded). Four steps, in this order:
1. **Expand** artist subscriptions into album wants — first, so step 2
sees them this pass rather than next.
2. **Retire** wants the library already owns.
3. **Sync** to clients that keep their own list.
4. **Attempt** a bounded batch (25) of due wants.
Everything the loop needs about music comes through a four-method
`CatalogPort`, adapted to the explore index in `backend/downloadcatalog.go`
— the composition root, so neither package learns about the other.
### Unattended grabs, and what stops them
`Manager.Attempt` is `Start` without the parking: it searches, and grabs
only if `AutoPickable` clears. When it does not, **nothing is
persisted** — no request row. A want retried weekly for a year would
otherwise leave fifty identical failed rows, none of them anything the
user can act on.
`AutoPickable` gained one condition: an anchored request with an empty
`Expected` is refused. An anchor with no tracklist behind it is an
anchor in name only, and match then rests on album/artist text — exactly
the evidence a wrong-album candidate also has. Nobody is watching a
reconcile pass.
## Per-provider concurrency
`Downloads.MaxConcurrent` was the only limit, and was never actually
applied (`SetMaxConcurrent` did not exist). Now:
- **slskd defaults to 1.** A Soulseek peer serves one file at a time
from one person's upload slot; asking for more gets you queued behind
everyone else at best. One is both the polite number and usually the
fastest.
- yt-dlp 2, torrent/usenet clients 4, overridable per provider via a
`maxConcurrent` field that `Register` appends automatically to any
descriptor declaring `CanTransport`.
- A grab takes its **provider's** slot before the global one, so a queue
on a busy slskd cannot sit on a global slot a usenet transfer could
have used. The transport is resolved before either slot is taken;
delegates take neither, since the transfer is happening inside another
system that is doing its own limiting.
## The Lister role
The fourth role, alongside Searcher/Transporter/Delegator. Lidarr
already models a want — a monitored artist or album — and it is always
on, where a desktop player is not. A subscription mirrored there keeps
working while the app is closed.
- `artist` → Lidarr artist, `monitor: future|missing` per scope
- `release-group`/`release` → monitored album
- `recording` → not pushed. Lidarr cannot say "one track", and
monitoring the album to get it downloads far more than was asked.
Sync is push-only in the loop; pulling happens only when the user
explicitly imports ("adopt the artists Lidarr already monitors", which
arrive at `future` scope). Removal **unmonitors**, never deletes — the
user's Lidarr may predate this app.
## Frontend
- `Wanted` view in the sidebar: Following / Looking for / Paused /
Found, with pause, remove, scope toggle and "Check now".
- "Want this" on the album page, "Follow for new releases" on the artist
page. The want button shows whether or not a client is connected —
wanting is durable and stays queued until one exists.
- `WantedListChanged` event, since a background pass changes the list
without the UI doing anything.
## Files
`backend/download/want.go`, `wantstore.go`, `reconcile.go`,
`provider_lidarr_list.go`; `backend/downloadcatalog.go`;
schema `download_wants.sql` + migration 48 for the two new
`download_requests` columns; `frontend/src/components/wanted-view/`.
## Deferred
- **Release-group wants are not retired by ownership of a specific
release.** The library indexes release groups and recordings, not
editions, so a `release` want is only satisfied by its own download
completing.
- **No recording lookup on the explore index**, so a track want relies
on the title the UI passed in. A want added as a bare recording MBID
has no tracklist and waits.
- Quality profiles and upgrade-if-better (from 003).
- Resume across restart (from 003) — still the largest gap, and it now
matters more: an unattended grab that dies on restart is retried by
the reconciler, but from zero bytes.
+38 -6
View File
@@ -10,7 +10,6 @@ YellowJacket is a cross-platform desktop music player built with Go (backend) an
Active and historical plans live in `.planning/`:
- `.planning/ROADMAP.md` — vision, capability set, milestone sequence.
- `.planning/NOTES.md` — gotchas, deferred items, open architecture questions, the "we already considered and rejected" list.
- `.planning/plans/active/` — work currently in progress (read first).
- `.planning/plans/pending/` — sequenced future work.
@@ -18,8 +17,6 @@ Active and historical plans live in `.planning/`:
Numbering is sequential and stable across status moves (a plan keeps its `NNN-` prefix as it migrates between `pending → active → completed`). Abandoned plans are deleted; paused work stays in `pending/`.
The legacy `.gsd/` directory is a snapshot of the prior GSD-CLI planning system. It's gitignored and will be removed once nothing relies on it.
## Commands
```bash
@@ -28,8 +25,8 @@ make dev-debug # Same as dev but with YJ_LOG_LEVEL=debug
make build-dev # Debug build with symbols
make build-prod # Production build (stripped, UPX-compressed)
make generate # Run code generators (sqlc + templ via go generate)
make lint # golangci-lint v2 (strict)
make test # All tests with race detector, 2min timeout
make lint # golangci-lint v2 (strict), both build configurations
make test # All tests with race detector, both build configurations
make vulncheck # govulncheck for CVEs
make setup # Install go tools, frontend deps, git hooks (lefthook)
```
@@ -44,6 +41,14 @@ go test -tags webkit2_41 ./backend/player/ # Single package
go test -tags webkit2_41 -run TestName ./backend/player/ # Single test
```
The central index builder is behind a second tag and is **not** covered
by the command above — `make test` runs both passes, but a manual run
needs it spelled out:
```bash
go test -tags "webkit2_41 indexbuild" ./backend/explore/... ./cmd/...
```
Audio playback integration tests require `YELLOWJACKET_INTEGRATION=1`.
## Architecture
@@ -55,13 +60,39 @@ Audio playback integration tests require `YELLOWJACKET_INTEGRATION=1`.
- `queue` — Track queue with shuffle (Fisher-Yates), repeat modes, auto-advance, and session persistence.
- `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan.
- `database` — SQLite via pure-Go driver. Schema in `database/sql/schemas/`, queries in `database/sql/queries/`. **sqlc** generates Go code into `database/sql/sqlcgen/` — never edit that directory by hand.
There is **no migration chain**: `applySchema` creates everything from
the schema files on every open (all DDL is `IF NOT EXISTS`), and a
database written by an older build is not supported. Changing the
schema means editing the file in `sql/schemas/`, not adding a step.
- `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC).
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
- `mediacontrols` — MPRIS integration on Linux via D-Bus.
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
- `explore` — Catalog search and browse over `explore_index`. See below.
- `profiling` — pprof server on `:6060`, compiled out in non-dev builds via build tags (`internal/dev/`).
**Explore catalog** (`backend/explore/`): the searchable MusicBrainz/
ListenBrainz catalog in `explore_index`. Deriving it from the MetaBrainz
dumps means streaming ~89 GB from a server that caps a client near
2 MB/s — half a day, for a catalog identical for every user. So that
work happens **once, centrally**, and users download the result:
- `cmd/indexbuild` builds the catalog from the dumps; `cmd/indexexport`
cuts it down to a shippable core and stamps its provenance.
`.gitea/workflows/index-artifact.yml` runs both and publishes the
compressed artifact under a fixed `latest` version.
- The app fetches and merges that artifact (`artifactfetch.go`,
`artifactimport.go`) — about a minute, versus a day.
- Everything the app does **not** need is behind the `indexbuild` build
tag (`dumpimport.go`, `dumpcounts.go`, `dumpcatalog.go`,
`dumpproject.go`, `dumpparallel.go`, `indexpatch.go`) so it is not
linked into the binary. `dumpbuild_stub.go` is the app-side entry
point; `dumpshared.go` holds what both sides use.
- The app keeps popularity current with the daily incremental dumps
(`dumpincremental.go`), and resolves artists outside the artifact's
coverage lazily on first view.
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated in `frontend/wailsjs/` — don't edit by hand.
**Event-driven communication**: Backend emits events via Wails runtime; frontend stores subscribe to them. Event names are constants in `backend/events/`.
@@ -82,7 +113,8 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
## Testing
Tests use `database.NewTestDB(t)` for in-memory SQLite with full schema. Test audio fixtures live in `test_data/music_library_test/`. Table-driven tests are the norm.
Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same
`applySchema` production uses so the two cannot diverge. Test audio fixtures live in `test_data/music_library_test/`. Table-driven tests are the norm.
## Git Workflow
+73
View File
@@ -36,6 +36,73 @@ fresh-install: setup generate clean
esac; \
go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
# Named, persistent sandboxes: `make sandbox foo` runs dev against
# $(FRESH_HOME_BASE)/yellowjacket-sandbox-foo, creating it on first use
# and reusing it (never deleting) afterward, so you can keep several
# long-lived states around — one with an imported search index, one with
# a small library, etc. `make sandbox-foo` is the same thing.
#
# `make sandboxes` lists the ones that exist.
#
# `make sandbox-rm foo [bar ...]` deletes them again, after confirming.
#
# The bare words after `sandbox` / `sandbox-rm` are extra make goals, so
# they need do-nothing rules to keep make from complaining. Those rules
# exist only when one of those is the first goal, so typos in other
# targets still fail loudly.
SANDBOX_DIR = $(FRESH_HOME_BASE)/yellowjacket-sandbox
ifneq (,$(filter $(firstword $(MAKECMDGOALS)),sandbox sandbox-rm))
SANDBOX_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
SANDBOX_NAME := $(firstword $(SANDBOX_ARGS))
$(foreach a,$(SANDBOX_ARGS),$(eval $(a):;@:))
endif
sandbox: ## Run dev against a named, persistent YJ_HOME: make sandbox <name>
@if [ -z "$(SANDBOX_NAME)" ]; then \
echo "usage: make sandbox <name> (e.g. make sandbox foo)" >&2; exit 2; \
fi
@$(MAKE) --no-print-directory sandbox-$(SANDBOX_NAME)
sandbox-rm: ## Delete named sandboxes: make sandbox-rm <name> [name ...]
@if [ -z "$(SANDBOX_ARGS)" ]; then \
echo "usage: make sandbox-rm <name> [name ...]" >&2; exit 2; \
fi
@set -e; \
targets=""; \
for n in $(SANDBOX_ARGS); do \
d="$(SANDBOX_DIR)-$$n"; \
if [ -d "$$d" ]; then \
echo " $$(du -sh "$$d" 2>/dev/null | cut -f1) $$d"; \
targets="$$targets $$d"; \
else \
echo " (no such sandbox: $$n)" >&2; \
fi; \
done; \
if [ -z "$$targets" ]; then exit 1; fi; \
if [ "$(FORCE)" != "1" ]; then \
printf "delete the above? [y/N] "; read -r ans; \
case "$$ans" in y|Y|yes|YES) ;; *) echo "aborted"; exit 1 ;; esac; \
fi; \
rm -rf $$targets; \
echo "==> removed"
sandbox-%: setup generate clean
if [ -f .env ]; then set -a; . ./.env; set +a; fi; \
export YJ_HOME="$(SANDBOX_DIR)-$*"; \
mkdir -p "$$YJ_HOME"; \
echo "==> sandbox '$*' YJ_HOME=$$YJ_HOME"; \
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
esac; \
go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
sandboxes: ## List existing named sandboxes
@ls -d "$(SANDBOX_DIR)"-* 2>/dev/null \
| sed 's|.*/yellowjacket-sandbox-| |' \
|| echo " (none)"
.PHONY: sandbox sandbox-rm sandboxes
build-dev: generate
go tool wails build -tags webkit2_41 -debug -clean -ldflags "$(LDFLAGS)"
@@ -54,9 +121,15 @@ generate:
lint:
go tool golangci-lint run
go tool golangci-lint run --build-tags indexbuild
# Two passes: the app build, then the `indexbuild` build that adds the
# CI-only dump importer. Without the second pass nothing would compile
# or exercise backend/explore/dump*.go or cmd/indexbuild at all.
test:
go test -tags webkit2_41 -race -count=1 -timeout 120s ./...
go test -tags "webkit2_41 indexbuild" -race -count=1 -timeout 300s \
./backend/explore/... ./cmd/...
vulncheck:
go tool govulncheck ./...
+155
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"path/filepath"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
@@ -18,10 +19,13 @@ import (
"yellowjacket/backend/config"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
"yellowjacket/backend/download"
"yellowjacket/backend/events"
"yellowjacket/backend/explore"
"yellowjacket/backend/frontendutil"
"yellowjacket/backend/jobs"
"yellowjacket/backend/library"
"yellowjacket/backend/maintenance"
"yellowjacket/backend/mediacontrols"
"yellowjacket/backend/player"
"yellowjacket/backend/playlist"
@@ -45,9 +49,13 @@ type YellowJacketApp struct {
queue *queue.Queue
explore *explore.Service
autotag *autotagservice.Service
downloads *download.Manager
downloadSvc *download.Service
wanted *download.Reconciler
jobs *jobs.Registry
mediaControls mediacontrols.Handler
tagWriter *tagwriter.TagWriter
janitor *maintenance.Runner
appContext context.Context
appConfig *config.Config
startupErr error
@@ -65,6 +73,7 @@ func NewYellowJacketApp(
logger: logger,
assetHandler: assetHandler,
appContext: context.Background(),
janitor: maintenance.NewRunner(logger),
}
// create database
@@ -168,6 +177,16 @@ func NewYellowJacketApp(
yjApp.tagWriter,
)
// Create the download subsystem. Acquiring music is optional: a
// failure here (unwritable data dir, say) must not stop the app
// from playing the library the user already has, so it is logged
// and the feature stays unavailable rather than fatal.
if err := yjApp.initDownloads(); err != nil {
yjApp.logger.Error(
"download clients unavailable", "error", err,
)
}
yjApp.FEBindings = []any{
yjApp.FrontendUtil,
yjApp.appConfig,
@@ -181,9 +200,51 @@ func NewYellowJacketApp(
jobs.NewService(yjApp.jobs),
}
if yjApp.downloadSvc != nil {
yjApp.FEBindings = append(yjApp.FEBindings, yjApp.downloadSvc)
}
return yjApp, nil
}
// initDownloads builds the download subsystem: staging area, secret
// store, importer and manager, plus the Wails-bound service.
func (yj *YellowJacketApp) initDownloads() error {
logger := yj.logger.WithGroup("download")
staging, err := download.NewStaging(logger)
if err != nil {
return fmt.Errorf("could not create download staging: %w", err)
}
secrets, err := download.NewFileSecretStore()
if err != nil {
return fmt.Errorf("could not create download secret store: %w", err)
}
store := download.NewStore(yj.database)
importer := download.NewImporter(logger, staging, yj.tagWriter, yj.library)
yj.downloads = download.NewManager(
logger, store, secrets, staging, importer, yj.library,
)
yj.downloads.SetJobRegistry(yj.jobs)
yj.downloadSvc = download.NewService(logger, yj.downloads, store, secrets)
// The wanted list needs the explore index to know what an artist
// released and what the library already owns, so it is wired here
// where both exist. The reconcile loop itself is not started until
// the Wails runtime is up.
yj.wanted = download.NewReconciler(
logger, store, yj.downloads, newExploreCatalog(yj.explore),
)
yj.downloadSvc.SetReconciler(yj.wanted)
return nil
}
// playerAdapter wraps *player.Player to satisfy the tagwriter.PlayerStopper
// interface, breaking the import cycle between tagwriter and player.
type playerAdapter struct{ p *player.Player }
@@ -194,6 +255,46 @@ func (a *playerAdapter) CurrentFilePath() string {
func (a *playerAdapter) StopAndRelease() { a.p.UnloadTrack() }
// initDownloadRuntime brings the download subsystem up once the Wails
// runtime exists: it applies the user's import layout, builds providers
// from stored config, and clears staging left by a previous run.
//
// Provider construction and the sweep both touch the network and the
// filesystem, so they run in the background — a slow or unreachable
// download client must not delay the window appearing.
func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
cfg := yj.appConfig.Downloads
if cfg == nil {
cfg = &download.UserConfig{}
cfg.ApplyDefaults()
}
yj.downloads.SetImportOptions(download.ImportOptions{
LibraryRoot: yj.appConfig.GetLibraryDirectory(),
PathTemplate: cfg.PathTemplate,
})
yj.downloads.SetMaxConcurrent(cfg.MaxConcurrent)
go func() {
if err := yj.downloads.Reload(ctx); err != nil {
yj.logger.Warn("could not load download providers", "error", err)
}
yj.downloads.Sweep(ctx)
}()
if yj.wanted == nil {
return
}
yj.wanted.SetInterval(cfg.WantedInterval())
yj.wanted.SetBatch(cfg.WantedBatch)
yj.wanted.SetOnChange(func() {
wailsruntime.EventsEmit(ctx, events.WantedListChanged)
})
yj.wanted.Start(ctx)
}
// WindowConfig returns the window configuration for use by the host.
func (yj *YellowJacketApp) WindowConfig() *config.WindowConfig {
return yj.appConfig.Window
@@ -234,6 +335,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.autotag.SetContext(ctx)
yj.jobs.SetContext(ctx)
if yj.downloadSvc != nil {
yj.downloadSvc.SetContext(ctx)
yj.initDownloadRuntime(ctx)
}
// Bring back jobs the user paused before the last shutdown, still
// paused. Must run before the soft scan in OnDomReady, which
// checks these records so it does not restart a paused library.
@@ -480,5 +586,54 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
// every app launch is fine; previously-scored items are
// skipped (the worker filters score IS NULL).
yj.autotag.StartBackgroundPrefetch()
// Start the janitor last: its sweeps compare against live data,
// so running them after the scan and index work has settled
// avoids deleting something a running import is about to
// reference. Each job enforces its own minimum interval, so the
// daily tick is a cheap no-op most of the time.
yj.startJanitor()
}()
}
// janitorTick is how often the maintenance runner wakes up. Individual
// jobs enforce their own minimum intervals, so most ticks do nothing.
const janitorTick = 6 * time.Hour
// startJanitor registers the maintenance jobs and starts the background
// runner. Every job is registered here rather than at each package's
// init, so the full set of janitorial work is one visible list — a cache
// that forgets to register is missing from this function, which is
// harder to overlook than a function nobody calls.
func (yj *YellowJacketApp) startJanitor() {
coversDir, err := coverart.CoversDir()
if err != nil {
yj.logger.Warn("janitor: could not resolve covers directory",
"err", err)
return
}
dataDir, err := system.GetUserDataDirPath()
if err != nil {
yj.logger.Warn("janitor: could not resolve user data directory",
"err", err)
return
}
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
yj.database, coversDir, library.CoverArtFileSet,
))
yj.janitor.Register(maintenance.OrphanedArtistImagesJob(
yj.database, filepath.Join(dataDir, explore.ArtistImageDirName),
))
yj.janitor.Register(maintenance.ExpiredProxyCacheJob(
filepath.Join(dataDir, explore.CoverArtCacheDirName),
))
yj.logger.Info("janitor started", "jobs", yj.janitor.JobNames())
yj.janitor.Start(yj.appContext, janitorTick)
}
+10
View File
@@ -192,6 +192,16 @@ func rotateEndWord(s string) string {
return s
}
// TitleSimilarity exposes titleSimilarity for callers outside the
// package that compare music metadata strings and should get the same
// answer the tagger would. The download pipeline uses it to match
// candidate filenames against an expected tracklist — Soulseek and
// torrent results carry paths, not tags, so filename comparison is the
// only signal available before the bytes arrive.
func TitleSimilarity(a, b string) float64 {
return titleSimilarity(a, b)
}
// titleSimilarity returns a score in [0, 1] from stringDist. 1.0
// means identical after normalization, 0.0 means fully dissimilar.
func titleSimilarity(a, b string) float64 {
+8
View File
@@ -12,6 +12,7 @@ import (
"github.com/BurntSushi/toml"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/download"
"yellowjacket/backend/events"
"yellowjacket/backend/favorites"
"yellowjacket/backend/library"
@@ -39,6 +40,7 @@ type Config struct {
TrackList *tracklist.Config `toml:"TrackList"`
Favorites *favorites.Config `toml:"Favorites"`
Shortcuts *shortcuts.Config `toml:"Shortcuts"`
Downloads *download.UserConfig `toml:"Downloads"`
}
// NewConfig creates a new config by loading it from disk.
@@ -258,6 +260,12 @@ func (c *Config) applyDefaults() {
}
c.Shortcuts.ApplyDefaults()
if c.Downloads == nil {
c.Downloads = &download.UserConfig{}
}
c.Downloads.ApplyDefaults()
}
// SetContext sets the Wails runtime context for event emission.
+135 -3709
View File
File diff suppressed because it is too large Load Diff
+8 -112
View File
@@ -12,7 +12,7 @@ import (
// Migration 6 integration tests
// ---------------------------------------------------------------------------
func TestMigration6FreshDB(t *testing.T) {
func TestSchemaCreatesLibrariesTable(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
@@ -172,32 +172,6 @@ func TestMigration6FreshDB(t *testing.T) {
t.Error("track_metadata VIEW does not contain library_id")
}
// Verify user_version >= 7.
var version int
verRows, err := db.QueryContext("PRAGMA user_version")
if err != nil {
t.Fatalf("PRAGMA user_version: %v", err)
}
if !verRows.Next() {
_ = verRows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := verRows.Scan(&version); err != nil {
_ = verRows.Close()
t.Fatalf("scan user_version: %v", err)
}
_ = verRows.Close()
if version < 7 {
t.Errorf("user_version = %d, want >= 7", version)
}
// Verify libraries table has only the sentinel row on fresh DB.
count, err := db.Queries.CountLibraries(db.Ctx)
if err != nil {
@@ -213,7 +187,7 @@ func TestMigration6FreshDB(t *testing.T) {
}
}
func TestMigration6LibraryQueries(t *testing.T) {
func TestLibraryQueries(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
@@ -331,7 +305,7 @@ func TestMigration6LibraryQueries(t *testing.T) {
}
}
func TestMigration6PhantomPlaylistTracks(t *testing.T) {
func TestPhantomPlaylistTracksAreCleaned(t *testing.T) {
t.Parallel()
db, libID := NewTestDBWithLibrary(t, "Test", "/test/music")
@@ -462,7 +436,7 @@ func TestMigration6PhantomPlaylistTracks(t *testing.T) {
}
}
func TestMigration6AudioFilesLibraryFK(t *testing.T) {
func TestAudioFilesLibraryForeignKey(t *testing.T) {
t.Parallel()
db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib")
@@ -523,7 +497,7 @@ func TestMigration6AudioFilesLibraryFK(t *testing.T) {
}
}
func TestMigration6TrackMetadataViewHasLibraryID(t *testing.T) {
func TestTrackMetadataViewHasLibraryID(t *testing.T) {
t.Parallel()
db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib")
@@ -592,37 +566,11 @@ func TestMigration6TrackMetadataViewHasLibraryID(t *testing.T) {
// Migration 9 integration tests
// ---------------------------------------------------------------------------
func TestMigration9SmartPlaylistColumns(t *testing.T) {
func TestSmartPlaylistColumns(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
// Verify user_version >= 9.
var version int
verRows, err := db.QueryContext("PRAGMA user_version")
if err != nil {
t.Fatalf("PRAGMA user_version: %v", err)
}
if !verRows.Next() {
_ = verRows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := verRows.Scan(&version); err != nil {
_ = verRows.Close()
t.Fatalf("scan user_version: %v", err)
}
_ = verRows.Close()
if version < 9 {
t.Errorf("user_version = %d, want >= 9", version)
}
// Verify playlists table has is_smart and smart_rules columns.
hasSmart := false
hasRules := false
@@ -774,37 +722,11 @@ func TestMigration9SmartPlaylistColumns(t *testing.T) {
// Migration 10 — play history tracking
// ---------------------------------------------------------------------------
func TestMigration10PlayHistory(t *testing.T) {
func TestPlayHistoryTable(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
// Verify user_version >= 10.
var version int
verRows, err := db.QueryContext("PRAGMA user_version")
if err != nil {
t.Fatalf("PRAGMA user_version: %v", err)
}
if !verRows.Next() {
_ = verRows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := verRows.Scan(&version); err != nil {
_ = verRows.Close()
t.Fatalf("scan user_version: %v", err)
}
_ = verRows.Close()
if version < 10 {
t.Errorf("user_version = %d, want >= 10", version)
}
// Verify play_history table exists.
var tableCount int64
@@ -1058,7 +980,7 @@ func TestMigration10PlayHistory(t *testing.T) {
// Migration 11 — explore_cache table
// ---------------------------------------------------------------------------
func TestMigration11ExploreCache(t *testing.T) {
func TestHTTPCacheTable(t *testing.T) {
t.Parallel()
// explore_cache was split into http_cache + artist_metadata by
@@ -1069,32 +991,6 @@ func TestMigration11ExploreCache(t *testing.T) {
db := NewTestDB(t)
// Verify user_version >= 11.
var version int
verRows, err := db.QueryContext("PRAGMA user_version")
if err != nil {
t.Fatalf("PRAGMA user_version: %v", err)
}
if !verRows.Next() {
_ = verRows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := verRows.Scan(&version); err != nil {
_ = verRows.Close()
t.Fatalf("scan user_version: %v", err)
}
_ = verRows.Close()
if version < 11 {
t.Errorf("user_version = %d, want >= 11", version)
}
// Verify explore_cache table exists.
var tableCount int64
+159
View File
@@ -0,0 +1,159 @@
package database
import (
"testing"
)
// seedExploreRow inserts one explore_index row.
func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) {
t.Helper()
if _, err := db.ExecContext(`
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
VALUES ('recording', ?, ?, ?, '')
`, mbid, title, artist); err != nil {
t.Fatalf("seed %s: %v", mbid, err)
}
}
// ftsMatches returns how many FTS rows match a query.
func ftsMatches(t *testing.T, db *DB, query string) int {
t.Helper()
rows, err := db.QueryContext(
"SELECT COUNT(*) FROM explore_index_fts WHERE explore_index_fts MATCH ?", query,
)
if err != nil {
t.Fatalf("fts query %q: %v", query, err)
}
defer func() { _ = rows.Close() }()
n := 0
if rows.Next() {
if err := rows.Scan(&n); err != nil {
t.Fatalf("scan fts count: %v", err)
}
}
if err := rows.Err(); err != nil {
t.Fatalf("fts rows: %v", err)
}
return n
}
// Rows written while FTS sync is suspended are invisible to search
// until the window closes — and fully searchable afterwards. This is
// the contract the dump import's bulk-load path depends on.
func TestExploreFTSSuspendResumeIndexesBulkRows(t *testing.T) {
db := NewTestDB(t)
seedExploreRow(t, db, "mbid-before", "Before Suspend", "Artist One")
if got := ftsMatches(t, db, "Before"); got != 1 {
t.Fatalf("matches for pre-suspend row = %d, want 1", got)
}
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("suspend: %v", err)
}
seedExploreRow(t, db, "mbid-during", "During Suspend", "Artist Two")
if got := ftsMatches(t, db, "During"); got != 0 {
t.Errorf("matches while suspended = %d, want 0 (triggers should be off)", got)
}
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("resume: %v", err)
}
if got := ftsMatches(t, db, "During"); got != 1 {
t.Errorf("matches for bulk-loaded row after resume = %d, want 1", got)
}
if got := ftsMatches(t, db, "Before"); got != 1 {
t.Errorf("matches for pre-suspend row after resume = %d, want 1", got)
}
}
// The import wipes explore_index before reassembling it. With the
// triggers suspended that DELETE writes no FTS delete-markers, so the
// rebuild must be what clears the old rows out of search.
func TestExploreFTSResumeDropsDeletedRows(t *testing.T) {
db := NewTestDB(t)
seedExploreRow(t, db, "mbid-stale", "Stale Recording", "Old Artist")
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("suspend: %v", err)
}
if _, err := db.ExecContext("DELETE FROM explore_index"); err != nil {
t.Fatalf("wipe: %v", err)
}
seedExploreRow(t, db, "mbid-fresh", "Fresh Recording", "New Artist")
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("resume: %v", err)
}
if got := ftsMatches(t, db, "Stale"); got != 0 {
t.Errorf("matches for wiped row = %d, want 0", got)
}
if got := ftsMatches(t, db, "Fresh"); got != 1 {
t.Errorf("matches for reassembled row = %d, want 1", got)
}
}
// resumeFTS runs from a defer as well as at its natural point in the
// pipeline, so a second call must be harmless.
func TestExploreFTSResumeIsIdempotent(t *testing.T) {
db := NewTestDB(t)
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("suspend: %v", err)
}
seedExploreRow(t, db, "mbid-a", "Repeatable Resume", "Artist")
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("first resume: %v", err)
}
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("second resume: %v", err)
}
if got := ftsMatches(t, db, "Repeatable"); got != 1 {
t.Errorf("matches after repeated resume = %d, want 1", got)
}
// Triggers must still be live for ordinary writes after the window.
seedExploreRow(t, db, "mbid-b", "Postwindow Row", "Artist")
if got := ftsMatches(t, db, "Postwindow"); got != 1 {
t.Errorf("matches for row written after resume = %d, want 1", got)
}
}
// Suspending twice must not fail — the triggers are simply already gone.
func TestExploreFTSSuspendIsIdempotent(t *testing.T) {
db := NewTestDB(t)
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("first suspend: %v", err)
}
if err := db.SuspendExploreIndexFTS(); err != nil {
t.Fatalf("second suspend: %v", err)
}
if err := db.ResumeExploreIndexFTS(); err != nil {
t.Fatalf("resume: %v", err)
}
}
+5 -33
View File
@@ -1062,45 +1062,17 @@ func TestClearSearchIndexPreservesSchema(t *testing.T) {
}
// ---------------------------------------------------------------------------
// Migration test
// Schema constraint tests
// ---------------------------------------------------------------------------
func TestMigrationsApplied(t *testing.T) {
func TestSearchIndexSchema(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
// Verify user_version >= 3 (all 3 migrations applied).
// Use QueryContext + immediate Scan + Close to release the
// single connection before subsequent ExecContext calls.
var version int
rows, err := db.QueryContext("PRAGMA user_version")
if err != nil {
t.Fatalf("PRAGMA user_version: %v", err)
}
if !rows.Next() {
_ = rows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := rows.Scan(&version); err != nil {
_ = rows.Close()
t.Fatalf("scan user_version: %v", err)
}
_ = rows.Close()
if version < 3 {
t.Errorf("user_version = %d, want >= 3", version)
}
// Verify the UNIQUE index from migration 3 exists by attempting
// a duplicate insert. First, create the prerequisite rows.
_, err = db.ExecContext(
// Verify the UNIQUE index on artist_credit_artist exists by
// attempting a duplicate insert. First, create the prerequisites.
_, err := db.ExecContext(
"INSERT INTO artists (id, name) VALUES (1, 'Test')",
)
if err != nil {
+17 -3
View File
@@ -6,8 +6,8 @@ RETURNING *;
INSERT INTO audio_files (
file_path, length_milliseconds, file_type_id, recording_id,
sample_rate, bit_depth, channels, bitrate, file_size, basename,
library_id, group_key, tag_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
library_id, group_key, tag_status, modified_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetAudioFileGroupKey :one
@@ -32,9 +32,23 @@ WHERE id = ?;
-- name: UpdateAudioFileRecording :exec
UPDATE audio_files
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, length_milliseconds = ?, modified_at = ?
WHERE id = ?;
-- name: UpdateAudioFileStat :exec
-- Records the on-disk mtime/size without re-reading tags. Used to
-- backfill the staleness baseline for files the scan skipped, and to
-- re-baseline after YellowJacket's own tag writer rewrites a file.
UPDATE audio_files
SET modified_at = ?, file_size = ?
WHERE id = ?;
-- name: GetLibraryMaxModifiedAt :one
-- Newest recorded mtime in a library, for the startup soft scan. 0 when
-- the library is empty or no row has a baseline yet.
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
WHERE library_id = ?;
-- name: DeleteAudioFile :exec
DELETE FROM audio_files
WHERE id = ?;
+203
View File
@@ -0,0 +1,203 @@
-- name: ListDownloadProviders :many
SELECT id, kind, name, enabled, priority, settings, created_at
FROM download_providers
ORDER BY priority DESC, name;
-- name: GetDownloadProvider :one
SELECT id, kind, name, enabled, priority, settings, created_at
FROM download_providers
WHERE id = ?;
-- name: CreateDownloadProvider :one
INSERT INTO download_providers (kind, name, enabled, priority, settings)
VALUES (?, ?, ?, ?, ?)
RETURNING id;
-- name: UpdateDownloadProvider :exec
UPDATE download_providers
SET name = ?, enabled = ?, priority = ?, settings = ?
WHERE id = ?;
-- name: DeleteDownloadProvider :exec
DELETE FROM download_providers
WHERE id = ?;
-- name: CreateDownloadRequest :exec
INSERT INTO download_requests (
id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
-- name: GetDownloadRequest :one
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
WHERE id = ?;
-- name: ListDownloadRequests :many
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
ORDER BY created_at DESC
LIMIT ?;
-- name: ListLiveDownloadRequests :many
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
WHERE state NOT IN ('complete', 'cancelled', 'failed')
ORDER BY created_at;
-- name: SetDownloadRequestState :exec
UPDATE download_requests
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: DeleteDownloadRequest :exec
DELETE FROM download_requests
WHERE id = ?;
-- name: CreateDownloadItem :exec
INSERT INTO download_items (
id, request_id, provider_id, transport_id, external_id,
candidate, state, staging_dir, bytes_total
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
-- name: GetDownloadItem :one
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
WHERE id = ?;
-- name: ListDownloadItemsForRequest :many
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
WHERE request_id = ?
ORDER BY created_at;
-- name: ListLiveDownloadItems :many
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
WHERE state NOT IN ('complete', 'cancelled', 'failed')
ORDER BY created_at;
-- name: SetDownloadItemState :exec
UPDATE download_items
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: SetDownloadItemProgress :exec
UPDATE download_items
SET bytes_done = ?, bytes_total = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: SetDownloadItemExternalID :exec
UPDATE download_items
SET external_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: SetDownloadItemImported :exec
UPDATE download_items
SET imported_paths = ?, state = 'complete', error = '',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: DeleteFinishedDownloadRequests :exec
DELETE FROM download_requests
WHERE state IN ('complete', 'cancelled', 'failed');
-- ---------------------------------------------------------------------
-- Wants
-- ---------------------------------------------------------------------
-- name: UpsertDownloadWant :one
-- Adding something already wanted is not an error and must not reset
-- the retry clock, so the conflict path only refreshes display text and
-- un-pauses nothing. scope and secondary are updated because asking
-- again with a wider scope is a real change of intent.
INSERT INTO download_wants (
mbid, entity, library_id, artist, title, scope, secondary,
parent_id, next_try_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(mbid, library_id) DO UPDATE SET
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
ELSE download_wants.artist END,
title = CASE WHEN excluded.title <> '' THEN excluded.title
ELSE download_wants.title END,
scope = excluded.scope,
secondary = excluded.secondary,
updated_at = CURRENT_TIMESTAMP
RETURNING id;
-- name: GetDownloadWant :one
SELECT * FROM download_wants WHERE id = ?;
-- name: GetDownloadWantByMBID :one
SELECT * FROM download_wants WHERE mbid = ? AND library_id = ?;
-- name: ListDownloadWants :many
SELECT * FROM download_wants
ORDER BY
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
artist, title;
-- name: ListDownloadWantsByEntity :many
SELECT * FROM download_wants
WHERE entity = ? AND state = ?
ORDER BY id;
-- name: ListDueDownloadWants :many
-- Everything the reconciler should act on this pass: wanted, not an
-- artist subscription (those expand rather than download), and either
-- never tried or past its backoff.
SELECT * FROM download_wants
WHERE state = 'wanted'
AND entity <> 'artist'
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
ORDER BY attempts, created_at
LIMIT ?;
-- name: ListChildDownloadWants :many
SELECT * FROM download_wants WHERE parent_id = ? ORDER BY id;
-- name: SetDownloadWantState :exec
UPDATE download_wants
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: RecordDownloadWantAttempt :exec
UPDATE download_wants
SET attempts = attempts + 1,
last_error = ?,
last_tried_at = CURRENT_TIMESTAMP,
next_try_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: SatisfyDownloadWant :exec
UPDATE download_wants
SET state = 'satisfied', last_error = '', next_try_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: SetDownloadWantExternalIDs :exec
UPDATE download_wants
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: DeleteDownloadWant :exec
DELETE FROM download_wants WHERE id = ?;
-- name: DeleteSatisfiedDownloadWants :exec
DELETE FROM download_wants WHERE state = 'satisfied';
@@ -1,7 +0,0 @@
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
autotag_warning_acked INTEGER NOT NULL DEFAULT 0
);
@@ -11,3 +11,6 @@ CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_artist_id
CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_credit_id
ON artist_credit_artist(credit_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_credit_artist_unique
ON artist_credit_artist(artist_id, credit_id);
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS artist_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artist_mbid TEXT NOT NULL,
source TEXT NOT NULL,
source_url TEXT NOT NULL,
file_path TEXT NOT NULL,
is_primary INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
width INTEGER,
height INTEGER,
file_size INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_artist_images_mbid
ON artist_images(artist_mbid);
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
ON artist_images(artist_mbid, source, source_url);
@@ -2,6 +2,8 @@
-- Sources: audiodb, fanart, wikidata-p18, wikipedia-lead, mb:artist-rels.
-- No TTL — this data changes very rarely and is the backing store for
-- the artist detail page.
CREATE TABLE IF NOT EXISTS artist_metadata (
mbid TEXT NOT NULL,
source TEXT NOT NULL,
@@ -9,4 +11,5 @@ CREATE TABLE IF NOT EXISTS artist_metadata (
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (mbid, source)
);
CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid ON artist_metadata(mbid);
+2
View File
@@ -3,3 +3,5 @@ CREATE TABLE IF NOT EXISTS artists (
name TEXT NOT NULL UNIQUE,
mbid TEXT
);
CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL;
+17 -11
View File
@@ -18,22 +18,28 @@ CREATE TABLE IF NOT EXISTS audio_files (
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
)),
group_key TEXT NOT NULL DEFAULT '',
-- File mtime as a Unix timestamp in seconds, captured at import.
-- Compared against the on-disk mtime during a scan to detect files
-- another application retagged in place. 0 means "never recorded"
-- (rows predating migration 47) and is treated as not-stale so an
-- upgrade does not re-import the whole library.
modified_at int NOT NULL DEFAULT 0,
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(library_id) REFERENCES libraries(id)
);
CREATE INDEX IF NOT EXISTS idx_audio_files_basename
ON audio_files(basename);
CREATE INDEX IF NOT EXISTS idx_audio_files_group_key
ON audio_files(group_key) WHERE group_key != '';
CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
ON audio_files(library_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id
ON audio_files(recording_id);
-- idx_audio_files_library_id is created by migration 6 (not here) because
-- on existing databases this schema file is a no-op (CREATE TABLE IF NOT EXISTS)
-- and the library_id column doesn't exist until the migration adds it.
--
-- idx_audio_files_tag_status_untagged + idx_audio_files_group_key are
-- created by migrations 31 and 32 for the same reason — on a pre-31
-- database the partial index predicates (`WHERE tag_status = '...'`
-- and `WHERE group_key != ''`) would reference columns that don't
-- yet exist, since CREATE TABLE IF NOT EXISTS does not add columns
-- to existing tables. sqlc still sees the columns above, and fresh
-- DBs pick up the indexes inside the migrations.
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
ON audio_files(library_id) WHERE tag_status = 'untagged';
@@ -0,0 +1,49 @@
-- One row per grab attempt against one candidate. A request can have
-- several: the first pick stalls, the user picks another, or a
-- search-only provider's candidate is fetched by a separate transport
-- (in which case provider_id is the searcher and transport_id is the
-- fetcher).
--
-- `candidate` is the full ranked Candidate as JSON. It is stored
-- rather than re-derived because the provider's result set is
-- ephemeral — a Soulseek peer that had the files an hour ago may be
-- offline now, and the item still has to render in the UI and explain
-- why it was chosen.
--
-- external_id holds a delegating manager's own identifier (a Lidarr
-- queue id), which is how polling finds the record again after a
-- restart.
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 is a JSON array of the library paths the files
-- ended up at, so an import can be undone without guessing.
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);
@@ -0,0 +1,28 @@
-- Download clients the user has connected: an slskd daemon, a Lidarr
-- instance, yt-dlp on PATH. One row per configured instance, so two
-- Prowlarr servers or two Soulseek accounts coexist.
--
-- Secrets (API keys, passwords) are NOT stored here. They live in a
-- 0600 file keyed by this row's id, so this table can be dumped into a
-- bug report without redaction. `settings` holds only non-sensitive
-- values (host, port, category, output format) as a JSON object.
--
-- `kind` names the adapter implementation and is looked up in the
-- provider registry at startup; a row whose kind no longer exists is
-- reported to the user rather than silently dropped.
CREATE TABLE IF NOT EXISTS download_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
-- priority breaks ties between providers that found equally good
-- candidates. Higher wins; 50 is the neutral default.
priority INTEGER NOT NULL DEFAULT 50,
settings TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_download_providers_enabled
ON download_providers(enabled);
@@ -0,0 +1,49 @@
-- 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_requests (
id TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
-- source records where the request came from: 'explore-album',
-- 'explore-artist', 'missing-album', 'wanted', 'manual'.
source TEXT NOT NULL DEFAULT 'manual',
-- want_id is set when the reconciler raised this request from the
-- wanted list, so the outcome can be written back to the want.
-- NULL for one-off requests the user started by hand. Requests are
-- disposable and wants are not, so the delete is a SET NULL rather
-- than a cascade in either direction.
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
release_mbid TEXT,
release_group_mbid TEXT,
-- recording_mbid anchors a single-track request raised from a
-- track-level want.
recording_mbid TEXT,
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
expected TEXT NOT NULL DEFAULT '[]',
state TEXT NOT NULL DEFAULT 'searching'
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
'verifying', 'tagging', 'importing',
'complete', 'cancelled', 'failed')),
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
);
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);
@@ -0,0 +1,76 @@
-- 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);
@@ -0,0 +1,6 @@
CREATE VIRTUAL TABLE IF NOT EXISTS explore_champion_fts USING fts5(
title, artist_name, aliases,
content='explore_index',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
@@ -0,0 +1,65 @@
CREATE TABLE IF NOT EXISTS explore_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
mbid TEXT NOT NULL,
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
artist_mbid TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '',
-- Popularity signals, derived from the ListenBrainz listens dump.
popularity INTEGER NOT NULL DEFAULT 0,
listener_count INTEGER NOT NULL DEFAULT 0,
-- Recording-specific fields.
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid TEXT NOT NULL DEFAULT '',
release_name TEXT NOT NULL DEFAULT '',
-- Release-group-specific fields.
primary_type TEXT NOT NULL DEFAULT '',
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
-- Artist-specific fields.
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
disambiguation TEXT NOT NULL DEFAULT '',
sort_name TEXT NOT NULL DEFAULT '',
-- Personalization flags.
in_library INTEGER NOT NULL DEFAULT 0,
is_similar INTEGER NOT NULL DEFAULT 0,
-- Cross-reference to local library tables. NULL when the
-- entity has no corresponding row in the library.
local_artist_id INTEGER,
local_release_group_id INTEGER,
local_recording_id INTEGER,
-- Set once an artist's full discography (release groups +
-- recordings) has been fetched, so EnsureArtistDiscography can
-- skip artists the catalog already covers.
discog_fetched INTEGER NOT NULL DEFAULT 0,
UNIQUE(mbid)
);
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
ON explore_index(LOWER(artist_name))
WHERE popularity > 0;
CREATE INDEX IF NOT EXISTS idx_explore_caa_release
ON explore_index(caa_release_mbid)
WHERE entity_type = 'release_group' AND caa_release_mbid != '';
CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid
ON explore_index(artist_mbid, entity_type, popularity DESC);
CREATE INDEX IF NOT EXISTS idx_explore_index_entity_pop
ON explore_index(entity_type, popularity DESC);
CREATE INDEX IF NOT EXISTS idx_explore_title_lower
ON explore_index(LOWER(title))
WHERE popularity > 0;
@@ -0,0 +1,6 @@
CREATE VIRTUAL TABLE IF NOT EXISTS explore_index_fts USING fts5(
title, artist_name, aliases,
content='explore_index',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS explore_index_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
@@ -3,6 +3,8 @@ CREATE TABLE IF NOT EXISTS file_types (
extension text NOT NULL UNIQUE
);
-- Seed rows: the supported audio formats, referenced by
-- audio_files.file_type_id.
INSERT OR IGNORE INTO file_types (id, extension) VALUES (0, '.mp3');
INSERT OR IGNORE INTO file_types (id, extension) VALUES (1, '.flac');
INSERT OR IGNORE INTO file_types (id, extension) VALUES (2, '.ogg');
@@ -1,5 +1,7 @@
-- Short-lived HTTP response cache (search results, MB/LB lookups, etc).
-- For long-lived enrichment data keyed by MBID, see artist_metadata.sql.
CREATE TABLE IF NOT EXISTS http_cache (
url_key TEXT PRIMARY KEY,
response BLOB NOT NULL,
@@ -7,5 +9,7 @@ CREATE TABLE IF NOT EXISTS http_cache (
entity_mbid TEXT NOT NULL DEFAULT '',
entity_type TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_http_cache_expires ON http_cache(expires_at);
CREATE INDEX IF NOT EXISTS idx_http_cache_mbid ON http_cache(entity_mbid);
@@ -6,6 +6,8 @@
-- Rows are written when a durable job enters the paused state and
-- deleted on resume, cancel, or completion — this is not a job history
-- table, and it stays at zero rows in the common case.
CREATE TABLE IF NOT EXISTS job_state (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
@@ -0,0 +1,20 @@
-- 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 libraries (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
autotag_warning_acked INTEGER NOT NULL DEFAULT 0
);
@@ -7,6 +7,8 @@
-- tokenised inverted index, so it stays compact even for large
-- libraries. contentless_delete=1 lets us delete/reinsert a single
-- row when a track's lyrics change (scan update or LRCLIB backfill).
CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_index USING fts5(
lyrics,
content='',
@@ -6,4 +6,5 @@ CREATE TABLE IF NOT EXISTS player_state (
last_position_seconds INTEGER NOT NULL DEFAULT 0
);
-- Singleton row: player state is a single mutable record.
INSERT OR IGNORE INTO player_state (id) VALUES (1);
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS playlist_tracks (
CREATE TABLE IF NOT EXISTS "playlist_tracks" (
id INTEGER PRIMARY KEY,
playlist_id INTEGER NOT NULL,
audio_file_id INTEGER,
@@ -8,14 +8,13 @@ CREATE TABLE IF NOT EXISTS playlist_tracks (
phantom_album TEXT,
phantom_duration_ms INTEGER,
phantom_genre TEXT,
phantom_cover_art_path TEXT,
phantom_file_path TEXT,
phantom_cover_art_path TEXT, phantom_file_path TEXT,
FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id
ON playlist_tracks(playlist_id);
);
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id
ON playlist_tracks(audio_file_id);
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id
ON playlist_tracks(playlist_id);
+1
View File
@@ -8,4 +8,5 @@ CREATE TABLE IF NOT EXISTS queue (
FOREIGN KEY(source_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
);
-- Singleton row: there is exactly one playback queue.
INSERT OR IGNORE INTO queue (id) VALUES (1);
@@ -7,8 +7,8 @@ CREATE TABLE IF NOT EXISTS recording_genres (
UNIQUE(recording_id, genre_id)
);
CREATE INDEX IF NOT EXISTS idx_recording_genres_recording_id
ON recording_genres(recording_id);
CREATE INDEX IF NOT EXISTS idx_recording_genres_genre_id
ON recording_genres(genre_id);
CREATE INDEX IF NOT EXISTS idx_recording_genres_recording_id
ON recording_genres(recording_id);
@@ -15,3 +15,5 @@ CREATE TABLE IF NOT EXISTS recordings (
CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id
ON recordings(artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_recordings_mbid ON recordings(mbid) WHERE mbid IS NOT NULL;
@@ -1,30 +1,20 @@
CREATE TABLE IF NOT EXISTS release_groups (
CREATE TABLE IF NOT EXISTS "release_groups" (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
cover_art_id INTEGER,
album_artist_credit_id INTEGER,
-- year is the *technical release year* of the album as it lives
-- in the user's library — typically the file's ID3 year tag,
-- which for remasters/reissues is the reissue year.
year INTEGER,
-- original_year is the album's *first-release-date* year sourced
-- from MusicBrainz' release-group.first-release-date. For a 2010
-- remaster of a 1973 album, year=2010 and original_year=1973.
-- Populated by autotag apply; NULL until the user accepts a
-- candidate (or for libraries that have never been autotagged).
-- Reads should COALESCE(original_year, year) to get the
-- preferred user-facing year.
original_year INTEGER,
total_tracks INTEGER,
total_discs INTEGER,
mbid TEXT,
total_discs INTEGER, mbid TEXT, original_year INTEGER,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id),
UNIQUE(name, album_artist_credit_id)
);
);
CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id
ON release_groups(album_artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id
ON release_groups(cover_art_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id
ON release_groups(album_artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_mbid ON release_groups(mbid) WHERE mbid IS NOT NULL;
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS release_to_rg (
release_mbid TEXT PRIMARY KEY,
rg_mbid TEXT NOT NULL
) WITHOUT ROWID;
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS search_clicks (
query TEXT NOT NULL,
entity_mbid TEXT NOT NULL,
entity_type TEXT NOT NULL,
click_count INTEGER NOT NULL DEFAULT 1,
last_clicked DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (query, entity_mbid)
);
CREATE INDEX IF NOT EXISTS idx_search_clicks_query
ON search_clicks(query);
@@ -6,4 +6,4 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
content='',
contentless_delete=1,
tokenize='unicode61 remove_diacritics 2'
);
);
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS similar_artist_map (
source_artist_mbid TEXT NOT NULL,
similar_artist_mbid TEXT NOT NULL,
similar_artist_name TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
);
CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source
ON similar_artist_map(source_artist_mbid);
@@ -10,6 +10,8 @@
-- CASCADE ties the blob's lifetime to its tagging_items row: when a
-- group's tracks change, the scan path deletes the old group_key row
-- (and SQLite, with foreign_keys = ON, drops the stale blob with it).
CREATE TABLE IF NOT EXISTS tagging_candidates (
group_key TEXT PRIMARY KEY,
candidates TEXT NOT NULL,
@@ -0,0 +1,47 @@
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
COALESCE(rg.year, r.year, 0) AS release_year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.library_id,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
@@ -1,52 +0,0 @@
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
-- Year defaults to the release group's original release year
-- (MusicBrainz first-release-date) so a 1973 album shows as
-- 1973 even if the user owns the 2010 remaster. Falls back
-- to release-group year (file tag), then to recording year.
-- See release_groups.original_year for full semantics.
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
COALESCE(rg.year, r.year, 0) AS release_year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.library_id,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
+57 -10
View File
@@ -35,7 +35,7 @@ func (q *Queries) CountAudioFilesByLibrary(ctx context.Context, libraryID int64)
const createAudioFile = `-- name: CreateAudioFile :one
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at
`
type CreateAudioFileParams struct {
@@ -84,6 +84,7 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
&i.ModifiedAt,
)
return i, err
}
@@ -92,9 +93,9 @@ const createAudioFileWithGroupKey = `-- name: CreateAudioFileWithGroupKey :one
INSERT INTO audio_files (
file_path, length_milliseconds, file_type_id, recording_id,
sample_rate, bit_depth, channels, bitrate, file_size, basename,
library_id, group_key, tag_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key
library_id, group_key, tag_status, modified_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at
`
type CreateAudioFileWithGroupKeyParams struct {
@@ -111,6 +112,7 @@ type CreateAudioFileWithGroupKeyParams struct {
LibraryID int64
GroupKey string
TagStatus string
ModifiedAt int64
}
func (q *Queries) CreateAudioFileWithGroupKey(ctx context.Context, arg CreateAudioFileWithGroupKeyParams) (AudioFile, error) {
@@ -128,6 +130,7 @@ func (q *Queries) CreateAudioFileWithGroupKey(ctx context.Context, arg CreateAud
arg.LibraryID,
arg.GroupKey,
arg.TagStatus,
arg.ModifiedAt,
)
var i AudioFile
err := row.Scan(
@@ -147,6 +150,7 @@ func (q *Queries) CreateAudioFileWithGroupKey(ctx context.Context, arg CreateAud
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
&i.ModifiedAt,
)
return i, err
}
@@ -203,7 +207,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa
}
const getAllAudioFiles = `-- name: GetAllAudioFiles :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files
`
func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
@@ -232,6 +236,7 @@ func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
&i.ModifiedAt,
); err != nil {
return nil, err
}
@@ -527,7 +532,7 @@ func (q *Queries) GetAllTracksWithFullMetadataByLibrary(ctx context.Context, lib
}
const getAudioFile = `-- name: GetAudioFile :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files
WHERE id = ? LIMIT 1
`
@@ -551,12 +556,13 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
&i.ModifiedAt,
)
return i, err
}
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files
WHERE file_path = ? LIMIT 1
`
@@ -580,6 +586,7 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
&i.ModifiedAt,
)
return i, err
}
@@ -597,7 +604,7 @@ func (q *Queries) GetAudioFileGroupKey(ctx context.Context, id int64) (string, e
}
const getAudioFilesByLibrary = `-- name: GetAudioFilesByLibrary :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files WHERE library_id = ?
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files WHERE library_id = ?
`
func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error) {
@@ -626,6 +633,7 @@ func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) (
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
&i.ModifiedAt,
); err != nil {
return nil, err
}
@@ -854,7 +862,7 @@ func (q *Queries) GetAudioFilesByReleaseGroupByLibrary(ctx context.Context, arg
}
const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files
WHERE recording_id = 0
`
@@ -884,6 +892,7 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
&i.ModifiedAt,
); err != nil {
return nil, err
}
@@ -898,6 +907,20 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile
return items, nil
}
const getLibraryMaxModifiedAt = `-- name: GetLibraryMaxModifiedAt :one
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
WHERE library_id = ?
`
// Newest recorded mtime in a library, for the startup soft scan. 0 when
// the library is empty or no row has a baseline yet.
func (q *Queries) GetLibraryMaxModifiedAt(ctx context.Context, libraryID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, getLibraryMaxModifiedAt, libraryID)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const getRandomAudioFilePath = `-- name: GetRandomAudioFilePath :one
SELECT file_path FROM audio_files
ORDER BY RANDOM()
@@ -1139,7 +1162,7 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams
const updateAudioFileRecording = `-- name: UpdateAudioFileRecording :exec
UPDATE audio_files
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, length_milliseconds = ?, modified_at = ?
WHERE id = ?
`
@@ -1150,6 +1173,8 @@ type UpdateAudioFileRecordingParams struct {
Channels int64
Bitrate int64
FileSize int64
LengthMilliseconds int64
ModifiedAt int64
ID int64
}
@@ -1161,7 +1186,29 @@ func (q *Queries) UpdateAudioFileRecording(ctx context.Context, arg UpdateAudioF
arg.Channels,
arg.Bitrate,
arg.FileSize,
arg.LengthMilliseconds,
arg.ModifiedAt,
arg.ID,
)
return err
}
const updateAudioFileStat = `-- name: UpdateAudioFileStat :exec
UPDATE audio_files
SET modified_at = ?, file_size = ?
WHERE id = ?
`
type UpdateAudioFileStatParams struct {
ModifiedAt int64
FileSize int64
ID int64
}
// Records the on-disk mtime/size without re-reading tags. Used to
// backfill the staleness baseline for files the scan skipped, and to
// re-baseline after YellowJacket's own tag writer rewrites a file.
func (q *Queries) UpdateAudioFileStat(ctx context.Context, arg UpdateAudioFileStatParams) error {
_, err := q.db.ExecContext(ctx, updateAudioFileStat, arg.ModifiedAt, arg.FileSize, arg.ID)
return err
}
@@ -0,0 +1,959 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: download.sql
package sqlcgen
import (
"context"
"database/sql"
)
const createDownloadItem = `-- name: CreateDownloadItem :exec
INSERT INTO download_items (
id, request_id, provider_id, transport_id, external_id,
candidate, state, staging_dir, bytes_total
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`
type CreateDownloadItemParams struct {
ID string
RequestID string
ProviderID int64
TransportID sql.NullInt64
ExternalID string
Candidate string
State string
StagingDir string
BytesTotal int64
}
func (q *Queries) CreateDownloadItem(ctx context.Context, arg CreateDownloadItemParams) error {
_, err := q.db.ExecContext(ctx, createDownloadItem,
arg.ID,
arg.RequestID,
arg.ProviderID,
arg.TransportID,
arg.ExternalID,
arg.Candidate,
arg.State,
arg.StagingDir,
arg.BytesTotal,
)
return err
}
const createDownloadProvider = `-- name: CreateDownloadProvider :one
INSERT INTO download_providers (kind, name, enabled, priority, settings)
VALUES (?, ?, ?, ?, ?)
RETURNING id
`
type CreateDownloadProviderParams struct {
Kind string
Name string
Enabled int64
Priority int64
Settings string
}
func (q *Queries) CreateDownloadProvider(ctx context.Context, arg CreateDownloadProviderParams) (int64, error) {
row := q.db.QueryRowContext(ctx, createDownloadProvider,
arg.Kind,
arg.Name,
arg.Enabled,
arg.Priority,
arg.Settings,
)
var id int64
err := row.Scan(&id)
return id, err
}
const createDownloadRequest = `-- name: CreateDownloadRequest :exec
INSERT INTO download_requests (
id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
type CreateDownloadRequestParams struct {
ID string
LibraryID int64
Source string
WantID sql.NullInt64
ReleaseMbid sql.NullString
ReleaseGroupMbid sql.NullString
RecordingMbid sql.NullString
Artist string
Album string
Query string
Expected string
State string
}
func (q *Queries) CreateDownloadRequest(ctx context.Context, arg CreateDownloadRequestParams) error {
_, err := q.db.ExecContext(ctx, createDownloadRequest,
arg.ID,
arg.LibraryID,
arg.Source,
arg.WantID,
arg.ReleaseMbid,
arg.ReleaseGroupMbid,
arg.RecordingMbid,
arg.Artist,
arg.Album,
arg.Query,
arg.Expected,
arg.State,
)
return err
}
const deleteDownloadProvider = `-- name: DeleteDownloadProvider :exec
DELETE FROM download_providers
WHERE id = ?
`
func (q *Queries) DeleteDownloadProvider(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteDownloadProvider, id)
return err
}
const deleteDownloadRequest = `-- name: DeleteDownloadRequest :exec
DELETE FROM download_requests
WHERE id = ?
`
func (q *Queries) DeleteDownloadRequest(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteDownloadRequest, id)
return err
}
const deleteDownloadWant = `-- name: DeleteDownloadWant :exec
DELETE FROM download_wants WHERE id = ?
`
func (q *Queries) DeleteDownloadWant(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteDownloadWant, id)
return err
}
const deleteFinishedDownloadRequests = `-- name: DeleteFinishedDownloadRequests :exec
DELETE FROM download_requests
WHERE state IN ('complete', 'cancelled', 'failed')
`
func (q *Queries) DeleteFinishedDownloadRequests(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteFinishedDownloadRequests)
return err
}
const deleteSatisfiedDownloadWants = `-- name: DeleteSatisfiedDownloadWants :exec
DELETE FROM download_wants WHERE state = 'satisfied'
`
func (q *Queries) DeleteSatisfiedDownloadWants(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteSatisfiedDownloadWants)
return err
}
const getDownloadItem = `-- name: GetDownloadItem :one
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
WHERE id = ?
`
func (q *Queries) GetDownloadItem(ctx context.Context, id string) (DownloadItem, error) {
row := q.db.QueryRowContext(ctx, getDownloadItem, id)
var i DownloadItem
err := row.Scan(
&i.ID,
&i.RequestID,
&i.ProviderID,
&i.TransportID,
&i.ExternalID,
&i.Candidate,
&i.State,
&i.StagingDir,
&i.BytesDone,
&i.BytesTotal,
&i.ImportedPaths,
&i.Error,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDownloadProvider = `-- name: GetDownloadProvider :one
SELECT id, kind, name, enabled, priority, settings, created_at
FROM download_providers
WHERE id = ?
`
func (q *Queries) GetDownloadProvider(ctx context.Context, id int64) (DownloadProvider, error) {
row := q.db.QueryRowContext(ctx, getDownloadProvider, id)
var i DownloadProvider
err := row.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.Enabled,
&i.Priority,
&i.Settings,
&i.CreatedAt,
)
return i, err
}
const getDownloadRequest = `-- name: GetDownloadRequest :one
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
WHERE id = ?
`
func (q *Queries) GetDownloadRequest(ctx context.Context, id string) (DownloadRequest, error) {
row := q.db.QueryRowContext(ctx, getDownloadRequest, id)
var i DownloadRequest
err := row.Scan(
&i.ID,
&i.LibraryID,
&i.Source,
&i.WantID,
&i.ReleaseMbid,
&i.ReleaseGroupMbid,
&i.RecordingMbid,
&i.Artist,
&i.Album,
&i.Query,
&i.Expected,
&i.State,
&i.Error,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDownloadWant = `-- name: GetDownloadWant :one
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE id = ?
`
func (q *Queries) GetDownloadWant(ctx context.Context, id int64) (DownloadWant, error) {
row := q.db.QueryRowContext(ctx, getDownloadWant, id)
var i DownloadWant
err := row.Scan(
&i.ID,
&i.Mbid,
&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,
)
return i, err
}
const getDownloadWantByMBID = `-- name: GetDownloadWantByMBID :one
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE mbid = ? AND library_id = ?
`
type GetDownloadWantByMBIDParams struct {
Mbid string
LibraryID int64
}
func (q *Queries) GetDownloadWantByMBID(ctx context.Context, arg GetDownloadWantByMBIDParams) (DownloadWant, error) {
row := q.db.QueryRowContext(ctx, getDownloadWantByMBID, arg.Mbid, arg.LibraryID)
var i DownloadWant
err := row.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,
)
return i, err
}
const listChildDownloadWants = `-- name: ListChildDownloadWants :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants WHERE parent_id = ? ORDER BY id
`
func (q *Queries) ListChildDownloadWants(ctx context.Context, parentID sql.NullInt64) ([]DownloadWant, error) {
rows, err := q.db.QueryContext(ctx, listChildDownloadWants, parentID)
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 listDownloadItemsForRequest = `-- name: ListDownloadItemsForRequest :many
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
WHERE request_id = ?
ORDER BY created_at
`
func (q *Queries) ListDownloadItemsForRequest(ctx context.Context, requestID string) ([]DownloadItem, error) {
rows, err := q.db.QueryContext(ctx, listDownloadItemsForRequest, requestID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadItem
for rows.Next() {
var i DownloadItem
if err := rows.Scan(
&i.ID,
&i.RequestID,
&i.ProviderID,
&i.TransportID,
&i.ExternalID,
&i.Candidate,
&i.State,
&i.StagingDir,
&i.BytesDone,
&i.BytesTotal,
&i.ImportedPaths,
&i.Error,
&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 listDownloadProviders = `-- name: ListDownloadProviders :many
SELECT id, kind, name, enabled, priority, settings, created_at
FROM download_providers
ORDER BY priority DESC, name
`
func (q *Queries) ListDownloadProviders(ctx context.Context) ([]DownloadProvider, error) {
rows, err := q.db.QueryContext(ctx, listDownloadProviders)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadProvider
for rows.Next() {
var i DownloadProvider
if err := rows.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.Enabled,
&i.Priority,
&i.Settings,
&i.CreatedAt,
); 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 listDownloadRequests = `-- name: ListDownloadRequests :many
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
ORDER BY created_at DESC
LIMIT ?
`
func (q *Queries) ListDownloadRequests(ctx context.Context, limit int64) ([]DownloadRequest, error) {
rows, err := q.db.QueryContext(ctx, listDownloadRequests, limit)
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.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,
); 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 listDownloadWants = `-- name: ListDownloadWants :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
ORDER BY
CASE state WHEN 'wanted' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
artist, title
`
func (q *Queries) ListDownloadWants(ctx context.Context) ([]DownloadWant, error) {
rows, err := q.db.QueryContext(ctx, listDownloadWants)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadWant
for rows.Next() {
var i DownloadWant
if err := rows.Scan(
&i.ID,
&i.Mbid,
&i.Entity,
&i.LibraryID,
&i.Artist,
&i.Title,
&i.Scope,
&i.Secondary,
&i.State,
&i.ParentID,
&i.Attempts,
&i.LastError,
&i.LastTriedAt,
&i.NextTryAt,
&i.ExternalIds,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDownloadWantsByEntity = `-- name: ListDownloadWantsByEntity :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
WHERE entity = ? AND state = ?
ORDER BY id
`
type ListDownloadWantsByEntityParams struct {
Entity string
State string
}
func (q *Queries) ListDownloadWantsByEntity(ctx context.Context, arg ListDownloadWantsByEntityParams) ([]DownloadWant, error) {
rows, err := q.db.QueryContext(ctx, listDownloadWantsByEntity, arg.Entity, arg.State)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadWant
for rows.Next() {
var i DownloadWant
if err := rows.Scan(
&i.ID,
&i.Mbid,
&i.Entity,
&i.LibraryID,
&i.Artist,
&i.Title,
&i.Scope,
&i.Secondary,
&i.State,
&i.ParentID,
&i.Attempts,
&i.LastError,
&i.LastTriedAt,
&i.NextTryAt,
&i.ExternalIds,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDueDownloadWants = `-- name: ListDueDownloadWants :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_wants
WHERE state = 'wanted'
AND entity <> 'artist'
AND (next_try_at IS NULL OR next_try_at <= CURRENT_TIMESTAMP)
ORDER BY attempts, created_at
LIMIT ?
`
// Everything the reconciler should act on this pass: wanted, not an
// artist subscription (those expand rather than download), and either
// never tried or past its backoff.
func (q *Queries) ListDueDownloadWants(ctx context.Context, limit int64) ([]DownloadWant, error) {
rows, err := q.db.QueryContext(ctx, listDueDownloadWants, limit)
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 listLiveDownloadItems = `-- name: ListLiveDownloadItems :many
SELECT id, request_id, provider_id, transport_id, external_id, candidate,
state, staging_dir, bytes_done, bytes_total, imported_paths,
error, created_at, updated_at
FROM download_items
WHERE state NOT IN ('complete', 'cancelled', 'failed')
ORDER BY created_at
`
func (q *Queries) ListLiveDownloadItems(ctx context.Context) ([]DownloadItem, error) {
rows, err := q.db.QueryContext(ctx, listLiveDownloadItems)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadItem
for rows.Next() {
var i DownloadItem
if err := rows.Scan(
&i.ID,
&i.RequestID,
&i.ProviderID,
&i.TransportID,
&i.ExternalID,
&i.Candidate,
&i.State,
&i.StagingDir,
&i.BytesDone,
&i.BytesTotal,
&i.ImportedPaths,
&i.Error,
&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 listLiveDownloadRequests = `-- name: ListLiveDownloadRequests :many
SELECT id, library_id, source, want_id, release_mbid, release_group_mbid,
recording_mbid, artist, album, query, expected, state, error,
created_at, updated_at
FROM download_requests
WHERE state NOT IN ('complete', 'cancelled', 'failed')
ORDER BY created_at
`
func (q *Queries) ListLiveDownloadRequests(ctx context.Context) ([]DownloadRequest, error) {
rows, err := q.db.QueryContext(ctx, listLiveDownloadRequests)
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.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,
); 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 recordDownloadWantAttempt = `-- name: RecordDownloadWantAttempt :exec
UPDATE download_wants
SET attempts = attempts + 1,
last_error = ?,
last_tried_at = CURRENT_TIMESTAMP,
next_try_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type RecordDownloadWantAttemptParams struct {
LastError string
NextTryAt sql.NullTime
ID int64
}
func (q *Queries) RecordDownloadWantAttempt(ctx context.Context, arg RecordDownloadWantAttemptParams) error {
_, err := q.db.ExecContext(ctx, recordDownloadWantAttempt, arg.LastError, arg.NextTryAt, arg.ID)
return err
}
const satisfyDownloadWant = `-- name: SatisfyDownloadWant :exec
UPDATE download_wants
SET state = 'satisfied', last_error = '', next_try_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
func (q *Queries) SatisfyDownloadWant(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, satisfyDownloadWant, id)
return err
}
const setDownloadItemExternalID = `-- name: SetDownloadItemExternalID :exec
UPDATE download_items
SET external_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadItemExternalIDParams struct {
ExternalID string
ID string
}
func (q *Queries) SetDownloadItemExternalID(ctx context.Context, arg SetDownloadItemExternalIDParams) error {
_, err := q.db.ExecContext(ctx, setDownloadItemExternalID, arg.ExternalID, arg.ID)
return err
}
const setDownloadItemImported = `-- name: SetDownloadItemImported :exec
UPDATE download_items
SET imported_paths = ?, state = 'complete', error = '',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadItemImportedParams struct {
ImportedPaths string
ID string
}
func (q *Queries) SetDownloadItemImported(ctx context.Context, arg SetDownloadItemImportedParams) error {
_, err := q.db.ExecContext(ctx, setDownloadItemImported, arg.ImportedPaths, arg.ID)
return err
}
const setDownloadItemProgress = `-- name: SetDownloadItemProgress :exec
UPDATE download_items
SET bytes_done = ?, bytes_total = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadItemProgressParams struct {
BytesDone int64
BytesTotal int64
ID string
}
func (q *Queries) SetDownloadItemProgress(ctx context.Context, arg SetDownloadItemProgressParams) error {
_, err := q.db.ExecContext(ctx, setDownloadItemProgress, arg.BytesDone, arg.BytesTotal, arg.ID)
return err
}
const setDownloadItemState = `-- name: SetDownloadItemState :exec
UPDATE download_items
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadItemStateParams struct {
State string
Error string
ID string
}
func (q *Queries) SetDownloadItemState(ctx context.Context, arg SetDownloadItemStateParams) error {
_, err := q.db.ExecContext(ctx, setDownloadItemState, arg.State, arg.Error, arg.ID)
return err
}
const setDownloadRequestState = `-- name: SetDownloadRequestState :exec
UPDATE download_requests
SET state = ?, error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadRequestStateParams struct {
State string
Error string
ID string
}
func (q *Queries) SetDownloadRequestState(ctx context.Context, arg SetDownloadRequestStateParams) error {
_, err := q.db.ExecContext(ctx, setDownloadRequestState, arg.State, arg.Error, arg.ID)
return err
}
const setDownloadWantExternalIDs = `-- name: SetDownloadWantExternalIDs :exec
UPDATE download_wants
SET external_ids = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadWantExternalIDsParams struct {
ExternalIds string
ID int64
}
func (q *Queries) SetDownloadWantExternalIDs(ctx context.Context, arg SetDownloadWantExternalIDsParams) error {
_, err := q.db.ExecContext(ctx, setDownloadWantExternalIDs, arg.ExternalIds, arg.ID)
return err
}
const setDownloadWantState = `-- name: SetDownloadWantState :exec
UPDATE download_wants
SET state = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
type SetDownloadWantStateParams struct {
State string
LastError string
ID int64
}
func (q *Queries) SetDownloadWantState(ctx context.Context, arg SetDownloadWantStateParams) error {
_, err := q.db.ExecContext(ctx, setDownloadWantState, arg.State, arg.LastError, arg.ID)
return err
}
const updateDownloadProvider = `-- name: UpdateDownloadProvider :exec
UPDATE download_providers
SET name = ?, enabled = ?, priority = ?, settings = ?
WHERE id = ?
`
type UpdateDownloadProviderParams struct {
Name string
Enabled int64
Priority int64
Settings string
ID int64
}
func (q *Queries) UpdateDownloadProvider(ctx context.Context, arg UpdateDownloadProviderParams) error {
_, err := q.db.ExecContext(ctx, updateDownloadProvider,
arg.Name,
arg.Enabled,
arg.Priority,
arg.Settings,
arg.ID,
)
return err
}
const upsertDownloadWant = `-- name: UpsertDownloadWant :one
INSERT INTO download_wants (
mbid, entity, library_id, artist, title, scope, secondary,
parent_id, next_try_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(mbid, library_id) DO UPDATE SET
artist = CASE WHEN excluded.artist <> '' THEN excluded.artist
ELSE download_wants.artist END,
title = CASE WHEN excluded.title <> '' THEN excluded.title
ELSE download_wants.title END,
scope = excluded.scope,
secondary = excluded.secondary,
updated_at = CURRENT_TIMESTAMP
RETURNING id
`
type UpsertDownloadWantParams struct {
Mbid string
Entity string
LibraryID int64
Artist string
Title string
Scope string
Secondary int64
ParentID sql.NullInt64
}
// ---------------------------------------------------------------------
// Wants
// ---------------------------------------------------------------------
// Adding something already wanted is not an error and must not reset
// the retry clock, so the conflict path only refreshes display text and
// un-pauses nothing. scope and secondary are updated because asking
// again with a wider scope is a real change of intent.
func (q *Queries) UpsertDownloadWant(ctx context.Context, arg UpsertDownloadWantParams) (int64, error) {
row := q.db.QueryRowContext(ctx, upsertDownloadWant,
arg.Mbid,
arg.Entity,
arg.LibraryID,
arg.Artist,
arg.Title,
arg.Scope,
arg.Secondary,
arg.ParentID,
)
var id int64
err := row.Scan(&id)
return id, err
}
+146 -1
View File
@@ -26,6 +26,20 @@ type ArtistCreditArtist struct {
CreditID int64
}
type ArtistImage struct {
ID int64
ArtistMbid string
Source string
SourceUrl string
FilePath string
IsPrimary int64
SortOrder int64
Width sql.NullInt64
Height sql.NullInt64
FileSize sql.NullInt64
CreatedAt time.Time
}
type ArtistMetadatum struct {
Mbid string
Source string
@@ -50,6 +64,7 @@ type AudioFile struct {
LastPlayed sql.NullTime
TagStatus string
GroupKey string
ModifiedAt int64
}
type CoverArt struct {
@@ -59,6 +74,116 @@ type CoverArt struct {
MimeType string
}
type DownloadItem struct {
ID string
RequestID string
ProviderID int64
TransportID sql.NullInt64
ExternalID string
Candidate string
State string
StagingDir string
BytesDone int64
BytesTotal int64
ImportedPaths string
Error string
CreatedAt time.Time
UpdatedAt time.Time
}
type DownloadProvider struct {
ID int64
Kind string
Name string
Enabled int64
Priority int64
Settings string
CreatedAt time.Time
}
type DownloadRequest struct {
ID string
LibraryID int64
Source string
WantID sql.NullInt64
ReleaseMbid sql.NullString
ReleaseGroupMbid sql.NullString
RecordingMbid sql.NullString
Artist string
Album string
Query string
Expected string
State string
Error string
CreatedAt time.Time
UpdatedAt time.Time
}
type DownloadWant struct {
ID int64
Mbid string
Entity string
LibraryID int64
Artist string
Title string
Scope string
Secondary int64
State string
ParentID sql.NullInt64
Attempts int64
LastError string
LastTriedAt sql.NullTime
NextTryAt sql.NullTime
ExternalIds string
CreatedAt time.Time
UpdatedAt time.Time
}
type ExploreChampionFt struct {
Title string
ArtistName string
Aliases string
}
type ExploreIndex struct {
ID int64
EntityType string
Mbid string
Title string
ArtistName string
ArtistMbid string
Aliases string
Popularity int64
ListenerCount int64
Duration int64
CaaReleaseMbid string
ReleaseName string
PrimaryType string
SecondaryTypes string
ReleaseDate string
ArtistType string
Country string
Disambiguation string
SortName string
InLibrary int64
IsSimilar int64
LocalArtistID sql.NullInt64
LocalReleaseGroupID sql.NullInt64
LocalRecordingID sql.NullInt64
DiscogFetched int64
}
type ExploreIndexFt struct {
Title string
ArtistName string
Aliases string
}
type ExploreIndexMetum struct {
Key string
Value string
}
type FileType struct {
ID int64
Extension string
@@ -176,10 +301,10 @@ type ReleaseGroup struct {
CoverArtID sql.NullInt64
AlbumArtistCreditID sql.NullInt64
Year sql.NullInt64
OriginalYear sql.NullInt64
TotalTracks sql.NullInt64
TotalDiscs sql.NullInt64
Mbid sql.NullString
OriginalYear sql.NullInt64
}
type ReleaseGroupRecording struct {
@@ -190,6 +315,19 @@ type ReleaseGroupRecording struct {
DiscNumber sql.NullInt64
}
type ReleaseToRg struct {
ReleaseMbid string
RgMbid string
}
type SearchClick struct {
Query string
EntityMbid string
EntityType string
ClickCount int64
LastClicked time.Time
}
type SearchIndex struct {
FilePath string
Title string
@@ -197,6 +335,13 @@ type SearchIndex struct {
Album string
}
type SimilarArtistMap struct {
SourceArtistMbid string
SimilarArtistMbid string
SimilarArtistName string
Score int64
}
type TaggingCandidate struct {
GroupKey string
Candidates string
@@ -23,7 +23,7 @@ func (q *Queries) CountReleaseGroupRecordings(ctx context.Context, releaseGroupI
const createReleaseGroup = `-- name: CreateReleaseGroup :one
INSERT INTO release_groups (name) VALUES (?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
`
func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) {
@@ -35,10 +35,10 @@ func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseG
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
)
return i, err
}
@@ -47,7 +47,7 @@ const createReleaseGroupFull = `-- name: CreateReleaseGroupFull :one
INSERT INTO release_groups (
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
`
type CreateReleaseGroupFullParams struct {
@@ -75,10 +75,10 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
)
return i, err
}
@@ -432,7 +432,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI
}
const getAllReleaseGroups = `-- name: GetAllReleaseGroups :many
SELECT id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
ORDER BY name
`
@@ -451,10 +451,10 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
); err != nil {
return nil, err
}
@@ -470,7 +470,7 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro
}
const getReleaseGroup = `-- name: GetReleaseGroup :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
WHERE id = ? LIMIT 1
`
@@ -483,16 +483,16 @@ func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup,
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
)
return i, err
}
const getReleaseGroupByNameAndArtist = `-- name: GetReleaseGroupByNameAndArtist :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1
`
@@ -510,10 +510,10 @@ func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetRel
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
)
return i, err
}
@@ -574,7 +574,7 @@ VALUES (?, ?, ?)
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
year = COALESCE(excluded.year, release_groups.year)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
`
type UpsertReleaseGroupParams struct {
@@ -592,10 +592,10 @@ func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroup
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
)
return i, err
}
+3 -3
View File
@@ -13,7 +13,7 @@ import (
// Migration 31: tag_status column
// ---------------------------------------------------------------------------
func TestMigration31_TagStatusDefaultAndCheck(t *testing.T) {
func TestTagStatusDefaultAndCheck(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
@@ -42,7 +42,7 @@ func TestMigration31_TagStatusDefaultAndCheck(t *testing.T) {
}
}
func TestMigration31_BackfillFromRecordingMBID(t *testing.T) {
func TestTagStatusBackfillFromRecordingMBID(t *testing.T) {
t.Parallel()
// Migration 31 runs against a DB that already exists — NewTestDB
@@ -97,7 +97,7 @@ func TestMigration31_BackfillFromRecordingMBID(t *testing.T) {
// Migration 32: tagging_items table + group_key column
// ---------------------------------------------------------------------------
func TestMigration32_TaggingItemsAndGroupKey(t *testing.T) {
func TestTaggingItemsAndGroupKey(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
+4 -27
View File
@@ -2,9 +2,7 @@ package database
import (
"database/sql"
"io/fs"
"log/slog"
"path"
"testing"
_ "modernc.org/sqlite" // Register sqlite driver.
@@ -34,31 +32,10 @@ func NewTestDB(t *testing.T) *DB {
t.Fatalf("could not apply PRAGMAs: %v", err)
}
dirEntries, err := schemas.ReadDir("sql/schemas")
if err != nil {
t.Fatalf("could not read schemas directory: %v", err)
}
for _, dirEntry := range dirEntries {
if !dirEntry.IsDir() {
filePath := path.Join("sql/schemas", dirEntry.Name())
sqlContent, err := fs.ReadFile(schemas, filePath)
if err != nil {
t.Fatalf("could not read file %s: %v", filePath, err)
}
if _, err = db.ExecContext(ctx, string(sqlContent)); err != nil {
t.Fatalf(
"error executing sql from file %s: %v",
filePath, err,
)
}
}
}
if err := runMigrations(ctx, db, slog.Default(), ":memory:"); err != nil {
t.Fatalf("could not run migrations: %v", err)
// The same call production uses, so a test database and a real one
// cannot diverge.
if err := applySchema(ctx, db); err != nil {
t.Fatalf("could not apply schema: %v", err)
}
// Insert a sentinel library row at id=0 so audio_files inserts
+410
View File
@@ -0,0 +1,410 @@
// Package datamap is the catalog of everything YellowJacket persists.
//
// It exists because deletion logic used to be written per call site and
// lived far from the thing being deleted. Nobody adding a table could
// know the full set of places that needed updating, so tables leaked
// (rows nothing ever removed), files leaked (thumbnails whose database
// row was gone), and in one case a new table's foreign key silently made
// libraries unremovable.
//
// Every table, view, and on-disk asset directory is classified along two
// axes that between them determine every policy worth having:
//
// - Kind — what it costs to lose the data.
// - Lifetime — how rows or files are removed.
//
// The catalog is plain data with no dependency on any service, so tests
// can assert it against a live schema. The rule that gives it teeth:
// every table in the database must be claimed by exactly one entry here,
// enforced by TestCatalogCoversSchema. A new table fails the build until
// somebody states what it is and how it dies.
package datamap
import "strings"
// Kind classifies persisted data by what losing it costs.
type Kind string
const (
// Owned data is a projection of the user's audio files. The files
// on disk are the source of truth; a rescan rebuilds all of it.
Owned Kind = "owned"
// Authored data was created by the user and exists nowhere else.
// Losing it is unrecoverable data loss — it must never be deleted
// as a side effect of anything.
Authored Kind = "authored"
// Derived data is computed from owned data and is cheap to rebuild.
// It may be deleted freely and must never block deletion of the
// data it was derived from.
Derived Kind = "derived"
// Cache data came from the network or a MusicBrainz dump. It is
// rebuildable but expensive — rate limits, or a multi-hour index
// build — so it is evicted on its own schedule rather than being
// tied to the lifetime of anything else.
Cache Kind = "cache"
)
// Lifetime says how rows leave a table. It is declared here and checked
// against the live schema by TestLifetimesMatchSchema, so a foreign key
// that disagrees with the stated policy fails the tests.
type Lifetime string
const (
// Cascade means a foreign key with ON DELETE CASCADE removes rows
// when the parent goes. No application code required.
Cascade Lifetime = "cascade"
// SetNull means a foreign key with ON DELETE SET NULL orphans the
// row deliberately, keeping it alive without its parent.
SetNull Lifetime = "set-null"
// Swept means application code must delete these rows explicitly —
// either in a removal path or in the maintenance janitor. Any table
// with a NO ACTION foreign key must declare this, because such a key
// blocks its parent's deletion until someone clears the child rows.
Swept Lifetime = "swept"
// Retained means rows are never removed automatically. Only an
// explicit user action deletes them.
Retained Lifetime = "retained"
)
// Table is one catalog entry.
type Table struct {
// Name is the SQL table or view name.
Name string
// Kind is what the data costs to lose.
Kind Kind
// Lifetime is how rows are removed.
Lifetime Lifetime
// FTS marks an FTS5 virtual table, which SQLite backs with four
// shadow tables (_config, _data, _docsize, _idx). Those are
// implementation detail and are resolved to this entry.
FTS bool
// Note records why the classification is what it is, particularly
// where a table is not purely one kind.
Note string
}
// ftsShadowSuffixes are the tables SQLite creates behind an FTS5 virtual
// table. They belong to their parent and are never catalogued directly.
var ftsShadowSuffixes = []string{
"_config", "_data", "_docsize", "_idx",
}
// internalTables are SQLite's own bookkeeping, outside our control.
var internalTables = map[string]bool{
"sqlite_sequence": true,
"sqlite_stat1": true,
"sqlite_stat4": true,
}
// tables is the catalog. Keep it alphabetical.
var tables = []Table{
{
Name: "artist_credit", Kind: Owned, Lifetime: Swept,
Note: "Credit strings parsed from file tags. Orphan-swept when " +
"no recording references them.",
},
{
Name: "artist_credit_artist", Kind: Owned, Lifetime: Swept,
Note: "Join table between credits and artists.",
},
{
Name: "artist_images", Kind: Cache, Lifetime: Swept,
Note: "Artist photos from fanart.tv/MusicBrainz. Rows point at " +
"files under the artist-images directory; the janitor sweeps " +
"both together.",
},
{
Name: "artist_metadata", Kind: Cache, Lifetime: Swept,
Note: "Fetched artist bios and metadata. No TTL — swept when the " +
"artist is no longer referenced.",
},
{
Name: "artists", Kind: Owned, Lifetime: Swept,
Note: "Artists parsed from file tags. Orphan-swept.",
},
{
Name: "audio_files", Kind: Owned, Lifetime: Swept,
Note: "MIXED KIND. Mostly an owned projection of files on disk, " +
"but play_count, last_played and tag_status are authored and " +
"exist nowhere else. Deleting a row to rebuild it destroys " +
"that authored state — which is why a file rename currently " +
"loses play counts. See the data architecture plan.",
},
{
Name: "cover_art", Kind: Owned, Lifetime: Swept,
Note: "Extracted embedded artwork. file_path names the original " +
"only; the sized variants beside it are derived filenames and " +
"must be expanded when deleting (see library.coverArtFileSet).",
},
{
Name: "download_items", Kind: Authored, Lifetime: Cascade,
Note: "One grab attempt per row, with the ranked candidate stored " +
"as JSON. Cascades from download_requests. The candidate blob " +
"is kept rather than re-derived because a provider's result " +
"set is ephemeral — the peer that had the files may be gone, " +
"and the row still has to explain why it was chosen.",
},
{
Name: "download_providers", Kind: Authored, Lifetime: Retained,
Note: "Download clients the user connected. Removed only by the " +
"user. Holds no secrets: API keys live in a 0600 file keyed " +
"by this row's id, so the table can be dumped into a bug " +
"report without redaction.",
},
{
Name: "download_requests", Kind: Authored, Lifetime: Cascade,
Note: "One row per 'go find me this'. Cascades from libraries, " +
"and cascades onward to download_items. Terminal rows are " +
"history the user clears explicitly.",
},
{
Name: "download_wants", Kind: Authored, Lifetime: Cascade,
Note: "The wanted list: one MBID per row, plus retry bookkeeping. " +
"Cascades from libraries, and from a parent artist want to " +
"the album wants it derived. Unlike download_requests these " +
"are not history — a want outlives every attempt made on it " +
"and is only removed by the user or by the library coming " +
"to own what it names.",
},
{
Name: "explore_champion_fts", Kind: Cache, Lifetime: Retained, FTS: true,
Note: "Full-text index over the champion entities of the " +
"MusicBrainz dump. Rebuilt only by a full index build.",
},
{
Name: "explore_index", Kind: Cache, Lifetime: Retained,
Note: "The offline MusicBrainz search index. Rebuilding costs a " +
"~205GB dump stream, so it is never swept automatically.",
},
{
Name: "explore_index_fts", Kind: Cache, Lifetime: Retained, FTS: true,
Note: "Full-text index over explore_index.",
},
{
Name: "explore_index_meta", Kind: Cache, Lifetime: Retained,
Note: "Build metadata for explore_index: dump version, coverage " +
"tiers, last refresh.",
},
{
Name: "file_types", Kind: Derived, Lifetime: Retained,
Note: "Static lookup rows seeded from code, not user data.",
},
{
Name: "genres", Kind: Owned, Lifetime: Swept,
Note: "Genres parsed from file tags. Orphan-swept.",
},
{
Name: "http_cache", Kind: Cache, Lifetime: Swept,
Note: "Cached HTTP responses with a TTL. Reads filter on " +
"expires_at; the janitor deletes expired rows.",
},
{
Name: "job_state", Kind: Authored, Lifetime: Retained,
Note: "Persisted background-job state, including scans the user " +
"paused. Represents user intent, so it survives restarts.",
},
{
Name: "libraries", Kind: Authored, Lifetime: Retained,
Note: "The directories the user chose. Removed only by explicit " +
"user action via RemoveLibrary.",
},
{
Name: "lyrics_index", Kind: Derived, Lifetime: Retained, FTS: true,
Note: "Full-text index over embedded and fetched lyrics. Rebuilt " +
"from owned files plus the LRCLIB backfill.",
},
{
Name: "play_history", Kind: Authored, Lifetime: Cascade,
Note: "Listening history. Authored, but intentionally cascades " +
"with its track — history for a file no longer in the library " +
"has nothing to point at.",
},
{
Name: "player_state", Kind: Authored, Lifetime: Retained,
Note: "Volume, repeat and shuffle modes, last position.",
},
{
Name: "playlist_tracks", Kind: Authored, Lifetime: SetNull,
Note: "Playlist membership. Deliberately survives its track: " +
"audio_file_id is nulled and phantom_* columns preserve the " +
"entry so a rescan can re-link it.",
},
{
Name: "playlists", Kind: Authored, Lifetime: Retained,
Note: "User-created playlists, including smart playlist rules.",
},
{
Name: "queue", Kind: Authored, Lifetime: Retained,
Note: "The play queue's own state (current position, source).",
},
{
Name: "queue_tracks", Kind: Authored, Lifetime: Cascade,
Note: "Queue entries. Cascade with their track; the queue is " +
"compacted afterwards.",
},
{
Name: "recording_genres", Kind: Owned, Lifetime: Swept,
Note: "Join table between recordings and genres.",
},
{
Name: "recordings", Kind: Owned, Lifetime: Swept,
Note: "Tracks as parsed from file tags. Orphan-swept.",
},
{
Name: "release_group_recordings", Kind: Owned, Lifetime: Swept,
Note: "Join table between release groups and recordings.",
},
{
Name: "release_groups", Kind: Owned, Lifetime: Swept,
Note: "Albums as parsed from file tags. Orphan-swept.",
},
{
Name: "release_to_rg", Kind: Cache, Lifetime: Retained,
Note: "Release to release-group mapping from the dump.",
},
{
Name: "search_clicks", Kind: Authored, Lifetime: Retained,
Note: "Which results the user picked, used to rank future " +
"searches. Behavioural but unrecoverable if dropped.",
},
{
Name: "search_index", Kind: Derived, Lifetime: Retained, FTS: true,
Note: "Contentless FTS5 over the library. Individual rows cannot " +
"be deleted, so stale entries are tolerated and filtered by " +
"joining track_metadata; a full rescan rebuilds it.",
},
{
Name: "similar_artist_map", Kind: Cache, Lifetime: Retained,
Note: "Artist similarity edges derived from the dump.",
},
{
Name: "tagging_candidates", Kind: Derived, Lifetime: Cascade,
Note: "Scored MusicBrainz candidates for a tagging group. " +
"Cascades with its tagging_items row.",
},
{
Name: "tagging_items", Kind: Derived, Lifetime: Swept,
Note: "MIXED KIND. The grouping is derived from folder layout, " +
"but status and cleared_at record the user's review " +
"decisions. Its library_id foreign key is NO ACTION, so " +
"RemoveLibrary must delete these rows explicitly or the " +
"library cannot be removed at all.",
},
{
Name: "track_metadata", Kind: Derived, Lifetime: Retained,
Note: "A view joining audio_files to its entity chain. Holds no " +
"rows of its own.",
},
}
// directories are the on-disk asset trees under the user data directory.
// Files there have no foreign keys, so nothing removes them implicitly —
// each needs a sweeper in the janitor.
var directories = []Directory{
{
Name: "covers", Kind: Derived,
Note: "Extracted cover art plus generated _sm/_md/_lg variants. " +
"Live set is cover_art.file_path expanded to its variants.",
},
{
Name: "artist-images", Kind: Cache,
Note: "Per-artist-MBID directories of fetched photos, a " +
"primary.jpg with thumbnails, and a .miss marker for artists " +
"known to have no art. Live set is artist_images.file_path.",
},
{
Name: "cover-art-cache", Kind: Cache,
Note: "Cover Art Archive images fetched for Explore browsing, " +
"keyed by release-group MBID. Not tied to owned data at all.",
},
}
// Directory is an on-disk asset tree owned by the application.
type Directory struct {
// Name is the directory name under the user data directory.
Name string
// Kind is what the files cost to lose.
Kind Kind
// Note records what lives there and how the live set is determined.
Note string
}
// Tables returns the full catalog.
func Tables() []Table {
out := make([]Table, len(tables))
copy(out, tables)
return out
}
// Directories returns the catalogued asset directories.
func Directories() []Directory {
out := make([]Directory, len(directories))
copy(out, directories)
return out
}
// Lookup returns the catalog entry for a table name, resolving FTS
// shadow tables to their parent. Reports false for SQLite's internal
// tables and for anything not catalogued.
func Lookup(name string) (Table, bool) {
if internalTables[name] {
return Table{}, false
}
for _, t := range tables {
if t.Name == name {
return t, true
}
}
if parent, ok := ftsParent(name); ok {
return Lookup(parent)
}
return Table{}, false
}
// IsInternal reports whether a table is SQLite's own bookkeeping.
func IsInternal(name string) bool {
return internalTables[name] || strings.HasPrefix(name, "sqlite_")
}
// ftsParent maps an FTS5 shadow table to the virtual table that owns it.
func ftsParent(name string) (string, bool) {
for _, suffix := range ftsShadowSuffixes {
if !strings.HasSuffix(name, suffix) {
continue
}
parent := strings.TrimSuffix(name, suffix)
for _, t := range tables {
if t.Name == parent && t.FTS {
return parent, true
}
}
}
return "", false
}
// ByKind returns every catalogued table of a given kind.
func ByKind(k Kind) []Table {
var out []Table
for _, t := range tables {
if t.Kind == k {
out = append(out, t)
}
}
return out
}
+287
View File
@@ -0,0 +1,287 @@
package datamap_test
import (
"fmt"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/datamap"
)
// liveTables returns every table and view in a freshly migrated schema.
func liveTables(t *testing.T, db *database.DB) []string {
t.Helper()
rows, err := db.QueryContext(
`SELECT name FROM sqlite_master
WHERE type IN ('table', 'view')
ORDER BY name`,
)
if err != nil {
t.Fatalf("read sqlite_master: %v", err)
}
defer func() { _ = rows.Close() }()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan table name: %v", err)
}
names = append(names, name)
}
return names
}
// Every table in the schema must be claimed by exactly one catalog
// entry. This is the mechanism that stops a new table from silently
// having no deletion policy — the failure mode that made libraries
// unremovable when tagging_items was added.
func TestCatalogCoversSchema(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
for _, name := range liveTables(t, db) {
if datamap.IsInternal(name) {
continue
}
if _, ok := datamap.Lookup(name); !ok {
t.Errorf(
"table %q exists in the schema but is not in the datamap "+
"catalog — add an entry stating its Kind and Lifetime",
name,
)
}
}
}
// The reverse direction: a catalog entry naming a table that no longer
// exists means the catalog has drifted.
func TestCatalogHasNoStaleEntries(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
live := make(map[string]bool)
for _, name := range liveTables(t, db) {
live[name] = true
}
for _, entry := range datamap.Tables() {
if !live[entry.Name] {
t.Errorf(
"catalog lists %q but it is not in the schema",
entry.Name,
)
}
}
}
type foreignKey struct {
child string
from string
parent string
onDelete string
}
// liveForeignKeys reads every foreign key in the schema.
func liveForeignKeys(t *testing.T, db *database.DB) []foreignKey {
t.Helper()
var out []foreignKey
for _, table := range liveTables(t, db) {
if datamap.IsInternal(table) {
continue
}
rows, err := db.QueryContext(
fmt.Sprintf("PRAGMA foreign_key_list(%q)", table),
)
if err != nil {
continue // views have none
}
for rows.Next() {
var (
id, seq int
parent, from, to, onUpd, onDel, matchOn string
)
if err := rows.Scan(
&id, &seq, &parent, &from, &to, &onUpd, &onDel, &matchOn,
); err != nil {
continue
}
out = append(out, foreignKey{
child: table,
from: from,
parent: parent,
onDelete: onDel,
})
}
_ = rows.Close()
}
return out
}
// A foreign key with NO ACTION blocks its parent's deletion until
// application code clears the child rows. Any table with such a key
// must therefore declare Lifetime "swept" — an assertion that some
// removal path or janitor actually deletes them. Declaring "retained"
// or "cascade" while holding a NO ACTION key is the exact shape of the
// tagging_items bug.
func TestNoActionForeignKeysAreDeclaredSwept(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
for _, fk := range liveForeignKeys(t, db) {
if fk.onDelete != "NO ACTION" {
continue
}
entry, ok := datamap.Lookup(fk.child)
if !ok {
continue // TestCatalogCoversSchema reports this
}
if entry.Lifetime != datamap.Swept {
t.Errorf(
"%s.%s references %s with ON DELETE NO ACTION, so it "+
"blocks deletion of %s — but the catalog declares "+
"Lifetime %q. Either declare it %q and delete the rows "+
"explicitly, or give the key an ON DELETE action.",
fk.child, fk.from, fk.parent, fk.parent,
entry.Lifetime, datamap.Swept,
)
}
}
}
// Declared cascade/set-null lifetimes must match the actual schema, so
// the catalog cannot quietly drift from what SQLite enforces.
func TestLifetimesMatchSchema(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
actual := make(map[string]map[string]bool)
for _, fk := range liveForeignKeys(t, db) {
if actual[fk.child] == nil {
actual[fk.child] = make(map[string]bool)
}
actual[fk.child][fk.onDelete] = true
}
for _, entry := range datamap.Tables() {
switch entry.Lifetime {
case datamap.Cascade:
if !actual[entry.Name]["CASCADE"] {
t.Errorf(
"%s declares Lifetime cascade but has no "+
"ON DELETE CASCADE foreign key",
entry.Name,
)
}
case datamap.SetNull:
if !actual[entry.Name]["SET NULL"] {
t.Errorf(
"%s declares Lifetime set-null but has no "+
"ON DELETE SET NULL foreign key",
entry.Name,
)
}
case datamap.Swept, datamap.Retained:
// No schema-level obligation.
}
}
}
// Authored data is unrecoverable, so it must never be removed as a side
// effect of deleting owned data. Cascade is allowed only where the
// catalog explains why (play_history, queue_tracks); this test pins the
// set so a new cascade onto authored data is a deliberate decision.
func TestAuthoredCascadesAreDeliberate(t *testing.T) {
t.Parallel()
allowed := map[string]bool{
"play_history": true,
"queue_tracks": true,
// Download history is scoped to the library it imported into.
// When that library is removed the files it acquired go with
// it, so a request describing "fetch this into library 3" has
// nothing left to mean. Keeping the rows would leave history
// pointing at a library the user deleted.
"download_requests": true,
// Items belong to their request and have no independent
// meaning; they cascade with it.
"download_items": true,
// A want says "put this in library 3". Delete that library and
// there is no longer anywhere for it to go, so the want has
// nothing left to mean — the same reasoning as its requests.
// The second cascade, artist want to derived album wants, is
// the point of the subscription: unsubscribing from an artist
// must stop the albums it queued on the user's behalf.
"download_wants": true,
}
for _, entry := range datamap.ByKind(datamap.Authored) {
if entry.Lifetime == datamap.Cascade && !allowed[entry.Name] {
t.Errorf(
"authored table %q cascades on delete — authored data is "+
"unrecoverable, so this needs an explicit exemption "+
"and a note explaining it",
entry.Name,
)
}
}
}
// Every catalogued table needs a note; the classification is only useful
// if the reasoning is written down.
func TestEveryEntryHasANote(t *testing.T) {
t.Parallel()
for _, entry := range datamap.Tables() {
if entry.Note == "" {
t.Errorf("catalog entry %q has no Note", entry.Name)
}
}
for _, dir := range datamap.Directories() {
if dir.Note == "" {
t.Errorf("catalog directory %q has no Note", dir.Name)
}
}
}
// FTS shadow tables must resolve to their parent entry rather than
// needing catalogue entries of their own.
func TestFTSShadowResolution(t *testing.T) {
t.Parallel()
entry, ok := datamap.Lookup("search_index_data")
if !ok {
t.Fatal("search_index_data did not resolve to a catalog entry")
}
if entry.Name != "search_index" {
t.Errorf("resolved to %q, want search_index", entry.Name)
}
}
+167
View File
@@ -0,0 +1,167 @@
package download
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Every remote provider here talks to a self-hosted service over the
// same shape of API: a base URL, one header carrying an API key, JSON
// in and out, and a handful of status codes that mean the same thing
// everywhere. This is that, once.
//
// Each adapter supplies its own sentinel errors so callers can still
// distinguish "slskd is down" from "Prowlarr is down" with errors.Is.
// apiClient is a small JSON-over-HTTP client for a self-hosted service.
type apiClient struct {
http *http.Client
baseURL string
authKey string
authValue string
// errUnreachable and errAuth are the adapter's sentinels, returned
// for transport failures and rejected credentials respectively.
errUnreachable error
errAuth error
}
// newAPIClient builds a client for one service.
func newAPIClient(
baseURL, authHeader, authValue string,
timeout time.Duration,
errUnreachable, errAuth error,
) *apiClient {
return &apiClient{
http: &http.Client{Timeout: timeout},
baseURL: strings.TrimRight(baseURL, "/"),
authKey: authHeader,
authValue: authValue,
errUnreachable: errUnreachable,
errAuth: errAuth,
}
}
// get performs a GET, decoding the response into out when non-nil.
func (c *apiClient) get(ctx context.Context, endpoint string, out any) error {
return c.do(ctx, http.MethodGet, endpoint, nil, out)
}
// post performs a POST with a JSON body.
func (c *apiClient) post(
ctx context.Context,
endpoint string,
body, out any,
) error {
return c.do(ctx, http.MethodPost, endpoint, body, out)
}
// put performs a PUT with a JSON body.
func (c *apiClient) put(
ctx context.Context,
endpoint string,
body, out any,
) error {
return c.do(ctx, http.MethodPut, endpoint, body, out)
}
// delete performs a DELETE.
func (c *apiClient) delete(ctx context.Context, endpoint string) error {
return c.do(ctx, http.MethodDelete, endpoint, nil, nil)
}
// do performs one request.
func (c *apiClient) do(
ctx context.Context,
method, endpoint string,
body, out any,
) error {
var reader io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request body: %w", err)
}
reader = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(
ctx, method, c.baseURL+endpoint, reader,
)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if c.authKey != "" {
req.Header.Set(c.authKey, c.authValue)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("%w: %w", c.errUnreachable, err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkStatus(resp); err != nil {
return err
}
if out == nil {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
// checkStatus maps HTTP status onto the adapter's sentinel errors,
// including a snippet of the body — self-hosted services put the useful
// part of a failure there, not in the status line.
func (c *apiClient) checkStatus(resp *http.Response) error {
switch {
case resp.StatusCode == http.StatusUnauthorized,
resp.StatusCode == http.StatusForbidden:
return c.errAuth
case resp.StatusCode >= 400:
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf(
"%w: HTTP %d: %s",
c.errUnreachable,
resp.StatusCode,
strings.TrimSpace(string(snippet)),
)
default:
return nil
}
}
// decodeJSON decodes a JSON string into out. Providers whose auth or
// response handling does not fit apiClient still parse bodies the same
// way, so the helper lives here rather than being repeated.
func decodeJSON(body string, out any) error {
if err := json.Unmarshal([]byte(body), out); err != nil {
return fmt.Errorf("decode json: %w", err)
}
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package download
import (
"context"
"testing"
"time"
)
func TestConcurrencyForPrefersOverrideThenKind(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg Config
want int
}{
{
name: "slskd defaults to one",
cfg: Config{Kind: KindSlskd},
want: 1,
},
{
name: "usenet defaults higher",
cfg: Config{Kind: KindSABnzbd},
want: 4,
},
{
name: "explicit override wins",
cfg: Config{
Kind: KindSlskd,
Settings: map[string]string{concurrencyKey: "3"},
},
want: 3,
},
{
name: "nonsense override falls back",
cfg: Config{
Kind: KindSlskd,
Settings: map[string]string{concurrencyKey: "not a number"},
},
want: 1,
},
{
name: "zero override falls back",
cfg: Config{
Kind: KindSlskd,
Settings: map[string]string{concurrencyKey: "0"},
},
want: 1,
},
{
name: "unknown kind falls back to the global default",
cfg: Config{Kind: Kind("something-new")},
want: defaultConcurrency,
},
}
for _, tt := range tests {
if got := concurrencyFor(tt.cfg); got != tt.want {
t.Errorf("%s: got %d, want %d", tt.name, got, tt.want)
}
}
}
// The reason the per-provider cap exists: a Soulseek daemon capped at
// one transfer must serialize, even when the global cap would allow
// more and the user has queued several albums at once.
func TestPerProviderCapSerializesTransfers(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
f.manager.SetMaxConcurrent(4)
slow := fakeWithAlbum(1, "slskd-like", ".flac")
slow.GrabGate = make(chan struct{})
f.manager.installProvider(Config{
ID: 1,
Kind: KindSlskd,
Priority: 50,
}, slow)
ctx := context.Background()
// Three requests against the same one-at-a-time provider.
for i := range 3 {
req := fourTrackRequest()
req.ID = "req-" + string(rune('a'+i))
if err := f.store.CreateRequest(ctx, req); err != nil {
t.Fatalf("CreateRequest: %v", err)
}
candidate := slow.Candidates[0]
candidate.ProviderID = 1
go f.manager.grab(ctx, req, candidate, nil)
}
// Give all three a chance to reach the transport, then check how
// many actually got through the gate.
waitFor(t, func() bool { return slow.GrabCallCount() >= 1 }, "no grab started")
time.Sleep(150 * time.Millisecond)
if got := slow.MaxParallelGrabs(); got != 1 {
t.Errorf("%d simultaneous transfers, want 1", got)
}
close(slow.GrabGate)
waitFor(
t,
func() bool { return slow.GrabCallCount() == 3 },
"not every queued transfer ran once the first finished",
)
if got := slow.MaxParallelGrabs(); got != 1 {
t.Errorf("%d simultaneous transfers overall, want 1", got)
}
}
// A provider that tolerates parallelism is not held to Soulseek's
// limit, and the global cap is what bounds it.
func TestPerProviderCapAllowsParallelWhereSafe(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
f.manager.SetMaxConcurrent(4)
fast := fakeWithAlbum(1, "usenet-like", ".flac")
fast.GrabGate = make(chan struct{})
f.manager.installProvider(Config{
ID: 1,
Kind: KindSABnzbd,
Priority: 50,
}, fast)
ctx := context.Background()
for i := range 3 {
req := fourTrackRequest()
req.ID = "req-" + string(rune('a'+i))
if err := f.store.CreateRequest(ctx, req); err != nil {
t.Fatalf("CreateRequest: %v", err)
}
candidate := fast.Candidates[0]
candidate.ProviderID = 1
go f.manager.grab(ctx, req, candidate, nil)
}
waitFor(
t,
func() bool { return fast.MaxParallelGrabs() >= 3 },
"transfers were serialized against a provider that allows parallelism",
)
close(fast.GrabGate)
}
// Reload must not strand a running transfer's slot when a provider's
// limit changes underneath it.
func TestSyncSemaphoresReplacesChangedLimits(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
f.manager.installProvider(Config{ID: 1, Kind: KindSlskd}, nil)
first := f.manager.semaphoreFor(1)
if cap(first) != 1 {
t.Fatalf("slskd semaphore cap = %d, want 1", cap(first))
}
// Same limit: the semaphore is kept, so in-flight accounting is not
// reset by an unrelated settings save.
f.manager.syncSemaphores(map[int64]Config{1: {ID: 1, Kind: KindSlskd}})
if again := f.manager.semaphoreFor(1); again != first {
t.Error("semaphore was replaced despite an unchanged limit")
}
// Changed limit: a new semaphore, with the new capacity.
f.manager.syncSemaphores(map[int64]Config{1: {
ID: 1,
Kind: KindSlskd,
Settings: map[string]string{concurrencyKey: "5"},
}})
f.manager.provMu.Lock()
f.manager.configs[1] = Config{
ID: 1,
Kind: KindSlskd,
Settings: map[string]string{concurrencyKey: "5"},
}
f.manager.provMu.Unlock()
if changed := f.manager.semaphoreFor(1); cap(changed) != 5 {
t.Errorf("semaphore cap = %d after raising the limit, want 5", cap(changed))
}
// A provider that is gone leaves no semaphore behind.
f.manager.syncSemaphores(map[int64]Config{})
f.manager.semMu.Lock()
_, still := f.manager.provSem[1]
f.manager.semMu.Unlock()
if still {
t.Error("semaphore survived the provider being removed")
}
}
+60
View File
@@ -0,0 +1,60 @@
package download
import "time"
// UserConfig is the download subsystem's slice of the TOML config file.
// Provider connections are not here — they live in the database, keyed
// by row, because there can be many of them and they change through the
// settings UI rather than by hand-editing.
type UserConfig struct {
// PathTemplate lays out imported files under the library root.
// Tokens: {albumartist} {artist} {album} {year} {track} {disc}
// {title}. Empty falls back to DefaultPathTemplate.
PathTemplate string `toml:"PathTemplate"`
// AutoPick lets a single high-confidence, high-quality candidate
// download without asking. Off by default: an unattended download
// that picks wrong puts the wrong files in the library, and the
// ranking has to earn that trust on a given user's sources first.
AutoPick bool `toml:"AutoPick"`
// MaxConcurrent bounds simultaneous transfers across all providers.
// Per-provider limits sit underneath it and are set on the provider
// itself, since the right number depends on what is on the other
// end: one Soulseek peer, or a usenet server built for parallelism.
MaxConcurrent int `toml:"MaxConcurrent"`
// WantedIntervalMinutes is how often the wanted list is reconciled:
// artist subscriptions expanded, owned items retired, due wants
// searched for. Zero uses the default.
WantedIntervalMinutes int `toml:"WantedIntervalMinutes"`
// WantedBatch bounds how many wants one reconcile pass searches
// for. A large list should be worked through steadily rather than
// in one burst that every provider sees as a flood.
WantedBatch int `toml:"WantedBatch"`
}
// ApplyDefaults fills unset fields.
func (c *UserConfig) ApplyDefaults() {
if c.PathTemplate == "" {
c.PathTemplate = DefaultPathTemplate
}
if c.MaxConcurrent <= 0 {
c.MaxConcurrent = defaultConcurrency
}
if c.WantedIntervalMinutes <= 0 {
c.WantedIntervalMinutes = int(defaultReconcileInterval / time.Minute)
}
if c.WantedBatch <= 0 {
c.WantedBatch = defaultDueBatch
}
}
// WantedInterval is the reconcile interval as a duration.
func (c *UserConfig) WantedInterval() time.Duration {
return time.Duration(c.WantedIntervalMinutes) * time.Minute
}
+283
View File
@@ -0,0 +1,283 @@
package download
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"sync"
)
// The fake provider exists so the pipeline can be tested end to end
// without a network, a daemon, or a binary on PATH. It is registered
// like any real adapter and excluded from Descriptors(), so it can
// never be offered in the UI.
// FakeProvider is an in-memory Provider used by tests. It fills all
// three roles; which ones are active is controlled by its Caps.
type FakeProvider struct {
info ProviderInfo
mu sync.Mutex
// Candidates is what Search returns.
Candidates []Candidate
// SearchErr, GrabErr and CheckErr are returned when set.
SearchErr error
GrabErr error
CheckErr error
// Written maps a relative file name to the bytes Grab creates in
// the staging directory, simulating a completed transfer.
Written map[string][]byte
// DelegateStatuses is returned by Poll in order, the last repeating.
DelegateStatuses []DelegateStatus
// GrabGate, when set, blocks Grab until it is closed or the
// context ends. It lets a test hold transfers open long enough to
// observe how many run at once.
GrabGate chan struct{}
// concurrent tracks how many Grabs are in flight, and maxParallel
// the high-water mark, which is what a concurrency cap is asserted
// against.
concurrent int
maxParallel int
// Calls records what happened, for assertions.
SearchCalls int
GrabCalls int
DelegateCalls int
pollIndex int
}
// NewFakeProvider returns a fake with the given capabilities.
func NewFakeProvider(id int64, name string, caps Caps) *FakeProvider {
return &FakeProvider{
info: ProviderInfo{
ID: id,
Kind: KindFake,
Name: name,
Enabled: true,
Priority: 50,
Caps: caps,
},
Written: map[string][]byte{},
}
}
// Info returns the fake's identity.
func (f *FakeProvider) Info() ProviderInfo {
return f.info
}
// SetPriority adjusts the fake's priority.
func (f *FakeProvider) SetPriority(p int) {
f.info.Priority = p
}
// Check reports configured health.
func (f *FakeProvider) Check(_ context.Context) error {
return f.CheckErr
}
// Close is a no-op.
func (f *FakeProvider) Close() error {
return nil
}
// Search returns the configured candidates.
func (f *FakeProvider) Search(
ctx context.Context,
_ Request,
) ([]Candidate, error) {
f.mu.Lock()
f.SearchCalls++
f.mu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // context error, already meaningful
}
if f.SearchErr != nil {
return nil, f.SearchErr
}
out := make([]Candidate, len(f.Candidates))
copy(out, f.Candidates)
for i := range out {
out[i].ProviderID = f.info.ID
}
return out, nil
}
// Grab writes the configured files into dst.
func (f *FakeProvider) Grab(
ctx context.Context,
_ Candidate,
dst string,
onProgress ProgressFunc,
) (Result, error) {
f.mu.Lock()
f.GrabCalls++
f.concurrent++
if f.concurrent > f.maxParallel {
f.maxParallel = f.concurrent
}
gate := f.GrabGate
f.mu.Unlock()
defer func() {
f.mu.Lock()
f.concurrent--
f.mu.Unlock()
}()
if gate != nil {
select {
case <-gate:
case <-ctx.Done():
return Result{}, ctx.Err() //nolint:wrapcheck // context error
}
}
if err := ctx.Err(); err != nil {
return Result{}, err //nolint:wrapcheck // context error
}
if f.GrabErr != nil {
return Result{}, f.GrabErr
}
files := make([]string, 0, len(f.Written))
var total int64
for name, data := range f.Written {
path := filepath.Join(dst, name)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return Result{}, err //nolint:wrapcheck // test helper
}
if err := os.WriteFile(path, data, 0o640); err != nil {
return Result{}, err //nolint:wrapcheck // test helper
}
files = append(files, path)
total += int64(len(data))
if onProgress != nil {
onProgress(Progress{Current: total, Total: total})
}
}
return Result{Dir: dst, Files: files, BytesTransferred: total}, nil
}
// GrabCallCount reports how many transfers have been started, safe to
// read while transfers are in flight.
func (f *FakeProvider) GrabCallCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.GrabCalls
}
// MaxParallelGrabs reports the most simultaneous transfers this
// provider ever saw, which is what a per-provider cap is asserted
// against.
func (f *FakeProvider) MaxParallelGrabs() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.maxParallel
}
// errNoDelegateStatus is returned when a fake delegator runs out of
// scripted statuses.
var errNoDelegateStatus = errors.New("fake: no delegate status configured")
// Delegate records the call and returns a fixed external ID.
func (f *FakeProvider) Delegate(
_ context.Context,
_ Request,
) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.DelegateCalls++
return "fake-external-1", nil
}
// Poll returns the next scripted status, repeating the last.
func (f *FakeProvider) Poll(
_ context.Context,
_ string,
) (DelegateStatus, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.DelegateStatuses) == 0 {
return DelegateStatus{}, errNoDelegateStatus
}
i := f.pollIndex
if i >= len(f.DelegateStatuses) {
i = len(f.DelegateStatuses) - 1
} else {
f.pollIndex++
}
return f.DelegateStatuses[i], nil
}
// Withdraw is a no-op.
func (f *FakeProvider) Withdraw(_ context.Context, _ string) error {
return nil
}
// fakeRegistry lets tests install providers directly, bypassing the
// database and the constructor registry.
func (m *Manager) installProvider(cfg Config, p Provider) {
m.provMu.Lock()
defer m.provMu.Unlock()
m.providers[cfg.ID] = p
m.configs[cfg.ID] = cfg
}
func init() {
Register(
Descriptor{
Kind: KindFake,
Name: "Fake (testing)",
Summary: "In-memory provider used by the test suite.",
Caps: Caps{
CanSearch: true,
CanTransport: true,
},
},
func(cfg Config, _ SecretLookup, _ *slog.Logger) (Provider, error) {
return NewFakeProvider(cfg.ID, cfg.Name, Caps{
CanSearch: true,
CanTransport: true,
}), nil
},
)
}
// slogDiscard returns a logger that writes nowhere, for tests.
func slogDiscard() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelError + 1,
}))
}
+536
View File
@@ -0,0 +1,536 @@
package download
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"yellowjacket/backend/tagwriter"
)
// The import step is the only writer into library paths. Everything
// before it happens in staging, where a bad download is a directory to
// delete rather than a row to un-ingest.
//
// Order matters: tags are written while the files are still staged, so
// the scanner's first sight of a file is already correct. Tagging
// after the move would mean a window where the library holds a track
// titled "01 - Track01.flac", and the user would watch it fix itself.
// Import errors.
var (
// ErrNoAudio means the grab produced no playable audio files.
ErrNoAudio = errors.New("download contained no audio files")
// ErrTooIncomplete means too few of the expected tracks arrived to
// call the download successful.
ErrTooIncomplete = errors.New("download is missing too many tracks")
// ErrDestinationExists means the computed library path is already
// occupied by a different file.
ErrDestinationExists = errors.New("destination file already exists")
)
// minCompleteness is the fraction of the expected tracklist that must
// arrive for an anchored import to proceed. Below this the download is
// a different thing than what was asked for — a single, a sampler, a
// partial transfer — and quietly importing it would corrupt the
// library's idea of the album.
const minCompleteness = 0.8
// TagWriterPort is the tag-writing capability the importer needs.
// Narrow interface rather than *tagwriter.TagWriter so importer tests
// do not need a database.
type TagWriterPort interface {
WriteUntrackedFileTags(filePath string, changes tagwriter.TagChanges) error
}
// LibraryPort is the library-side capability the importer needs.
type LibraryPort interface {
// ScanLibrary triggers a rescan so imported files are ingested.
ScanLibrary(id int64) error
}
// ImportOptions configures how imported files are laid out.
type ImportOptions struct {
// LibraryRoot is the directory imported files are placed under.
LibraryRoot string
// PathTemplate lays out the destination path. Supported tokens:
// {albumartist} {artist} {album} {year} {track} {disc} {title}.
// Empty means flat: everything into LibraryRoot/{albumartist}/{album}.
PathTemplate string
// WriteTags controls whether the importer tags files before moving
// them. Off for delegate providers, which have already imported
// and tagged the files themselves.
WriteTags bool
}
// DefaultPathTemplate is the layout used when none is configured.
const DefaultPathTemplate = "{albumartist}/{album}/{track} {title}"
// Importer moves verified downloads into the library.
type Importer struct {
logger *slog.Logger
staging *Staging
tags TagWriterPort
library LibraryPort
}
// NewImporter builds an importer.
func NewImporter(
logger *slog.Logger,
staging *Staging,
tags TagWriterPort,
library LibraryPort,
) *Importer {
return &Importer{
logger: logger,
staging: staging,
tags: tags,
library: library,
}
}
// ImportResult reports what an import placed where.
type ImportResult struct {
// Paths are the library paths files ended up at.
Paths []string
// Tagged counts files whose tags were rewritten.
Tagged int
// Skipped counts non-audio files left in staging (logs, cue sheets,
// scene .nfo files) — deliberately not imported.
Skipped int
}
// Import verifies, tags and moves a completed grab into the library.
//
// On any failure the staging directory is left intact so the user can
// retry or inspect it; only a fully successful import releases staging.
func (i *Importer) Import(
ctx context.Context,
req Request,
result Result,
opts ImportOptions,
) (ImportResult, error) {
files, err := i.staging.Verify(result.Dir, result.Files)
if err != nil {
return ImportResult{}, err
}
audio, skipped := splitAudio(files)
if len(audio) == 0 {
return ImportResult{}, ErrNoAudio
}
if err := checkCompleteness(len(audio), req); err != nil {
return ImportResult{}, err
}
// Align staged files to the expected tracklist so tags and
// filenames reflect the release, not the uploader's naming.
plan := i.planFiles(audio, req)
out := ImportResult{
Paths: make([]string, 0, len(plan)),
Skipped: skipped,
}
for _, p := range plan {
if err := ctx.Err(); err != nil {
return out, fmt.Errorf("import cancelled: %w", err)
}
if opts.WriteTags {
if err := i.tagFile(p, req); err != nil {
// A file that cannot be tagged is still worth importing
// — the scanner will read whatever tags it has, and the
// autotag queue can pick it up later. Losing the whole
// album over one unwritable file would be worse.
i.logger.Warn(
"could not tag downloaded file before import",
"path", p.Source,
"error", err,
)
} else {
out.Tagged++
}
}
dest, err := i.destinationFor(p, req, opts)
if err != nil {
return out, err
}
if err := movePath(p.Source, dest); err != nil {
return out, err
}
out.Paths = append(out.Paths, dest)
}
return out, nil
}
// plannedFile pairs a staged file with the expected track it matched.
type plannedFile struct {
Source string
// Track is the matched expected track, or the zero value when the
// file could not be aligned (free-text requests, bonus tracks).
Track ExpectedTrack
Matched bool
}
// planFiles aligns staged files to the expected tracklist.
func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
files := make([]CandidateFile, 0, len(audio))
for _, a := range audio {
format, isAudio := FormatForPath(a)
files = append(files, CandidateFile{
Path: a,
Format: format,
IsAudio: isAudio,
})
}
matched, _ := matchFiles(files, req.Expected)
byPosition := make(map[int]ExpectedTrack, len(req.Expected))
for _, e := range req.Expected {
byPosition[e.Position] = e
}
out := make([]plannedFile, 0, len(matched))
for _, m := range matched {
p := plannedFile{Source: m.Path}
if t, ok := byPosition[m.MatchedTo]; ok && m.MatchedTo != 0 {
p.Track = t
p.Matched = true
}
out = append(out, p)
}
// Stable order: matched tracks by position, then unmatched by path,
// so a partial import is reproducible.
sort.SliceStable(out, func(a, b int) bool {
if out[a].Matched != out[b].Matched {
return out[a].Matched
}
if out[a].Matched {
if out[a].Track.DiscNumber != out[b].Track.DiscNumber {
return out[a].Track.DiscNumber < out[b].Track.DiscNumber
}
return out[a].Track.Position < out[b].Track.Position
}
return out[a].Source < out[b].Source
})
return out
}
// tagFile writes the release's metadata onto a staged file.
func (i *Importer) tagFile(p plannedFile, req Request) error {
if i.tags == nil || !p.Matched {
return nil
}
changes := tagwriter.TagChanges{
tagwriter.FieldAlbum: req.Album,
tagwriter.FieldAlbumArtist: req.Artist,
tagwriter.FieldTitle: p.Track.Title,
tagwriter.FieldTrackNumber: p.Track.Position,
}
if p.Track.Artist != "" {
changes[tagwriter.FieldArtist] = p.Track.Artist
} else {
changes[tagwriter.FieldArtist] = req.Artist
}
if p.Track.DiscNumber > 0 {
changes[tagwriter.FieldDiscNumber] = p.Track.DiscNumber
}
if err := i.tags.WriteUntrackedFileTags(p.Source, changes); err != nil {
return fmt.Errorf("write tags: %w", err)
}
return nil
}
// destinationFor computes a file's library path from the template.
func (i *Importer) destinationFor(
p plannedFile,
req Request,
opts ImportOptions,
) (string, error) {
if opts.LibraryRoot == "" {
return "", fmt.Errorf(
"%w: no library root configured", ErrNotConfigured,
)
}
tmpl := opts.PathTemplate
if tmpl == "" {
tmpl = DefaultPathTemplate
}
ext := filepath.Ext(p.Source)
title := p.Track.Title
if title == "" {
// Unmatched file: keep the uploader's name rather than
// inventing one, so nothing is silently renamed to a track it
// may not be.
title = strings.TrimSuffix(filepath.Base(p.Source), ext)
}
artist := p.Track.Artist
if artist == "" {
artist = req.Artist
}
repl := strings.NewReplacer(
"{albumartist}", sanitizePathPart(fallback(req.Artist, "Unknown Artist")),
"{artist}", sanitizePathPart(fallback(artist, "Unknown Artist")),
"{album}", sanitizePathPart(fallback(req.Album, "Unknown Album")),
"{title}", sanitizePathPart(title),
"{track}", trackToken(p.Track.Position),
"{disc}", strconv.Itoa(p.Track.DiscNumber),
"{year}", "",
)
rel := repl.Replace(tmpl)
// Clean up any empty segments left by unset tokens.
parts := make([]string, 0, 4)
for _, seg := range strings.Split(rel, "/") {
seg = strings.TrimSpace(seg)
if seg != "" {
parts = append(parts, seg)
}
}
if len(parts) == 0 {
return "", fmt.Errorf(
"%w: path template produced an empty path", ErrNotConfigured,
)
}
dest := filepath.Join(opts.LibraryRoot, filepath.Join(parts...)) + ext
return uniqueDestination(dest)
}
// uniqueDestination returns dest, or a numbered variant when dest is
// taken. Overwriting is never right here: the existing file may be a
// better copy the user already owns, and the download is not
// authoritative just because it arrived later.
func uniqueDestination(dest string) (string, error) {
const maxAttempts = 50
ext := filepath.Ext(dest)
base := strings.TrimSuffix(dest, ext)
for n := range maxAttempts {
candidate := dest
if n > 0 {
candidate = base + " (" + strconv.Itoa(n+1) + ")" + ext
}
_, err := os.Stat(candidate)
if os.IsNotExist(err) {
return candidate, nil
}
if err != nil {
return "", fmt.Errorf("stat destination: %w", err)
}
}
return "", fmt.Errorf("%w: %s", ErrDestinationExists, dest)
}
// movePath moves a file, falling back to copy+remove when the staging
// area and the library are on different filesystems — which is the
// normal case, since staging lives in the user data directory.
func movePath(src, dest string) error {
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
if err := os.Rename(src, dest); err == nil {
return nil
}
if err := copyFile(src, dest); err != nil {
return err
}
if err := os.Remove(src); err != nil {
// The copy succeeded, so the import is good; a leftover staged
// file is swept later.
return nil //nolint:nilerr // staging sweep handles the leftover
}
return nil
}
// copyFile copies src to dest, writing to a temporary file first so an
// interrupted copy never leaves a partial file at a library path where
// the scanner would find it.
func copyFile(src, dest string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open downloaded file: %w", err)
}
defer func() { _ = in.Close() }()
tmp := dest + ".part"
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
if err != nil {
return fmt.Errorf("create library file: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return fmt.Errorf("copy into library: %w", err)
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("close library file: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("finalize library file: %w", err)
}
return nil
}
// checkCompleteness rejects an anchored download that is missing too
// much of its tracklist.
func checkCompleteness(got int, req Request) error {
if len(req.Expected) == 0 {
return nil
}
ratio := float64(got) / float64(len(req.Expected))
if ratio < minCompleteness {
return fmt.Errorf(
"%w: got %d of %d tracks",
ErrTooIncomplete, got, len(req.Expected),
)
}
return nil
}
// splitAudio partitions verified files into audio and a count of the
// rest.
func splitAudio(files []string) (audio []string, skipped int) {
audio = make([]string, 0, len(files))
for _, f := range files {
if _, ok := FormatForPath(f); ok {
audio = append(audio, f)
continue
}
skipped++
}
return audio, skipped
}
// trackToken formats a track number as a zero-padded two-digit string,
// or empty when unknown.
func trackToken(n int) string {
if n <= 0 {
return ""
}
if n < 10 {
return "0" + strconv.Itoa(n)
}
return strconv.Itoa(n)
}
// fallback returns s, or alt when s is blank.
func fallback(s, alt string) string {
if strings.TrimSpace(s) == "" {
return alt
}
return s
}
// sanitizePathPart makes a string safe as a single path segment on
// every supported platform: Windows reserves characters that are legal
// on Linux, and a library synced between the two must not produce
// unopenable files.
func sanitizePathPart(s string) string {
const maxSegment = 120
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
b.WriteByte('_')
default:
if r < 0x20 {
continue
}
b.WriteRune(r)
}
}
out := strings.TrimSpace(b.String())
// Trailing dots and spaces are silently stripped by Windows, which
// turns "Vol. 2 " into a name that no longer round-trips.
out = strings.TrimRight(out, ". ")
if len(out) > maxSegment {
out = strings.TrimSpace(out[:maxSegment])
}
if out == "" {
return "Unknown"
}
return out
}
+440
View File
@@ -0,0 +1,440 @@
package download
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"yellowjacket/backend/tagwriter"
)
// recordingTagWriter captures tag writes instead of touching files, so
// importer tests do not need real audio.
type recordingTagWriter struct {
mu sync.Mutex
writes map[string]tagwriter.TagChanges
failFor string
}
func newRecordingTagWriter() *recordingTagWriter {
return &recordingTagWriter{writes: map[string]tagwriter.TagChanges{}}
}
var errTagWriteFailed = errors.New("tag write failed")
func (r *recordingTagWriter) WriteUntrackedFileTags(
path string,
changes tagwriter.TagChanges,
) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.failFor != "" && strings.Contains(path, r.failFor) {
return errTagWriteFailed
}
r.writes[filepath.Base(path)] = changes
return nil
}
// stubLibrary records scan requests.
type stubLibrary struct {
mu sync.Mutex
scanned []int64
}
func (s *stubLibrary) ScanLibrary(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
s.scanned = append(s.scanned, id)
return nil
}
// importFixture stages a set of files and returns the pieces an import
// needs.
type importFixture struct {
staging *Staging
importer *Importer
tags *recordingTagWriter
lib *stubLibrary
dir string
root string
files []string
}
func newImportFixture(t *testing.T, names ...string) importFixture {
t.Helper()
staging := newTestStaging(t)
tags := newRecordingTagWriter()
lib := &stubLibrary{}
imp := NewImporter(slogDiscard(), staging, tags, lib)
dir, err := staging.Reserve("item-1")
if err != nil {
t.Fatalf("Reserve: %v", err)
}
files := make([]string, 0, len(names))
for _, n := range names {
p := filepath.Join(dir, n)
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(p, []byte("audio-data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
files = append(files, p)
}
return importFixture{
staging: staging,
importer: imp,
tags: tags,
lib: lib,
dir: dir,
root: t.TempDir(),
files: files,
}
}
func fourTrackRequest() Request {
return Request{
ID: "req-1",
LibraryID: 1,
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
Album: "OK Computer",
Expected: []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
{Position: 3, Title: "Subterranean Homesick Alien"},
{Position: 4, Title: "Exit Music (For a Film)"},
},
}
}
func TestImportPlacesAndTagsFiles(t *testing.T) {
t.Parallel()
f := newImportFixture(t,
"01 - Airbag.flac",
"02 - Paranoid Android.flac",
"03 - Subterranean Homesick Alien.flac",
"04 - Exit Music (For a Film).flac",
)
got, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
if len(got.Paths) != 4 {
t.Fatalf("imported %d files, want 4", len(got.Paths))
}
if got.Tagged != 4 {
t.Errorf("tagged %d files, want 4", got.Tagged)
}
want := filepath.Join(
f.root, "Radiohead", "OK Computer", "01 Airbag.flac",
)
if got.Paths[0] != want {
t.Errorf("first path = %s, want %s", got.Paths[0], want)
}
for _, p := range got.Paths {
if _, err := os.Stat(p); err != nil {
t.Errorf("imported file missing: %v", err)
}
}
}
// Tags must be written while the file is still staged: if the library
// ever sees an untagged file, the scanner ingests it and the user
// watches it correct itself.
func TestImportTagsBeforeMoving(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
if _, err := f.importer.Import(
context.Background(),
req,
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
); err != nil {
t.Fatalf("Import: %v", err)
}
changes, ok := f.tags.writes["01 - Airbag.flac"]
if !ok {
t.Fatalf(
"tags were not written to the staged filename; got writes for %v",
keysOf(f.tags.writes),
)
}
if changes[tagwriter.FieldTitle] != "Airbag" {
t.Errorf("title = %v, want Airbag", changes[tagwriter.FieldTitle])
}
if changes[tagwriter.FieldAlbum] != "OK Computer" {
t.Errorf("album = %v, want OK Computer", changes[tagwriter.FieldAlbum])
}
}
// One unwritable file should not cost the whole album.
func TestImportContinuesWhenTaggingFails(t *testing.T) {
t.Parallel()
f := newImportFixture(t,
"01 - Airbag.flac",
"02 - Paranoid Android.flac",
"03 - Subterranean Homesick Alien.flac",
"04 - Exit Music (For a Film).flac",
)
f.tags.failFor = "Paranoid"
got, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
if len(got.Paths) != 4 {
t.Errorf("imported %d files, want all 4", len(got.Paths))
}
if got.Tagged != 3 {
t.Errorf("tagged %d, want 3 (one failure)", got.Tagged)
}
}
func TestImportRejectsTooIncomplete(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
_, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if !errors.Is(err, ErrTooIncomplete) {
t.Fatalf("error = %v, want ErrTooIncomplete", err)
}
entries, _ := os.ReadDir(f.root)
if len(entries) != 0 {
t.Error("failed import wrote into the library root")
}
}
func TestImportRejectsNoAudio(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "rip.log", "cover.jpg")
_, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if !errors.Is(err, ErrNoAudio) {
t.Fatalf("error = %v, want ErrNoAudio", err)
}
}
func TestImportSkipsNonAudioFiles(t *testing.T) {
t.Parallel()
f := newImportFixture(t,
"01 - Airbag.flac",
"02 - Paranoid Android.flac",
"03 - Subterranean Homesick Alien.flac",
"04 - Exit Music (For a Film).flac",
"rip.log",
"cover.jpg",
)
got, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
if got.Skipped != 2 {
t.Errorf("skipped = %d, want 2", got.Skipped)
}
if len(got.Paths) != 4 {
t.Errorf("imported %d, want 4 audio files only", len(got.Paths))
}
}
// An existing file is never overwritten: it may be a better copy the
// user already owns, and arriving later does not make a download
// authoritative.
func TestImportNeverOverwrites(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
existing := filepath.Join(
f.root, "Radiohead", "OK Computer", "01 Airbag.flac",
)
if err := os.MkdirAll(filepath.Dir(existing), 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(existing, []byte("original"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := f.importer.Import(
context.Background(),
req,
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
data, err := os.ReadFile(existing)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(data) != "original" {
t.Error("import overwrote an existing library file")
}
if got.Paths[0] == existing {
t.Errorf("imported to the occupied path %s", existing)
}
}
func TestImportCustomPathTemplate(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
got, err := f.importer.Import(
context.Background(),
req,
Result{Dir: f.dir, Files: f.files},
ImportOptions{
LibraryRoot: f.root,
PathTemplate: "{albumartist} - {album}/{track}. {title}",
WriteTags: true,
},
)
if err != nil {
t.Fatalf("Import: %v", err)
}
want := filepath.Join(
f.root, "Radiohead - OK Computer", "01. Airbag.flac",
)
if got.Paths[0] != want {
t.Errorf("path = %s, want %s", got.Paths[0], want)
}
}
func TestSanitizePathPart(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want string
}{
{"AC/DC", "AC_DC"},
{"Where Are We Now?", "Where Are We Now_"},
{`Bad: Title*`, "Bad_ Title_"},
{"Vol. 2 ", "Vol. 2"},
{"trailing dots...", "trailing dots"},
{"", "Unknown"},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
t.Parallel()
if got := sanitizePathPart(tt.in); got != tt.want {
t.Errorf("sanitizePathPart(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestImportRequiresLibraryRoot(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
_, err := f.importer.Import(
context.Background(),
req,
Result{Dir: f.dir, Files: f.files},
ImportOptions{WriteTags: true},
)
if !errors.Is(err, ErrNotConfigured) {
t.Fatalf("error = %v, want ErrNotConfigured", err)
}
}
func keysOf(m map[string]tagwriter.TagChanges) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
File diff suppressed because it is too large Load Diff
+507
View File
@@ -0,0 +1,507 @@
package download
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"yellowjacket/backend/database"
)
// managerFixture wires a manager over a real (in-memory) database, a
// temp staging area and a temp library root, with no network anywhere.
type managerFixture struct {
manager *Manager
store *Store
staging *Staging
lib *stubLibrary
tags *recordingTagWriter
root string
}
func newManagerFixture(t *testing.T) managerFixture {
t.Helper()
db := database.NewTestDB(t)
seedLibrary(t, db)
store := NewStore(db)
staging := newTestStaging(t)
tags := newRecordingTagWriter()
lib := &stubLibrary{}
root := t.TempDir()
imp := NewImporter(slogDiscard(), staging, tags, lib)
m := NewManager(
slogDiscard(), store, NewMemSecretStore(), staging, imp, lib,
)
m.SetImportOptions(ImportOptions{LibraryRoot: root})
return managerFixture{
manager: m,
store: store,
staging: staging,
lib: lib,
tags: tags,
root: root,
}
}
// seedLibrary inserts the library row download_requests references.
func seedLibrary(t *testing.T, db *database.DB) {
t.Helper()
if _, err := db.ExecContext(
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
); err != nil {
t.Fatalf("seed library: %v", err)
}
}
// fakeWithAlbum returns a fake provider that finds and can deliver the
// four-track reference album.
func fakeWithAlbum(id int64, name string, ext string) *FakeProvider {
f := NewFakeProvider(id, name, Caps{CanSearch: true, CanTransport: true})
titles := allTitles()
c := candidateFor(name+"-cand", titles, ext, 30_000_000)
c.ProviderID = id
f.Candidates = []Candidate{c}
for i, tt := range titles {
f.Written[trackToken(i+1)+" - "+tt+ext] = []byte("audio-data")
}
return f
}
func TestManagerSearchRanksAcrossProviders(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
mp3 := fakeWithAlbum(1, "mp3-source", ".mp3")
flac := fakeWithAlbum(2, "flac-source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, mp3)
f.manager.installProvider(Config{ID: 2, Priority: 50}, flac)
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(ranked) != 2 {
t.Fatalf("got %d candidates, want 2", len(ranked))
}
if ranked[0].ProviderID != 2 {
t.Errorf(
"winner from provider %d, want 2 (FLAC)", ranked[0].ProviderID,
)
}
if mp3.SearchCalls != 1 || flac.SearchCalls != 1 {
t.Errorf(
"search calls: mp3=%d flac=%d, want 1 each",
mp3.SearchCalls, flac.SearchCalls,
)
}
}
// One broken provider must not take the others down with it.
func TestManagerSearchToleratesProviderFailure(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
broken := NewFakeProvider(1, "broken", Caps{CanSearch: true})
broken.SearchErr = errors.New("connection refused") //nolint:err113 // test
working := fakeWithAlbum(2, "working", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, broken)
f.manager.installProvider(Config{ID: 2, Priority: 50}, working)
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(ranked) != 1 {
t.Fatalf("got %d candidates, want 1 from the working provider", len(ranked))
}
}
func TestManagerSearchNoProviders(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
_, err := f.manager.Search(context.Background(), fourTrackRequest())
if !errors.Is(err, ErrNoProviders) {
t.Fatalf("error = %v, want ErrNoProviders", err)
}
}
func TestManagerSearchNoCandidates(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
empty := NewFakeProvider(1, "empty", Caps{CanSearch: true})
f.manager.installProvider(Config{ID: 1, Priority: 50}, empty)
_, err := f.manager.Search(context.Background(), fourTrackRequest())
if !errors.Is(err, ErrNoCandidates) {
t.Fatalf("error = %v, want ErrNoCandidates", err)
}
}
// The whole pipeline: request → search → auto-pick → grab → verify →
// tag → import → library scan.
func TestManagerEndToEndAutoPick(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
provider := fakeWithAlbum(1, "flac-source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
ranked, err := f.manager.Start(context.Background(), req)
if err != nil {
t.Fatalf("Start: %v", err)
}
if !AutoPickable(req, ranked) {
t.Fatalf(
"expected a clear winner to auto-pick; best match %f quality %f",
ranked[0].Match.Overall, ranked[0].Quality.Overall,
)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
if provider.GrabCalls != 1 {
t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
}
// Files landed in the library, laid out by the template.
want := filepath.Join(f.root, "Radiohead", "OK Computer", "01 Airbag.flac")
if _, err := os.Stat(want); err != nil {
t.Errorf("expected imported file at %s: %v", want, err)
}
// Staging was released only after a successful import.
entries, err := os.ReadDir(f.staging.Root())
if err != nil {
t.Fatalf("read staging root: %v", err)
}
if len(entries) != 0 {
t.Errorf("staging not released: %d dirs remain", len(entries))
}
// The library was told to rescan.
f.lib.mu.Lock()
scanned := len(f.lib.scanned)
f.lib.mu.Unlock()
if scanned != 1 {
t.Errorf("library scans = %d, want 1", scanned)
}
}
// An ambiguous result set must park for the user rather than guess.
func TestManagerWaitsWhenAmbiguous(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
a := fakeWithAlbum(1, "source-a", ".flac")
b := fakeWithAlbum(2, "source-b", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
f.manager.installProvider(Config{ID: 2, Priority: 50}, b)
req := fourTrackRequest()
ranked, err := f.manager.Start(context.Background(), req)
if err != nil {
t.Fatalf("Start: %v", err)
}
if AutoPickable(req, ranked) {
t.Fatal("two equivalent candidates must not auto-pick")
}
// Nothing was grabbed while waiting for the user.
if a.GrabCalls != 0 || b.GrabCalls != 0 {
t.Errorf(
"grabs happened without a pick: a=%d b=%d",
a.GrabCalls, b.GrabCalls,
)
}
stored, err := f.store.GetRequest(context.Background(), req.ID)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if stored.ID != req.ID {
t.Errorf("stored request id = %s, want %s", stored.ID, req.ID)
}
// The user picks the second one explicitly.
if err := f.manager.Pick(
context.Background(), req.ID, ranked[1].ID,
); err != nil {
t.Fatalf("Pick: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
}
func TestManagerPickUnknownCandidate(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
req.Expected = nil // free text: never auto-picks
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
err := f.manager.Pick(context.Background(), req.ID, "no-such-candidate")
if !errors.Is(err, ErrCandidateGone) {
t.Fatalf("error = %v, want ErrCandidateGone", err)
}
}
// A failed grab must leave staging intact for retry and must not put
// anything in the library.
func TestManagerFailedGrabLeavesLibraryClean(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
provider := fakeWithAlbum(1, "source", ".flac")
provider.GrabErr = errors.New("peer went offline") //nolint:err113 // test
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateFailed)
entries, err := os.ReadDir(f.root)
if err != nil {
t.Fatalf("read library root: %v", err)
}
if len(entries) != 0 {
t.Errorf("failed grab wrote %d entries into the library", len(entries))
}
staged, err := os.ReadDir(f.staging.Root())
if err != nil {
t.Fatalf("read staging root: %v", err)
}
if len(staged) == 0 {
t.Error("staging released after a failure; nothing left to retry")
}
}
// A search-only provider's candidate is fetched by whichever enabled
// transport handles its protocol.
func TestManagerPairsSearcherWithTransport(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
searcher := NewFakeProvider(1, "indexer", Caps{CanSearch: true})
titles := allTitles()
c := candidateFor("torrent-cand", titles, ".flac", 30_000_000)
c.Protocol = ProtocolTorrent
searcher.Candidates = []Candidate{c}
transport := NewFakeProvider(2, "torrent-client", Caps{
CanTransport: true,
Transports: []Protocol{ProtocolTorrent},
})
for i, tt := range titles {
transport.Written[trackToken(i+1)+" - "+tt+".flac"] = []byte("audio-data")
}
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
f.manager.installProvider(Config{ID: 2, Priority: 50}, transport)
req := fourTrackRequest()
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
if transport.GrabCalls != 1 {
t.Errorf("transport grabs = %d, want 1", transport.GrabCalls)
}
if searcher.GrabCalls != 0 {
t.Errorf("searcher should not have grabbed, got %d", searcher.GrabCalls)
}
}
// A candidate whose protocol nothing handles must fail loudly rather
// than being silently dropped.
func TestManagerNoTransportForProtocol(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
searcher := NewFakeProvider(1, "indexer", Caps{CanSearch: true})
c := candidateFor("usenet-cand", allTitles(), ".flac", 30_000_000)
c.Protocol = ProtocolUsenet
searcher.Candidates = []Candidate{c}
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
req := fourTrackRequest()
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateFailed)
}
// waitForRequestState polls until a request reaches the wanted state.
// The pipeline runs on its own goroutine, so tests observe it through
// the store rather than by reaching into the manager.
func waitForRequestState(
t *testing.T,
store *Store,
requestID string,
want State,
) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var last State
for time.Now().Before(deadline) {
state, _, err := store.GetRequestState(context.Background(), requestID)
if err == nil {
last = state
if last == want {
return
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("request state = %q after 5s, want %q", last, want)
}
// A delegate's files are already in the external manager's library,
// tagged by it and at paths it chose. The pipeline must record them in
// place rather than moving them out from under a system that is still
// managing them.
func TestManagerDelegateReconcilesInPlace(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
f.manager.delegatePoll = time.Millisecond
delegate := NewFakeProvider(1, "lidarr", Caps{
CanSearch: true,
CanDelegate: true,
})
c := candidateFor("delegate-cand", allTitles(), ".flac", 30_000_000)
c.ProviderID = 1
delegate.Candidates = []Candidate{c}
external := []string{
"/external/library/Radiohead/OK Computer/01 Airbag.flac",
"/external/library/Radiohead/OK Computer/02 Paranoid Android.flac",
}
delegate.DelegateStatuses = []DelegateStatus{
{State: StateGrabbing, Progress: 0.5},
{State: StateComplete, Progress: 1, ImportedPaths: external},
}
f.manager.installProvider(Config{ID: 1, Priority: 50}, delegate)
req := fourTrackRequest()
if _, err := f.manager.Start(context.Background(), req); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
if delegate.DelegateCalls != 1 {
t.Errorf("delegate calls = %d, want 1", delegate.DelegateCalls)
}
// Nothing was tagged or written into our library root — the files
// belong to the external manager.
entries, err := os.ReadDir(f.root)
if err != nil {
t.Fatalf("read library root: %v", err)
}
if len(entries) != 0 {
t.Errorf("delegate import wrote %d entries into our library", len(entries))
}
f.tags.mu.Lock()
writes := len(f.tags.writes)
f.tags.mu.Unlock()
if writes != 0 {
t.Errorf("tagged %d files, want 0 for a delegated import", writes)
}
// The external paths were recorded against the item.
items, err := f.store.ListItemsForRequest(context.Background(), req.ID)
if err != nil {
t.Fatalf("ListItemsForRequest: %v", err)
}
if len(items) != 1 {
t.Fatalf("got %d items, want 1", len(items))
}
if len(items[0].Imported) != len(external) {
t.Errorf(
"recorded %v, want the external manager's paths %v",
items[0].Imported, external,
)
}
}
+314
View File
@@ -0,0 +1,314 @@
package download
import (
"path"
"regexp"
"strconv"
"strings"
"yellowjacket/backend/autotag"
)
// Candidate files arrive as paths, not tags — a Soulseek result is
// `@@abc\Music\Pink Floyd - The Wall (1979) [FLAC]\1-01 In The Flesh.flac`
// and nothing more. Everything the ranker knows about whether a
// candidate is the right album comes from parsing that string, so the
// heuristics here carry real weight.
// audioExtensions maps a lowercase file extension to its format.
var audioExtensions = map[string]Format{
".flac": FormatFLAC,
".mp3": FormatMP3,
".ogg": FormatOGG,
".oga": FormatOGG,
".opus": FormatOpus,
".wav": FormatWAV,
".m4a": FormatAAC,
".aac": FormatAAC,
".alac": FormatALAC,
".wma": FormatWMA,
".ape": FormatUnknown,
".wv": FormatUnknown,
}
var (
// trackNumPattern matches a leading track number in the common
// shapes: "01 - Title", "1. Title", "1-01 Title" (disc-track),
// "[01] Title". The disc group is optional.
trackNumPattern = regexp.MustCompile(
`^\s*\[?(?:(\d{1,2})\s*[-_.]\s*)?(\d{1,3})\]?\s*[-_.)\]]?\s+`,
)
// bareTrackNumPattern matches a number with no separator at all
// ("01Title" is rare, but "01 Title" with a single space is not).
bareTrackNumPattern = regexp.MustCompile(`^\s*(\d{1,3})\s+`)
// bitratePattern finds a bitrate hint in a folder or file name:
// "[320]", "V0", "320kbps", "(V2)".
bitratePattern = regexp.MustCompile(
`(?i)\b(\d{2,4})\s*k(?:bps|b/s)?\b|\[(\d{2,4})\]`,
)
// vbrPattern finds LAME VBR preset names, which imply a bitrate
// band rather than a number.
vbrPattern = regexp.MustCompile(`(?i)\b(V[0-2])\b`)
// yearPattern finds a 4-digit year in parentheses or brackets.
yearPattern = regexp.MustCompile(`[(\[](19|20)\d{2}[)\]]`)
// junkSuffixPattern strips scene/rip tags from a folder name before
// comparing it to an album title.
junkSuffixPattern = regexp.MustCompile(
`(?i)[\[(]\s*(flac|mp3|web|cd|vinyl|24bit|16bit|lossless|` +
`v0|v2|320|256|192|128|kbps|reissue|remaster(ed)?|` +
`\d{2,3}\s*k(bps)?)\s*[^\])]*[\])]`,
)
// separatorPattern splits "Artist - Album" style folder names.
separatorPattern = regexp.MustCompile(`\s+[-–—]\s+`)
)
// FormatForPath returns the audio format implied by a path's extension,
// and whether the path is audio at all. Cue sheets, logs, playlists
// and cover images are not.
func FormatForPath(p string) (Format, bool) {
ext := strings.ToLower(path.Ext(strings.ReplaceAll(p, `\`, "/")))
f, ok := audioExtensions[ext]
return f, ok
}
// TrackHint is what a single candidate file's path reveals about the
// track it holds. Every field is best-effort and may be zero.
type TrackHint struct {
Disc int
Track int
// Title is the filename with the extension, track number and any
// leading artist credit removed.
Title string
// Folder is the immediate parent directory name, cleaned of scene
// tags — the best available proxy for the album title.
Folder string
}
// ParsePath extracts what it can from one candidate file path.
func ParsePath(p string) TrackHint {
// Soulseek paths are Windows-style; normalize before splitting.
norm := strings.ReplaceAll(p, `\`, "/")
base := path.Base(norm)
folder := path.Base(path.Dir(norm))
name := strings.TrimSuffix(base, path.Ext(base))
hint := TrackHint{Folder: cleanAlbumName(folder)}
if m := trackNumPattern.FindStringSubmatch(name); m != nil {
if m[1] != "" {
hint.Disc, _ = strconv.Atoi(m[1])
}
hint.Track, _ = strconv.Atoi(m[2])
name = name[len(m[0]):]
} else if m := bareTrackNumPattern.FindStringSubmatch(name); m != nil {
hint.Track, _ = strconv.Atoi(m[1])
name = name[len(m[0]):]
}
// "Artist - Title" inside the filename: drop the leading credit
// when what follows is substantial. Guessing wrong here costs a
// little title similarity; not doing it costs a lot, because most
// Soulseek folders name the artist in every file.
if parts := separatorPattern.Split(name, 2); len(parts) == 2 {
if len(strings.TrimSpace(parts[1])) >= 3 {
name = parts[1]
}
}
hint.Title = strings.TrimSpace(name)
return hint
}
// cleanAlbumName strips year markers and scene tags from a folder name
// so it can be compared against a release title.
func cleanAlbumName(folder string) string {
s := junkSuffixPattern.ReplaceAllString(folder, " ")
s = yearPattern.ReplaceAllString(s, " ")
// A folder is often "Artist - Album"; keep the right-hand side when
// there is one, since the album is what we compare against.
if parts := separatorPattern.Split(s, 2); len(parts) == 2 {
if len(strings.TrimSpace(parts[1])) >= 2 {
s = parts[1]
}
}
return strings.TrimSpace(strings.Join(strings.Fields(s), " "))
}
// BitrateForPath infers a bitrate in kbps from path text. Returns 0
// when nothing is stated. VBR presets map to their nominal average.
func BitrateForPath(p string) int {
if m := vbrPattern.FindStringSubmatch(p); m != nil {
switch strings.ToUpper(m[1]) {
case "V0":
return 245
case "V1":
return 225
case "V2":
return 190
}
}
if m := bitratePattern.FindStringSubmatch(p); m != nil {
raw := m[1]
if raw == "" {
raw = m[2]
}
if n, err := strconv.Atoi(raw); err == nil && n >= 32 && n <= 3000 {
return n
}
}
return 0
}
// AnnotateFiles fills in Format, IsAudio and Bitrate for a candidate's
// files. Providers call this so each adapter does not re-derive the
// same things from the same paths.
func AnnotateFiles(files []CandidateFile) []CandidateFile {
out := make([]CandidateFile, len(files))
for i, f := range files {
format, isAudio := FormatForPath(f.Path)
f.IsAudio = isAudio
if f.Format == FormatUnknown {
f.Format = format
}
if f.Bitrate == 0 {
f.Bitrate = BitrateForPath(f.Path)
}
out[i] = f
}
return out
}
// matchFiles aligns a candidate's audio files to the expected tracklist
// and returns the per-file assignment plus the mean title similarity of
// the aligned pairs.
//
// Alignment is greedy by score rather than optimal: candidate folders
// are small (a few dozen files at most) and the common cases — correct
// track numbers, or clean "NN Title" names — are unambiguous, so the
// extra machinery of Hungarian assignment buys nothing here.
func matchFiles(
files []CandidateFile,
expected []ExpectedTrack,
) ([]CandidateFile, float64) {
annotated := make([]CandidateFile, len(files))
copy(annotated, files)
if len(expected) == 0 {
return annotated, 0
}
hints := make([]TrackHint, len(annotated))
for i, f := range annotated {
hints[i] = ParsePath(f.Path)
}
takenExpected := make(map[int]bool, len(expected))
var (
total float64
matched int
)
// Pass 1: trust explicit track numbers when they are unique and in
// range. A folder that numbers its files correctly is the strong
// case, and title comparison only adds noise there.
for i := range annotated {
if !annotated[i].IsAudio || hints[i].Track == 0 {
continue
}
idx := indexForPosition(expected, hints[i].Disc, hints[i].Track)
if idx < 0 || takenExpected[idx] {
continue
}
takenExpected[idx] = true
annotated[i].MatchedTo = expected[idx].Position
total += autotag.TitleSimilarity(hints[i].Title, expected[idx].Title)
matched++
}
// Pass 2: title similarity for whatever is left.
for i := range annotated {
if !annotated[i].IsAudio || annotated[i].MatchedTo != 0 {
continue
}
bestIdx, bestSim := -1, 0.0
for j := range expected {
if takenExpected[j] {
continue
}
sim := autotag.TitleSimilarity(hints[i].Title, expected[j].Title)
if sim > bestSim {
bestIdx, bestSim = j, sim
}
}
// Below this the "match" is two unrelated strings sharing a few
// characters, and counting it drags the mean toward noise.
const minTitleSim = 0.55
if bestIdx < 0 || bestSim < minTitleSim {
continue
}
takenExpected[bestIdx] = true
annotated[i].MatchedTo = expected[bestIdx].Position
total += bestSim
matched++
}
if matched == 0 {
return annotated, 0
}
return annotated, total / float64(matched)
}
// indexForPosition finds the expected track at a disc/track position.
// A zero disc hint matches on track number alone, which is right for
// single-disc releases and the best guess for multi-disc folders that
// do not encode the disc.
func indexForPosition(expected []ExpectedTrack, disc, track int) int {
for i, e := range expected {
if e.Position != track {
continue
}
if disc != 0 && e.DiscNumber != 0 && e.DiscNumber != disc {
continue
}
return i
}
return -1
}
+242
View File
@@ -0,0 +1,242 @@
package download
import "testing"
func TestParsePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantDisc int
wantTrack int
wantTitle string
}{
{
name: "soulseek windows path with disc and track",
path: `@@abc\Music\Pink Floyd - The Wall (1979) [FLAC]\1-05 Another Brick In The Wall.flac`,
wantDisc: 1,
wantTrack: 5,
wantTitle: "Another Brick In The Wall",
},
{
name: "dash separated track number",
path: "Radiohead - OK Computer/03 - Subterranean Homesick Alien.mp3",
wantTrack: 3,
wantTitle: "Subterranean Homesick Alien",
},
{
name: "dotted track number",
path: "Album/7. Karma Police.flac",
wantTrack: 7,
wantTitle: "Karma Police",
},
{
name: "bracketed track number",
path: "Album/[02] Paranoid Android.mp3",
wantTrack: 2,
wantTitle: "Paranoid Android",
},
{
name: "bare number and space",
path: "Album/11 Lucky.ogg",
wantTrack: 11,
wantTitle: "Lucky",
},
{
name: "artist credit inside filename is dropped",
path: "VA - Comp/04 - Aphex Twin - Xtal.flac",
wantTrack: 4,
wantTitle: "Xtal",
},
{
name: "no track number",
path: "Album/Introduction.flac",
wantTrack: 0,
wantTitle: "Introduction",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ParsePath(tt.path)
if got.Disc != tt.wantDisc {
t.Errorf("Disc = %d, want %d", got.Disc, tt.wantDisc)
}
if got.Track != tt.wantTrack {
t.Errorf("Track = %d, want %d", got.Track, tt.wantTrack)
}
if got.Title != tt.wantTitle {
t.Errorf("Title = %q, want %q", got.Title, tt.wantTitle)
}
})
}
}
func TestCleanAlbumName(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want string
}{
{"Pink Floyd - The Wall (1979) [FLAC]", "The Wall"},
{"Radiohead - OK Computer [V0]", "OK Computer"},
{"In Rainbows", "In Rainbows"},
{"Artist - Album [320kbps]", "Album"},
{"Kid A (2000)", "Kid A"},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
t.Parallel()
if got := cleanAlbumName(tt.in); got != tt.want {
t.Errorf("cleanAlbumName(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestBitrateForPath(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want int
}{
{"Album [320]/01 Track.mp3", 320},
{"Album [V0]/01 Track.mp3", 245},
{"Album (V2)/01 Track.mp3", 190},
{"Album 192kbps/01 Track.mp3", 192},
{"Album/01 Track.flac", 0},
{"Album [9999]/01 Track.mp3", 0}, // out of plausible range
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
t.Parallel()
if got := BitrateForPath(tt.in); got != tt.want {
t.Errorf("BitrateForPath(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
func TestFormatForPath(t *testing.T) {
t.Parallel()
tests := []struct {
path string
want Format
wantAudio bool
}{
{"a/b.flac", FormatFLAC, true},
{"a/b.MP3", FormatMP3, true},
{`a\b.ogg`, FormatOGG, true},
{"a/cover.jpg", FormatUnknown, false},
{"a/rip.log", FormatUnknown, false},
{"a/playlist.m3u", FormatUnknown, false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
t.Parallel()
got, isAudio := FormatForPath(tt.path)
if isAudio != tt.wantAudio {
t.Errorf("isAudio = %v, want %v", isAudio, tt.wantAudio)
}
if isAudio && got != tt.want {
t.Errorf("format = %q, want %q", got, tt.want)
}
})
}
}
func TestMatchFilesByTrackNumber(t *testing.T) {
t.Parallel()
expected := []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
{Position: 3, Title: "Subterranean Homesick Alien"},
}
files := []CandidateFile{
{Path: "OK Computer/01 - Airbag.flac", IsAudio: true},
{Path: "OK Computer/02 - Paranoid Android.flac", IsAudio: true},
{Path: "OK Computer/03 - Subterranean Homesick Alien.flac", IsAudio: true},
}
matched, sim := matchFiles(files, expected)
for i, m := range matched {
if m.MatchedTo != i+1 {
t.Errorf("file %d matched to %d, want %d", i, m.MatchedTo, i+1)
}
}
if sim < 0.99 {
t.Errorf("similarity = %f, want ~1.0", sim)
}
}
// Track numbers that lie are the common Soulseek failure: a folder
// numbered 1..N whose contents are a different album entirely. Title
// matching has to be what catches it.
func TestMatchFilesFallsBackToTitles(t *testing.T) {
t.Parallel()
expected := []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
}
files := []CandidateFile{
{Path: "Album/Paranoid Android.flac", IsAudio: true},
{Path: "Album/Airbag.flac", IsAudio: true},
}
matched, sim := matchFiles(files, expected)
if matched[0].MatchedTo != 2 {
t.Errorf("first file matched to %d, want 2", matched[0].MatchedTo)
}
if matched[1].MatchedTo != 1 {
t.Errorf("second file matched to %d, want 1", matched[1].MatchedTo)
}
if sim < 0.9 {
t.Errorf("similarity = %f, want high", sim)
}
}
func TestMatchFilesUnrelatedScoresLow(t *testing.T) {
t.Parallel()
expected := []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
}
files := []CandidateFile{
{Path: "Other/Enter Sandman.flac", IsAudio: true},
{Path: "Other/Master Of Puppets.flac", IsAudio: true},
}
_, sim := matchFiles(files, expected)
if sim > 0.5 {
t.Errorf("similarity = %f, want low for unrelated tracks", sim)
}
}
+350
View File
@@ -0,0 +1,350 @@
package download
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strconv"
"sync"
)
// Provider errors.
var (
// ErrUnknownKind is returned when a stored provider row names a kind
// no constructor is registered for — an old row after a provider
// was removed, or a config file from a newer build.
ErrUnknownKind = errors.New("unknown provider kind")
// ErrNotConfigured means the provider exists but is missing
// required settings (host, API key) and cannot be used yet.
ErrNotConfigured = errors.New("provider is not configured")
// ErrUnsupported is returned when a caller asks a provider for a
// role it does not fill.
ErrUnsupported = errors.New("provider does not support this operation")
// ErrNoTransport means a search-only provider produced a candidate
// whose protocol no enabled transport can fetch.
ErrNoTransport = errors.New("no enabled transport handles this protocol")
)
// Provider is the common surface every adapter implements. The three
// role interfaces below are optional and discovered by type assertion,
// gated on what Caps declares.
type Provider interface {
// Info returns the provider's identity and declared capabilities.
Info() ProviderInfo
// Check verifies the provider is reachable and configured
// correctly. It backs the "test connection" button, and the
// pipeline calls it before first use in a session.
Check(ctx context.Context) error
// Close releases any long-lived resources (sessions, cookies).
Close() error
}
// Searcher turns a request into candidates. Implementations must
// respect ctx deadlines: the pipeline searches providers concurrently
// with a per-provider timeout and takes whatever came back in time.
type Searcher interface {
Search(ctx context.Context, req Request) ([]Candidate, error)
}
// Transporter moves a candidate's bytes into dst, which the pipeline
// has already created and which the transport owns for the duration.
//
// Implementations report progress through onProgress (best-effort, may
// be nil) and must return promptly when ctx is cancelled, leaving
// partial files in place — the pipeline sweeps them.
type Transporter interface {
Grab(
ctx context.Context,
c Candidate,
dst string,
onProgress ProgressFunc,
) (Result, error)
}
// Delegator hands the whole request to an external manager. Unlike a
// Transporter we do not own the transfer, so the pipeline polls until
// the manager reports terminal state.
type Delegator interface {
// Delegate submits the request and returns the manager's own ID.
Delegate(ctx context.Context, req Request) (string, error)
// Poll reports on a previously delegated request.
Poll(ctx context.Context, externalID string) (DelegateStatus, error)
// Withdraw asks the manager to drop the request. Best-effort.
Withdraw(ctx context.Context, externalID string) error
}
// Lister is a provider that keeps a persistent wanted list of its own —
// Lidarr monitoring an artist, say. It is the fourth role, and it
// exists because for those systems "I want this" is a durable statement
// they already model, and mirroring it there means the user's intent
// survives in the place they will look for it.
//
// Sync through this interface is one-directional in the loop: this app
// pushes, the external system receives. Pulling happens only when the
// user explicitly imports.
type Lister interface {
// PushWant records a want in the provider's own list and returns
// the provider's identifier for it. Implementations must be
// idempotent: pushing a want the provider already has returns the
// existing identifier rather than duplicating it.
PushWant(ctx context.Context, w Want) (string, error)
// RemoveWant drops a previously pushed want. Best-effort.
RemoveWant(ctx context.Context, externalID string) error
// ListWants reads the provider's list back, for the deliberate
// import path. LibraryID is filled in by the caller.
ListWants(ctx context.Context) ([]Want, error)
}
// ProviderInfo is a provider's identity as the frontend sees it.
type ProviderInfo struct {
ID int64 `json:"id"`
Kind Kind `json:"kind"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
// Priority breaks ties between providers that found equally good
// candidates. Higher wins; default 50.
Priority int `json:"priority"`
Caps Caps `json:"caps"`
}
// Config is a provider's stored settings. Secret values are not held
// here — they live in the secrets store keyed by provider ID, so a
// config blob can be logged or shown in the UI without redaction.
type Config struct {
ID int64 `json:"id"`
Kind Kind `json:"kind"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
Settings map[string]string `json:"settings"`
}
// Setting returns a config value, or fallback when unset.
func (c Config) Setting(key, fallback string) string {
if v, ok := c.Settings[key]; ok && v != "" {
return v
}
return fallback
}
// Constructor builds a provider from its stored config. The secret
// lookup is passed in rather than the secret itself so a provider can
// fetch several (username and password, say) and so nothing forces the
// secret into a struct field that might get logged.
type Constructor func(
cfg Config,
secrets SecretLookup,
logger *slog.Logger,
) (Provider, error)
// SecretLookup retrieves a named secret for a provider.
type SecretLookup func(name string) (string, error)
// registry maps provider kinds to their constructors. Adapters
// register themselves in an init function, so adding a provider does
// not require editing this file.
var (
registryMu sync.RWMutex
constructors = map[Kind]Constructor{}
descriptors = map[Kind]Descriptor{}
)
// Descriptor is the static, instance-independent description of a
// provider kind: what it is called, what it can do, and which settings
// it needs. The settings page renders its form from this, so a new
// provider gets a config UI without any frontend work.
type Descriptor struct {
Kind Kind `json:"kind"`
Name string `json:"name"`
// Summary is one line explaining what connecting this gets you.
Summary string `json:"summary"`
// Caps are the kind's inherent capabilities, before configuration.
Caps Caps `json:"caps"`
// Fields are the settings the user must supply.
Fields []Field `json:"fields"`
// RequiresExternal names the software the user must run themselves
// (a slskd daemon, a Lidarr instance), or is empty for providers
// that need nothing but a binary on PATH.
RequiresExternal string `json:"requiresExternal,omitempty"`
}
// Field describes one provider setting for the settings form.
type Field struct {
Key string `json:"key"`
Label string `json:"label"`
Placeholder string `json:"placeholder,omitempty"`
Help string `json:"help,omitempty"`
// Secret marks a value stored in the secrets store rather than the
// provider config row, and rendered as a password input.
Secret bool `json:"secret"`
Required bool `json:"required"`
Default string `json:"default,omitempty"`
}
// Register makes a provider kind available. Called from adapter init
// functions; panics on a duplicate kind because that is a build-time
// programming error, not a runtime condition.
func Register(d Descriptor, c Constructor) {
registryMu.Lock()
defer registryMu.Unlock()
if _, exists := constructors[d.Kind]; exists {
panic("download: duplicate provider kind " + string(d.Kind))
}
// Every provider that moves bytes gets a transfer limit it can be
// tuned with, appended here rather than repeated in each adapter's
// descriptor: the setting means the same thing everywhere, only the
// sensible default differs.
if d.Caps.CanTransport {
d.Fields = append(d.Fields, concurrencyField(d.Kind))
}
constructors[d.Kind] = c
descriptors[d.Kind] = d
}
// concurrencyField describes the per-provider transfer limit, with help
// text explaining why the default is what it is — a user who raises
// slskd from 1 to 8 and gets themselves queued behind every other
// Soulseek user deserves to have been warned.
func concurrencyField(k Kind) Field {
help := "Maximum simultaneous transfers from this client."
if k == KindSlskd {
help = "Maximum simultaneous transfers. Soulseek peers serve " +
"one file at a time and queue or ban clients that ask for " +
"more, so 1 is both the polite setting and usually the " +
"fastest."
}
return Field{
Key: concurrencyKey,
Label: "Simultaneous transfers",
Help: help,
Default: strconv.Itoa(kindConcurrency[k]),
}
}
// New builds a provider instance from stored config.
func New(
cfg Config,
secrets SecretLookup,
logger *slog.Logger,
) (Provider, error) {
registryMu.RLock()
ctor, ok := constructors[cfg.Kind]
registryMu.RUnlock()
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnknownKind, cfg.Kind)
}
p, err := ctor(cfg, secrets, logger)
if err != nil {
return nil, fmt.Errorf("build %s provider: %w", cfg.Kind, err)
}
return p, nil
}
// Descriptors returns every registered provider kind, name-ordered, for
// the "add a download client" picker.
func Descriptors() []Descriptor {
registryMu.RLock()
defer registryMu.RUnlock()
out := make([]Descriptor, 0, len(descriptors))
for _, d := range descriptors {
if d.Kind == KindFake {
continue
}
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Name < out[j].Name
})
return out
}
// DescriptorFor returns the descriptor for a kind.
func DescriptorFor(k Kind) (Descriptor, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
d, ok := descriptors[k]
return d, ok
}
// asSearcher returns the provider's Searcher role, gated on Caps so a
// type that implements the method but declares it unsupported (because
// it is misconfigured) is not used.
func asSearcher(p Provider) (Searcher, bool) {
if !p.Info().Caps.CanSearch {
return nil, false
}
s, ok := p.(Searcher)
return s, ok
}
// asTransporter returns the provider's Transporter role.
func asTransporter(p Provider) (Transporter, bool) {
if !p.Info().Caps.CanTransport {
return nil, false
}
t, ok := p.(Transporter)
return t, ok
}
// asDelegator returns the provider's Delegator role.
func asDelegator(p Provider) (Delegator, bool) {
if !p.Info().Caps.CanDelegate {
return nil, false
}
d, ok := p.(Delegator)
return d, ok
}
// asLister returns the provider's Lister role.
func asLister(p Provider) (Lister, bool) {
if !p.Info().Caps.CanList {
return nil, false
}
l, ok := p.(Lister)
return l, ok
}
+548
View File
@@ -0,0 +1,548 @@
package download
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strconv"
"strings"
"time"
)
// Lidarr is the delegate shape, and it is genuinely different from the
// other two: we do not search, we do not move bytes, and we do not own
// the import. We hand Lidarr an album and ask periodically whether it
// is done.
//
// The consequence that shapes this adapter: when Lidarr finishes, the
// files are already in Lidarr's library, tagged by Lidarr, at paths
// Lidarr chose. Re-importing them would mean moving files out from
// under a system that is actively managing them. So a completed
// delegate returns the paths Lidarr reports and the pipeline skips
// tagging and moving — it reconciles rather than imports.
// Lidarr provider errors.
var (
// ErrLidarrUnreachable means the instance did not answer.
ErrLidarrUnreachable = errors.New("lidarr is unreachable")
// ErrLidarrAuth means the API key was rejected.
ErrLidarrAuth = errors.New("lidarr rejected the API key")
// ErrLidarrNoMatch means Lidarr could not find the release.
ErrLidarrNoMatch = errors.New("lidarr could not find this release")
// ErrLidarrNoRootFolder means no root folder is configured, so
// Lidarr has nowhere to put anything it finds.
ErrLidarrNoRootFolder = errors.New("lidarr has no root folder configured")
)
// lidarrHTTPTimeout bounds one API call.
const lidarrHTTPTimeout = 30 * time.Second
func init() {
Register(
Descriptor{
Kind: KindLidarr,
Name: "Lidarr",
Summary: "Hand album requests to an existing Lidarr instance " +
"and let it do the searching and importing.",
RequiresExternal: "Lidarr",
Caps: Caps{
CanDelegate: true,
CanCancel: true,
CanList: true,
},
Fields: []Field{
{
Key: "url",
Label: "Lidarr URL",
Placeholder: "http://localhost:8686",
Required: true,
Default: "http://localhost:8686",
},
{
Key: "apiKey",
Label: "API key",
Secret: true,
Required: true,
Help: "Lidarr → Settings → General → API Key.",
},
{
Key: "qualityProfileId",
Label: "Quality profile ID",
Help: "Numeric ID of the Lidarr quality profile to use. " +
"Leave blank to use the first one.",
},
{
Key: "metadataProfileId",
Label: "Metadata profile ID",
Help: "Leave blank to use the first one.",
},
{
Key: "rootFolderPath",
Label: "Root folder",
Help: "Leave blank to use Lidarr's first configured " +
"root folder.",
},
},
},
newLidarr,
)
}
// lidarr is the Lidarr delegate provider.
type lidarr struct {
info ProviderInfo
logger *slog.Logger
client *apiClient
qualityProfileID int
metadataProfileID int
rootFolderPath string
}
// newLidarr builds the provider from config.
func newLidarr(
cfg Config,
secrets SecretLookup,
logger *slog.Logger,
) (Provider, error) {
base := strings.TrimRight(cfg.Setting("url", ""), "/")
if base == "" {
return nil, fmt.Errorf("%w: Lidarr URL is required", ErrNotConfigured)
}
apiKey := ""
if secrets != nil {
key, err := secrets("apiKey")
if err != nil {
return nil, fmt.Errorf("%w: no API key stored", ErrNotConfigured)
}
apiKey = key
}
quality, _ := strconv.Atoi(cfg.Setting("qualityProfileId", "0"))
metadata, _ := strconv.Atoi(cfg.Setting("metadataProfileId", "0"))
return &lidarr{
info: ProviderInfo{
ID: cfg.ID,
Kind: KindLidarr,
Name: cfg.Name,
Enabled: cfg.Enabled,
Priority: cfg.Priority,
Caps: Caps{
CanDelegate: true,
CanCancel: true,
CanList: true,
},
},
logger: logger.With("provider", "lidarr"),
client: newAPIClient(
base, "X-Api-Key", apiKey, lidarrHTTPTimeout,
ErrLidarrUnreachable, ErrLidarrAuth,
),
qualityProfileID: quality,
metadataProfileID: metadata,
rootFolderPath: cfg.Setting("rootFolderPath", ""),
}, nil
}
// Info returns the provider's identity.
func (l *lidarr) Info() ProviderInfo {
return l.info
}
// Close is a no-op.
func (l *lidarr) Close() error {
return nil
}
// Check verifies the instance answers and has somewhere to put music.
func (l *lidarr) Check(ctx context.Context) error {
var status struct {
Version string `json:"version"`
}
if err := l.client.get(ctx, "/api/v1/system/status", &status); err != nil {
return err
}
folders, err := l.rootFolders(ctx)
if err != nil {
return err
}
if len(folders) == 0 {
return ErrLidarrNoRootFolder
}
return nil
}
// ---------------------------------------------------------------------------
// API types
// ---------------------------------------------------------------------------
// lidarrAlbum is the subset of Lidarr's album resource used here.
type lidarrAlbum struct {
ID int `json:"id"`
Title string `json:"title"`
ForeignAlbum string `json:"foreignAlbumId"`
Monitored bool `json:"monitored"`
ArtistID int `json:"artistId"`
Artist lidarrArtist `json:"artist"`
Statistics lidarrAlbumStats `json:"statistics"`
}
// lidarrArtist is the artist an album belongs to. Lidarr models albums
// as children of artists, so an album it does not yet know about cannot
// be monitored until its artist exists.
type lidarrArtist struct {
ID int `json:"id"`
ArtistName string `json:"artistName"`
ForeignArtistID string `json:"foreignArtistId"`
}
// lidarrAlbumStats is how Lidarr reports import progress.
type lidarrAlbumStats struct {
TrackFileCount int `json:"trackFileCount"`
TrackCount int `json:"trackCount"`
PercentOfTracks float64 `json:"percentOfTracks"`
}
// lidarrRootFolder is a configured library root.
type lidarrRootFolder struct {
ID int `json:"id"`
Path string `json:"path"`
}
// lidarrTrackFile is one imported file.
type lidarrTrackFile struct {
ID int `json:"id"`
Path string `json:"path"`
AlbumID int `json:"albumId"`
}
// ---------------------------------------------------------------------------
// Delegate
// ---------------------------------------------------------------------------
// Delegate adds the album to Lidarr, monitors it and triggers a search.
// The external ID returned is Lidarr's album ID, which is what Poll
// needs and what survives a restart.
func (l *lidarr) Delegate(ctx context.Context, req Request) (string, error) {
album, err := l.findAlbum(ctx, req)
if err != nil {
return "", err
}
// An album Lidarr already knows about only needs monitoring turned
// on; one it does not needs its artist added first, because Lidarr
// models albums as children of artists.
if album.ID == 0 {
added, err := l.addArtistForAlbum(ctx, album)
if err != nil {
return "", err
}
album = added
}
if !album.Monitored {
if err := l.monitorAlbum(ctx, album.ID); err != nil {
return "", err
}
}
if err := l.command(ctx, map[string]any{
"name": "AlbumSearch",
"albumIds": []int{album.ID},
}); err != nil {
return "", err
}
return strconv.Itoa(album.ID), nil
}
// findAlbum looks for the requested album, preferring the MusicBrainz
// release-group ID because that is unambiguous where a title search is
// not.
func (l *lidarr) findAlbum(
ctx context.Context,
req Request,
) (lidarrAlbum, error) {
term := req.SearchText()
if req.ReleaseGroupMBID != "" {
term = "lidarr:" + req.ReleaseGroupMBID
}
var results []struct {
Album lidarrAlbum `json:"album"`
}
endpoint := "/api/v1/search?term=" + url.QueryEscape(term)
if err := l.client.get(ctx, endpoint, &results); err != nil {
return lidarrAlbum{}, err
}
for _, r := range results {
if r.Album.Title != "" {
return r.Album, nil
}
}
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, req.SearchText())
}
// addArtistForAlbum adds the album's artist so the album becomes a real
// record Lidarr can monitor.
func (l *lidarr) addArtistForAlbum(
ctx context.Context,
album lidarrAlbum,
) (lidarrAlbum, error) {
root := l.rootFolderPath
if root == "" {
folders, err := l.rootFolders(ctx)
if err != nil {
return lidarrAlbum{}, err
}
if len(folders) == 0 {
return lidarrAlbum{}, ErrLidarrNoRootFolder
}
root = folders[0].Path
}
quality, metadata, err := l.profiles(ctx)
if err != nil {
return lidarrAlbum{}, err
}
body := map[string]any{
"foreignArtistId": album.Artist.ForeignArtistID,
"artistName": album.Artist.ArtistName,
"qualityProfileId": quality,
"metadataProfileId": metadata,
"rootFolderPath": root,
"monitored": true,
"addOptions": map[string]any{
// Monitor nothing by default and turn on just the requested
// album below. Adding an artist with everything monitored
// would kick off downloads of their entire discography,
// which is emphatically not what the user asked for.
"monitor": "none",
"searchForMissingAlbums": false,
},
}
var created struct {
ID int `json:"id"`
}
if err := l.client.post(ctx, "/api/v1/artist", body, &created); err != nil {
return lidarrAlbum{}, err
}
// Re-resolve the album now that its artist exists.
var albums []lidarrAlbum
endpoint := "/api/v1/album?artistId=" + strconv.Itoa(created.ID)
if err := l.client.get(ctx, endpoint, &albums); err != nil {
return lidarrAlbum{}, err
}
for _, a := range albums {
if a.ForeignAlbum == album.ForeignAlbum {
return a, nil
}
}
return lidarrAlbum{}, fmt.Errorf(
"%w: album not present after adding artist", ErrLidarrNoMatch,
)
}
// monitorAlbum turns on monitoring for one album.
func (l *lidarr) monitorAlbum(ctx context.Context, albumID int) error {
body := map[string]any{
"albumIds": []int{albumID},
"monitored": true,
}
return l.client.put(ctx, "/api/v1/album/monitor", body, nil)
}
// Poll reports whether Lidarr has finished with the album.
//
// Completion is judged by imported track files rather than by queue
// state: the queue empties when a download finishes, which is before
// the import happens, and reporting success then would have the
// pipeline reconcile files that are not there yet.
func (l *lidarr) Poll(
ctx context.Context,
externalID string,
) (DelegateStatus, error) {
albumID, err := strconv.Atoi(externalID)
if err != nil {
return DelegateStatus{}, fmt.Errorf(
"%w: bad album id %q", ErrLidarrNoMatch, externalID,
)
}
var album lidarrAlbum
endpoint := "/api/v1/album/" + strconv.Itoa(albumID)
if err := l.client.get(ctx, endpoint, &album); err != nil {
return DelegateStatus{}, err
}
total := album.Statistics.TrackCount
got := album.Statistics.TrackFileCount
progress := -1.0
if total > 0 {
progress = float64(got) / float64(total)
}
if total > 0 && got >= total {
paths, err := l.trackFilePaths(ctx, albumID)
if err != nil {
return DelegateStatus{}, err
}
return DelegateStatus{
State: StateComplete,
Progress: 1,
ImportedPaths: paths,
Message: fmt.Sprintf(
"Lidarr imported %d of %d tracks", got, total,
),
}, nil
}
return DelegateStatus{
State: StateGrabbing,
Progress: progress,
Message: fmt.Sprintf(
"Lidarr has %d of %d tracks", got, total,
),
}, nil
}
// trackFilePaths returns the on-disk paths Lidarr imported.
func (l *lidarr) trackFilePaths(
ctx context.Context,
albumID int,
) ([]string, error) {
var files []lidarrTrackFile
endpoint := "/api/v1/trackfile?albumId=" + strconv.Itoa(albumID)
if err := l.client.get(ctx, endpoint, &files); err != nil {
return nil, err
}
out := make([]string, 0, len(files))
for _, f := range files {
if f.Path != "" {
out = append(out, f.Path)
}
}
return out, nil
}
// Withdraw stops monitoring the album so Lidarr gives up on it. The
// album record is left in place: deleting it would be a bigger action
// than the user asked for, and it may predate this request.
func (l *lidarr) Withdraw(ctx context.Context, externalID string) error {
albumID, err := strconv.Atoi(externalID)
if err != nil {
return fmt.Errorf("%w: bad album id %q", ErrLidarrNoMatch, externalID)
}
body := map[string]any{
"albumIds": []int{albumID},
"monitored": false,
}
return l.client.put(ctx, "/api/v1/album/monitor", body, nil)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// rootFolders lists Lidarr's configured library roots.
func (l *lidarr) rootFolders(ctx context.Context) ([]lidarrRootFolder, error) {
var folders []lidarrRootFolder
if err := l.client.get(ctx, "/api/v1/rootfolder", &folders); err != nil {
return nil, err
}
return folders, nil
}
// profiles resolves the quality and metadata profile IDs to use,
// falling back to the first of each when unconfigured.
func (l *lidarr) profiles(ctx context.Context) (quality, metadata int, err error) {
quality, metadata = l.qualityProfileID, l.metadataProfileID
if quality == 0 {
var profiles []struct {
ID int `json:"id"`
}
if err := l.client.get(ctx, "/api/v1/qualityprofile", &profiles); err != nil {
return 0, 0, err
}
if len(profiles) == 0 {
return 0, 0, fmt.Errorf(
"%w: no quality profiles configured", ErrNotConfigured,
)
}
quality = profiles[0].ID
}
if metadata == 0 {
var profiles []struct {
ID int `json:"id"`
}
if err := l.client.get(ctx, "/api/v1/metadataprofile", &profiles); err != nil {
return 0, 0, err
}
if len(profiles) == 0 {
return 0, 0, fmt.Errorf(
"%w: no metadata profiles configured", ErrNotConfigured,
)
}
metadata = profiles[0].ID
}
return quality, metadata, nil
}
// command posts to Lidarr's command endpoint.
func (l *lidarr) command(ctx context.Context, body map[string]any) error {
return l.client.post(ctx, "/api/v1/command", body, nil)
}
+262
View File
@@ -0,0 +1,262 @@
package download
import (
"context"
"fmt"
"net/url"
"strconv"
)
// Lidarr's Lister role: mirroring this app's wanted list into Lidarr's
// own monitoring.
//
// Lidarr already models exactly what a want is — a monitored artist or
// a monitored album — so the mapping is direct and, more usefully, it
// means an artist subscription made here keeps working through Lidarr's
// own release-checking even while this app is closed. That is the
// whole reason the Lister role exists: a desktop player is not running
// most of the time, and a always-on system that already watches for new
// releases is a better place for a subscription to live than a loop
// that only ticks when someone opens the app.
//
// The mapping is deliberately lossy in one direction only:
//
// artist want -> Lidarr artist, monitored
// release-group want -> Lidarr album, monitored (artist added if new)
// release want -> same, at release-group granularity
// recording want -> not pushed; Lidarr has no concept of wanting
// one track, and monitoring the whole album to
// get it would download far more than asked.
//
// Nothing here searches. Pushing a want expresses intent; Lidarr
// decides when to act on it, which is the point of delegating.
// PushWant records a want in Lidarr's own monitoring.
//
// It is idempotent because Lidarr is: adding an artist that already
// exists returns the existing record, and monitoring an already-
// monitored album is a no-op. Callers rely on that — the reconciler
// pushes on every pass until it gets an ID back.
func (l *lidarr) PushWant(ctx context.Context, w Want) (string, error) {
switch w.Entity {
case EntityArtist:
return l.pushArtistWant(ctx, w)
case EntityReleaseGroup, EntityRelease:
return l.pushAlbumWant(ctx, w)
case EntityRecording:
// Deliberately unsupported rather than approximated. See the
// mapping note above.
return "", nil
default:
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, w.Entity)
}
}
// pushArtistWant makes Lidarr monitor an artist.
//
// Scope is honoured through Lidarr's own monitor option rather than by
// pushing each album separately: "future" maps to monitoring new
// releases only, "all" to monitoring everything missing. Letting
// Lidarr apply the policy means it stays applied to albums released
// after this push, which is what a subscription is for.
func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
existing, err := l.findArtistByMBID(ctx, w.MBID)
if err != nil {
return "", err
}
monitor := "future"
if w.Scope == ScopeAll {
monitor = "missing"
}
if existing.ID != 0 {
return strconv.Itoa(existing.ID), nil
}
root, err := l.resolveRootFolder(ctx)
if err != nil {
return "", err
}
quality, metadata, err := l.profiles(ctx)
if err != nil {
return "", err
}
name := w.Artist
if name == "" {
name = w.Title
}
body := map[string]any{
"foreignArtistId": w.MBID,
"artistName": name,
"qualityProfileId": quality,
"metadataProfileId": metadata,
"rootFolderPath": root,
"monitored": true,
"addOptions": map[string]any{
"monitor": monitor,
// Searching is left off even for a full-discography
// subscription: adding an artist should not launch forty
// simultaneous searches on a system the user shares with
// their own queue. Lidarr picks the albums up on its next
// scheduled search.
"searchForMissingAlbums": false,
},
}
var created struct {
ID int `json:"id"`
}
if err := l.client.post(ctx, "/api/v1/artist", body, &created); err != nil {
return "", err
}
return strconv.Itoa(created.ID), nil
}
// pushAlbumWant makes Lidarr monitor one album, adding its artist if
// Lidarr has never heard of them.
func (l *lidarr) pushAlbumWant(ctx context.Context, w Want) (string, error) {
album, err := l.findAlbum(ctx, Request{
ReleaseGroupMBID: w.MBID,
Artist: w.Artist,
Album: w.Title,
})
if err != nil {
return "", err
}
if album.ID == 0 {
album, err = l.addArtistForAlbum(ctx, album)
if err != nil {
return "", err
}
}
if !album.Monitored {
if err := l.monitorAlbum(ctx, album.ID); err != nil {
return "", err
}
}
return strconv.Itoa(album.ID), nil
}
// RemoveWant stops Lidarr monitoring something.
//
// It unmonitors rather than deletes: the user's Lidarr may have been
// monitoring that artist long before this app existed, and removing a
// want here is not permission to tear down their setup. An unmonitored
// artist stays in their library with its files intact.
func (l *lidarr) RemoveWant(ctx context.Context, externalID string) error {
id, err := strconv.Atoi(externalID)
if err != nil {
return fmt.Errorf("%w: bad lidarr id %q", ErrLidarrNoMatch, externalID)
}
// The ID may name an album or an artist and the caller does not
// track which, so try the album endpoint first and fall back.
if err := l.client.put(ctx, "/api/v1/album/monitor", map[string]any{
"albumIds": []int{id},
"monitored": false,
}, nil); err == nil {
return nil
}
var artist map[string]any
endpoint := "/api/v1/artist/" + strconv.Itoa(id)
if err := l.client.get(ctx, endpoint, &artist); err != nil {
return err
}
artist["monitored"] = false
return l.client.put(ctx, endpoint, artist, nil)
}
// ListWants reads Lidarr's monitored artists back, for the deliberate
// "import what Lidarr is already watching" action.
//
// Only artists are imported, not their individual monitored albums: an
// artist is the durable statement of intent, and importing every
// monitored album alongside it would produce a wanted list that is
// mostly redundant with the subscription that generated it.
func (l *lidarr) ListWants(ctx context.Context) ([]Want, error) {
var artists []struct {
lidarrArtist
Monitored bool `json:"monitored"`
}
if err := l.client.get(ctx, "/api/v1/artist", &artists); err != nil {
return nil, err
}
out := make([]Want, 0, len(artists))
for _, a := range artists {
if !a.Monitored || a.ForeignArtistID == "" {
continue
}
out = append(out, Want{
MBID: a.ForeignArtistID,
Entity: EntityArtist,
Artist: a.ArtistName,
Title: a.ArtistName,
// Imported subscriptions take the conservative scope: the
// user can widen it, but silently queueing a back catalogue
// on import would be a nasty surprise.
Scope: ScopeFuture,
})
}
return out, nil
}
// findArtistByMBID looks up an artist Lidarr already has.
func (l *lidarr) findArtistByMBID(
ctx context.Context,
mbid string,
) (lidarrArtist, error) {
var artists []lidarrArtist
endpoint := "/api/v1/artist?mbId=" + url.QueryEscape(mbid)
if err := l.client.get(ctx, endpoint, &artists); err != nil {
return lidarrArtist{}, err
}
for _, a := range artists {
if a.ForeignArtistID == mbid {
return a, nil
}
}
return lidarrArtist{}, nil
}
// resolveRootFolder returns the configured root folder, or Lidarr's
// first if none is configured.
func (l *lidarr) resolveRootFolder(ctx context.Context) (string, error) {
if l.rootFolderPath != "" {
return l.rootFolderPath, nil
}
folders, err := l.rootFolders(ctx)
if err != nil {
return "", err
}
if len(folders) == 0 {
return "", ErrLidarrNoRootFolder
}
return folders[0].Path, nil
}
@@ -0,0 +1,240 @@
package download
import (
"context"
"testing"
)
// An artist subscription becomes a monitored Lidarr artist, with the
// scope translated into Lidarr's own monitor option — so the policy
// keeps applying to albums released after the push, which is the whole
// reason to mirror a subscription rather than a list of albums.
func TestLidarrPushArtistWantMapsScope(t *testing.T) {
t.Parallel()
tests := []struct {
name string
scope WantScope
wantMonitor string
}{
{name: "future", scope: ScopeFuture, wantMonitor: "future"},
{name: "all", scope: ScopeAll, wantMonitor: "missing"},
}
for _, tt := range tests {
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
id, err := l.PushWant(context.Background(), Want{
MBID: "artist-mbid",
Entity: EntityArtist,
Artist: "Radiohead",
Scope: tt.scope,
})
if err != nil {
t.Fatalf("%s: PushWant: %v", tt.name, err)
}
if id != "42" {
t.Errorf("%s: external id = %q, want 42", tt.name, id)
}
stub.mu.Lock()
added := append([]map[string]any(nil), stub.addedArtists...)
stub.mu.Unlock()
if len(added) != 1 {
t.Fatalf("%s: added %d artists, want 1", tt.name, len(added))
}
opts, ok := added[0]["addOptions"].(map[string]any)
if !ok {
t.Fatalf("%s: no addOptions in the artist body", tt.name)
}
if opts["monitor"] != tt.wantMonitor {
t.Errorf(
"%s: monitor = %v, want %q",
tt.name, opts["monitor"], tt.wantMonitor,
)
}
// Adding an artist must never kick off a discography-wide
// search on a system the user shares with their own queue.
if opts["searchForMissingAlbums"] != false {
t.Errorf("%s: push triggered a search", tt.name)
}
}
}
// Pushing a want Lidarr already has returns the existing ID instead of
// adding a second copy — the reconciler pushes on every pass, so this
// is load-bearing rather than tidy.
func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
stub.artists = []map[string]any{{
"id": 7,
"artistName": "Radiohead",
"foreignArtistId": "artist-mbid",
"monitored": true,
}}
l := newStubLidarr(t, stub)
id, err := l.PushWant(context.Background(), Want{
MBID: "artist-mbid",
Entity: EntityArtist,
Artist: "Radiohead",
})
if err != nil {
t.Fatalf("PushWant: %v", err)
}
if id != "7" {
t.Errorf("external id = %q, want the existing artist's 7", id)
}
stub.mu.Lock()
added := len(stub.addedArtists)
stub.mu.Unlock()
if added != 0 {
t.Errorf("added %d artists, want 0 — it already existed", added)
}
}
// Lidarr cannot express "I want one track", and monitoring the whole
// album to get it would download far more than was asked for.
func TestLidarrPushRecordingWantIsSkipped(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
id, err := l.PushWant(context.Background(), Want{
MBID: "recording-mbid",
Entity: EntityRecording,
Title: "Paranoid Android",
})
if err != nil {
t.Fatalf("PushWant: %v", err)
}
if id != "" {
t.Errorf("external id = %q, want empty (not pushed)", id)
}
stub.mu.Lock()
added := len(stub.addedArtists)
monitors := len(stub.monitorCalls)
stub.mu.Unlock()
if added != 0 || monitors != 0 {
t.Errorf(
"track want touched Lidarr: %d artists, %d monitors",
added, monitors,
)
}
}
// Importing adopts monitored artists conservatively: a subscription
// pulled in from elsewhere must not queue a back catalogue.
func TestLidarrListWantsImportsMonitoredArtistsOnly(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
stub.artists = []map[string]any{
{
"id": 1,
"artistName": "Radiohead",
"foreignArtistId": "artist-1",
"monitored": true,
},
{
"id": 2,
"artistName": "Unmonitored Band",
"foreignArtistId": "artist-2",
"monitored": false,
},
{
"id": 3,
"artistName": "No MBID",
"foreignArtistId": "",
"monitored": true,
},
}
l := newStubLidarr(t, stub)
wants, err := l.ListWants(context.Background())
if err != nil {
t.Fatalf("ListWants: %v", err)
}
if len(wants) != 1 {
t.Fatalf("imported %d wants, want 1", len(wants))
}
w := wants[0]
if w.MBID != "artist-1" {
t.Errorf("mbid = %q, want artist-1", w.MBID)
}
if w.Entity != EntityArtist {
t.Errorf("entity = %q, want artist", w.Entity)
}
if w.Scope != ScopeFuture {
t.Errorf("scope = %q, want the conservative future", w.Scope)
}
}
// Removing a want must not tear down a Lidarr setup that may predate
// this app: it unmonitors, it does not delete.
func TestLidarrRemoveWantUnmonitorsOnly(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
if err := l.RemoveWant(context.Background(), "55"); err != nil {
t.Fatalf("RemoveWant: %v", err)
}
stub.mu.Lock()
monitors := append([]map[string]any(nil), stub.monitorCalls...)
stub.mu.Unlock()
if len(monitors) != 1 {
t.Fatalf("got %d monitor calls, want 1", len(monitors))
}
if monitors[0]["monitored"] != false {
t.Errorf("monitored = %v, want false", monitors[0]["monitored"])
}
}
// The Lister role has to be declared, not just implemented, or the
// reconciler never finds it.
func TestLidarrDeclaresListerCapability(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
if _, ok := asLister(l); !ok {
t.Error("lidarr does not present as a Lister")
}
desc, ok := DescriptorFor(KindLidarr)
if !ok {
t.Fatal("no descriptor registered for lidarr")
}
if !desc.Caps.CanList {
t.Error("lidarr's descriptor does not declare CanList")
}
}
+562
View File
@@ -0,0 +1,562 @@
package download
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// lidarrStub is a fake Lidarr instance.
type lidarrStub struct {
server *httptest.Server
mu sync.Mutex
// searchAlbum is what /api/v1/search returns.
searchAlbum lidarrAlbum
// album is what /api/v1/album/{id} returns, in order; the last
// entry repeats.
albumStates []lidarrAlbum
pollCount int
// trackFiles is what /api/v1/trackfile returns.
trackFiles []lidarrTrackFile
// rootFolders is what /api/v1/rootfolder returns.
rootFolders []lidarrRootFolder
// commands records the commands that were issued.
commands []string
// monitorCalls records album-monitor toggles.
monitorCalls []map[string]any
// addedArtists records artist additions.
addedArtists []map[string]any
// artists is what a GET of /api/v1/artist returns, which is how the
// Lister role looks up and enumerates monitored artists.
artists []map[string]any
unauthorized bool
}
func newLidarrStub(t *testing.T) *lidarrStub {
t.Helper()
s := &lidarrStub{
rootFolders: []lidarrRootFolder{{ID: 1, Path: "/music"}},
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/system/status", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
writeJSON(t, w, map[string]any{"version": "2.0.0"})
})
mux.HandleFunc("/api/v1/rootfolder", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
s.mu.Lock()
folders := s.rootFolders
s.mu.Unlock()
writeJSON(t, w, folders)
})
mux.HandleFunc("/api/v1/qualityprofile", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
writeJSON(t, w, []map[string]any{{"id": 7}})
})
mux.HandleFunc("/api/v1/metadataprofile", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
writeJSON(t, w, []map[string]any{{"id": 3}})
})
mux.HandleFunc("/api/v1/search", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
s.mu.Lock()
album := s.searchAlbum
s.mu.Unlock()
writeJSON(t, w, []map[string]any{{"album": album}})
})
mux.HandleFunc("/api/v1/artist", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
if r.Method == http.MethodGet {
s.mu.Lock()
artists := s.artists
s.mu.Unlock()
if artists == nil {
artists = []map[string]any{}
}
writeJSON(t, w, artists)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode artist body: %v", err)
}
s.mu.Lock()
s.addedArtists = append(s.addedArtists, body)
s.mu.Unlock()
writeJSON(t, w, map[string]any{"id": 42})
})
mux.HandleFunc("/api/v1/album/monitor", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode monitor body: %v", err)
}
s.mu.Lock()
s.monitorCalls = append(s.monitorCalls, body)
s.mu.Unlock()
w.WriteHeader(http.StatusAccepted)
})
mux.HandleFunc("/api/v1/album", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
s.mu.Lock()
album := s.searchAlbum
s.mu.Unlock()
album.ID = 99
writeJSON(t, w, []lidarrAlbum{album})
})
mux.HandleFunc("/api/v1/album/", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
s.mu.Lock()
idx := s.pollCount
if idx >= len(s.albumStates) {
idx = len(s.albumStates) - 1
} else {
s.pollCount++
}
var album lidarrAlbum
if idx >= 0 && len(s.albumStates) > 0 {
album = s.albumStates[idx]
}
s.mu.Unlock()
writeJSON(t, w, album)
})
mux.HandleFunc("/api/v1/trackfile", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
s.mu.Lock()
files := s.trackFiles
s.mu.Unlock()
writeJSON(t, w, files)
})
mux.HandleFunc("/api/v1/command", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode command body: %v", err)
}
name, _ := body["name"].(string)
s.mu.Lock()
s.commands = append(s.commands, name)
s.mu.Unlock()
writeJSON(t, w, map[string]any{"id": 1})
})
s.server = httptest.NewServer(mux)
t.Cleanup(s.server.Close)
return s
}
func (s *lidarrStub) reject(w http.ResponseWriter, r *http.Request) bool {
s.mu.Lock()
unauthorized := s.unauthorized
s.mu.Unlock()
if unauthorized || r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return true
}
return false
}
func newStubLidarr(t *testing.T, stub *lidarrStub) *lidarr {
t.Helper()
p, err := newLidarr(
Config{
ID: 1,
Kind: KindLidarr,
Name: "lidarr",
Enabled: true,
Settings: map[string]string{
"url": stub.server.URL,
},
},
func(string) (string, error) { return "test-key", nil },
slogDiscard(),
)
if err != nil {
t.Fatalf("newLidarr: %v", err)
}
l, ok := p.(*lidarr)
if !ok {
t.Fatalf("provider is %T, want *lidarr", p)
}
return l
}
func TestLidarrCheck(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
if err := l.Check(context.Background()); err != nil {
t.Errorf("Check: %v", err)
}
}
func TestLidarrCheckRejectsBadKey(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
stub.unauthorized = true
l := newStubLidarr(t, stub)
if err := l.Check(context.Background()); !errors.Is(err, ErrLidarrAuth) {
t.Errorf("error = %v, want ErrLidarrAuth", err)
}
}
// Lidarr with nowhere to put music cannot fulfil anything, and that
// should be visible at configuration time.
func TestLidarrCheckRequiresRootFolder(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
stub.rootFolders = nil
l := newStubLidarr(t, stub)
if err := l.Check(context.Background()); !errors.Is(
err, ErrLidarrNoRootFolder,
) {
t.Errorf("error = %v, want ErrLidarrNoRootFolder", err)
}
}
// An album Lidarr already tracks only needs monitoring and a search.
func TestLidarrDelegateExistingAlbum(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
stub.searchAlbum = lidarrAlbum{
ID: 55,
Title: "OK Computer",
ForeignAlbum: "rg-mbid",
Monitored: false,
}
l := newStubLidarr(t, stub)
externalID, err := l.Delegate(context.Background(), Request{
ReleaseGroupMBID: "rg-mbid",
Artist: "Radiohead",
Album: "OK Computer",
})
if err != nil {
t.Fatalf("Delegate: %v", err)
}
if externalID != "55" {
t.Errorf("external id = %q, want 55", externalID)
}
stub.mu.Lock()
commands := append([]string(nil), stub.commands...)
monitors := len(stub.monitorCalls)
added := len(stub.addedArtists)
stub.mu.Unlock()
if added != 0 {
t.Errorf("added %d artists, want 0 for an album Lidarr already has", added)
}
if monitors != 1 {
t.Errorf("monitor calls = %d, want 1", monitors)
}
if len(commands) != 1 || commands[0] != "AlbumSearch" {
t.Errorf("commands = %v, want [AlbumSearch]", commands)
}
}
// Adding an artist must not kick off their entire discography.
func TestLidarrDelegateNewArtistMonitorsNothingByDefault(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
stub.searchAlbum = lidarrAlbum{
Title: "OK Computer",
ForeignAlbum: "rg-mbid",
}
stub.searchAlbum.Artist.ForeignArtistID = "artist-mbid"
stub.searchAlbum.Artist.ArtistName = "Radiohead"
l := newStubLidarr(t, stub)
if _, err := l.Delegate(context.Background(), Request{
ReleaseGroupMBID: "rg-mbid",
Artist: "Radiohead",
Album: "OK Computer",
}); err != nil {
t.Fatalf("Delegate: %v", err)
}
stub.mu.Lock()
added := append([]map[string]any(nil), stub.addedArtists...)
stub.mu.Unlock()
if len(added) != 1 {
t.Fatalf("added %d artists, want 1", len(added))
}
opts, ok := added[0]["addOptions"].(map[string]any)
if !ok {
t.Fatalf("addOptions missing from %v", added[0])
}
if opts["monitor"] != "none" {
t.Errorf("monitor = %v, want none", opts["monitor"])
}
if opts["searchForMissingAlbums"] != false {
t.Errorf(
"searchForMissingAlbums = %v, want false",
opts["searchForMissingAlbums"],
)
}
}
func TestLidarrDelegateNoMatch(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
// searchAlbum stays zero-valued: no title means no match.
l := newStubLidarr(t, stub)
_, err := l.Delegate(context.Background(), Request{
Artist: "Nobody",
Album: "Nothing",
})
if !errors.Is(err, ErrLidarrNoMatch) {
t.Errorf("error = %v, want ErrLidarrNoMatch", err)
}
}
// Completion is judged by imported files, not by an empty queue: the
// queue drains when the download finishes, which is before the import.
func TestLidarrPollWaitsForImportedFiles(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
stub.albumStates = []lidarrAlbum{
{ID: 55, Statistics: lidarrAlbumStats{TrackCount: 12}},
{ID: 55, Statistics: lidarrAlbumStats{TrackFileCount: 6, TrackCount: 12}},
{ID: 55, Statistics: lidarrAlbumStats{TrackFileCount: 12, TrackCount: 12}},
}
stub.trackFiles = []lidarrTrackFile{
{ID: 1, Path: "/music/Radiohead/OK Computer/01 Airbag.flac"},
{ID: 2, Path: "/music/Radiohead/OK Computer/02 Paranoid Android.flac"},
}
l := newStubLidarr(t, stub)
ctx := context.Background()
// Nothing imported yet.
first, err := l.Poll(ctx, "55")
if err != nil {
t.Fatalf("Poll: %v", err)
}
if first.State != StateGrabbing {
t.Errorf("state = %q, want grabbing", first.State)
}
// Half done.
second, err := l.Poll(ctx, "55")
if err != nil {
t.Fatalf("Poll: %v", err)
}
if second.State != StateGrabbing {
t.Errorf("state = %q, want grabbing", second.State)
}
if second.Progress <= first.Progress {
t.Errorf(
"progress did not advance: %f then %f",
first.Progress, second.Progress,
)
}
// Complete, with the paths Lidarr imported to.
third, err := l.Poll(ctx, "55")
if err != nil {
t.Fatalf("Poll: %v", err)
}
if third.State != StateComplete {
t.Fatalf("state = %q, want complete", third.State)
}
if len(third.ImportedPaths) != 2 {
t.Errorf("imported paths = %v, want 2", third.ImportedPaths)
}
for _, p := range third.ImportedPaths {
if !strings.HasPrefix(p, "/music/") {
t.Errorf("path %q is not in Lidarr's library", p)
}
}
}
func TestLidarrPollRejectsBadExternalID(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
if _, err := l.Poll(context.Background(), "not-a-number"); !errors.Is(
err, ErrLidarrNoMatch,
) {
t.Errorf("error = %v, want ErrLidarrNoMatch", err)
}
}
// Withdrawing stops monitoring; it must not delete the album, which may
// predate this request.
func TestLidarrWithdrawUnmonitorsOnly(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
if err := l.Withdraw(context.Background(), "55"); err != nil {
t.Fatalf("Withdraw: %v", err)
}
stub.mu.Lock()
calls := append([]map[string]any(nil), stub.monitorCalls...)
stub.mu.Unlock()
if len(calls) != 1 {
t.Fatalf("monitor calls = %d, want 1", len(calls))
}
if calls[0]["monitored"] != false {
t.Errorf("monitored = %v, want false", calls[0]["monitored"])
}
}
func TestLidarrRequiresConfiguration(t *testing.T) {
t.Parallel()
t.Run("no url", func(t *testing.T) {
t.Parallel()
_, err := newLidarr(
Config{},
func(string) (string, error) { return "k", nil },
slogDiscard(),
)
if !errors.Is(err, ErrNotConfigured) {
t.Errorf("error = %v, want ErrNotConfigured", err)
}
})
t.Run("no api key", func(t *testing.T) {
t.Parallel()
_, err := newLidarr(
Config{Settings: map[string]string{"url": "http://localhost:8686"}},
func(string) (string, error) { return "", ErrSecretNotFound },
slogDiscard(),
)
if !errors.Is(err, ErrNotConfigured) {
t.Errorf("error = %v, want ErrNotConfigured", err)
}
})
}
+318
View File
@@ -0,0 +1,318 @@
package download
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strconv"
"strings"
"time"
)
// Prowlarr is the one provider that cannot finish a job by itself: it
// searches dozens of indexers and hands back magnet links and NZB URLs,
// which some other client has to actually fetch. That is the whole
// reason the role interfaces are separate — Prowlarr implements
// Searcher and nothing else, and the pipeline pairs its results with
// whichever enabled transport handles the protocol.
//
// Its search results also carry no file list. A torrent is one opaque
// blob until it is fetched, so match scoring here has only the release
// title to work with, and candidates are marked accordingly rather than
// pretending to know what is inside.
// Prowlarr provider errors.
var (
// ErrProwlarrUnreachable means the instance did not answer.
ErrProwlarrUnreachable = errors.New("prowlarr is unreachable")
// ErrProwlarrAuth means the API key was rejected.
ErrProwlarrAuth = errors.New("prowlarr rejected the API key")
// ErrProwlarrNoIndexers means nothing is configured to search.
ErrProwlarrNoIndexers = errors.New("prowlarr has no enabled indexers")
)
// prowlarrHTTPTimeout bounds one API call. Indexer fan-out is slow, so
// this is longer than the other adapters'.
const prowlarrHTTPTimeout = 45 * time.Second
// prowlarrMusicCategory is Newznab's music category. Searching without
// it returns every match across film and software too.
const prowlarrMusicCategory = "3000"
// prowlarrMaxResults caps how many results are turned into candidates.
const prowlarrMaxResults = 40
func init() {
Register(
Descriptor{
Kind: KindProwlarr,
Name: "Prowlarr",
Summary: "Search many torrent and usenet indexers at once. " +
"Needs a download client (qBittorrent or SABnzbd) to fetch results.",
RequiresExternal: "Prowlarr",
Caps: Caps{
CanSearch: true,
},
Fields: []Field{
{
Key: "url",
Label: "Prowlarr URL",
Placeholder: "http://localhost:9696",
Required: true,
Default: "http://localhost:9696",
},
{
Key: "apiKey",
Label: "API key",
Secret: true,
Required: true,
Help: "Prowlarr → Settings → General → API Key.",
},
{
Key: "indexerIds",
Label: "Indexer IDs",
Help: "Comma-separated numeric IDs to restrict the search to. " +
"Leave blank to search all enabled indexers.",
},
{
Key: "minSeeders",
Label: "Minimum seeders",
Help: "Torrent results below this are hidden. " +
"Defaults to 1.",
Default: "1",
},
},
},
newProwlarr,
)
}
// prowlarr is the Prowlarr search provider.
type prowlarr struct {
info ProviderInfo
logger *slog.Logger
client *apiClient
indexerIDs []string
minSeeders int
}
// newProwlarr builds the provider from config.
func newProwlarr(
cfg Config,
secrets SecretLookup,
logger *slog.Logger,
) (Provider, error) {
base := strings.TrimRight(cfg.Setting("url", ""), "/")
if base == "" {
return nil, fmt.Errorf("%w: Prowlarr URL is required", ErrNotConfigured)
}
apiKey := ""
if secrets != nil {
key, err := secrets("apiKey")
if err != nil {
return nil, fmt.Errorf("%w: no API key stored", ErrNotConfigured)
}
apiKey = key
}
minSeeders, err := strconv.Atoi(cfg.Setting("minSeeders", "1"))
if err != nil {
minSeeders = 1
}
var indexers []string
for _, id := range strings.Split(cfg.Setting("indexerIds", ""), ",") {
if trimmed := strings.TrimSpace(id); trimmed != "" {
indexers = append(indexers, trimmed)
}
}
return &prowlarr{
info: ProviderInfo{
ID: cfg.ID,
Kind: KindProwlarr,
Name: cfg.Name,
Enabled: cfg.Enabled,
Priority: cfg.Priority,
Caps: Caps{CanSearch: true},
},
logger: logger.With("provider", "prowlarr"),
client: newAPIClient(
base, "X-Api-Key", apiKey, prowlarrHTTPTimeout,
ErrProwlarrUnreachable, ErrProwlarrAuth,
),
indexerIDs: indexers,
minSeeders: minSeeders,
}, nil
}
// Info returns the provider's identity.
func (p *prowlarr) Info() ProviderInfo {
return p.info
}
// Close is a no-op.
func (p *prowlarr) Close() error {
return nil
}
// Check verifies the instance answers and has something to search.
func (p *prowlarr) Check(ctx context.Context) error {
var status struct {
Version string `json:"version"`
}
if err := p.client.get(ctx, "/api/v1/system/status", &status); err != nil {
return err
}
var indexers []struct {
ID int `json:"id"`
Enable bool `json:"enable"`
}
if err := p.client.get(ctx, "/api/v1/indexer", &indexers); err != nil {
return err
}
for _, i := range indexers {
if i.Enable {
return nil
}
}
return ErrProwlarrNoIndexers
}
// prowlarrResult is one indexer hit.
type prowlarrResult struct {
GUID string `json:"guid"`
Title string `json:"title"`
Indexer string `json:"indexer"`
Size int64 `json:"size"`
Seeders int `json:"seeders"`
Leechers int `json:"leechers"`
Protocol string `json:"protocol"` // "torrent" or "usenet"
DownloadURL string `json:"downloadUrl"`
MagnetURL string `json:"magnetUrl"`
InfoHash string `json:"infoHash"`
}
// Search queries every configured indexer through Prowlarr.
func (p *prowlarr) Search(
ctx context.Context,
req Request,
) ([]Candidate, error) {
query := url.Values{}
query.Set("query", req.SearchText())
query.Set("categories", prowlarrMusicCategory)
query.Set("type", "search")
for _, id := range p.indexerIDs {
query.Add("indexerIds", id)
}
var results []prowlarrResult
endpoint := "/api/v1/search?" + query.Encode()
if err := p.client.get(ctx, endpoint, &results); err != nil {
return nil, err
}
out := make([]Candidate, 0, len(results))
for _, r := range results {
if len(out) >= prowlarrMaxResults {
break
}
protocol := protocolFor(r.Protocol)
if protocol == ProtocolDirect {
continue
}
// A torrent with no seeders will never finish. Offering it
// wastes the user's pick on something that cannot complete.
if protocol == ProtocolTorrent && r.Seeders < p.minSeeders {
continue
}
link := r.MagnetURL
if link == "" {
link = r.DownloadURL
}
if link == "" {
continue
}
out = append(out, Candidate{
ID: "prowlarr:" + r.GUID,
Kind: KindProwlarr,
Protocol: protocol,
Title: r.Title,
Origin: r.Indexer,
// Indexer results are opaque before they are fetched: there
// is no file list, so no per-file scoring is possible and
// the ranker works from the release title alone.
Files: nil,
TotalSize: r.Size,
Health: swarmHealth(protocol, r.Seeders),
Payload: map[string]string{
"link": link,
"indexer": r.Indexer,
"infoHash": r.InfoHash,
},
})
}
return out, nil
}
// protocolFor maps Prowlarr's protocol string onto ours.
func protocolFor(s string) Protocol {
switch strings.ToLower(s) {
case "torrent":
return ProtocolTorrent
case "usenet":
return ProtocolUsenet
default:
return ProtocolDirect
}
}
// swarmHealth scores availability, in 0..1. Usenet has no swarm: a
// retained article either downloads at full speed or is gone, so it
// gets a flat, confident score.
func swarmHealth(protocol Protocol, seeders int) float64 {
if protocol == ProtocolUsenet {
return 0.85
}
// Seeder counts have sharply diminishing returns — the difference
// between 1 and 10 is enormous, between 100 and 500 irrelevant.
switch {
case seeders <= 0:
return 0.05
case seeders == 1:
return 0.3
case seeders < 5:
return 0.5
case seeders < 20:
return 0.75
case seeders < 100:
return 0.9
default:
return 1.0
}
}
+313
View File
@@ -0,0 +1,313 @@
package download
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// prowlarrStub is a fake Prowlarr instance.
type prowlarrStub struct {
server *httptest.Server
mu sync.Mutex
results []prowlarrResult
indexers []map[string]any
// lastQuery records the search query string for assertions.
lastQuery string
unauthorized bool
}
func newProwlarrStub(t *testing.T) *prowlarrStub {
t.Helper()
s := &prowlarrStub{
indexers: []map[string]any{{"id": 1, "enable": true}},
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/system/status", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
writeJSON(t, w, map[string]any{"version": "1.0.0"})
})
mux.HandleFunc("/api/v1/indexer", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
s.mu.Lock()
indexers := s.indexers
s.mu.Unlock()
writeJSON(t, w, indexers)
})
mux.HandleFunc("/api/v1/search", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
s.mu.Lock()
s.lastQuery = r.URL.RawQuery
results := s.results
s.mu.Unlock()
writeJSON(t, w, results)
})
s.server = httptest.NewServer(mux)
t.Cleanup(s.server.Close)
return s
}
func (s *prowlarrStub) reject(w http.ResponseWriter, r *http.Request) bool {
s.mu.Lock()
unauthorized := s.unauthorized
s.mu.Unlock()
if unauthorized || r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return true
}
return false
}
func newStubProwlarr(t *testing.T, stub *prowlarrStub, settings map[string]string) *prowlarr {
t.Helper()
if settings == nil {
settings = map[string]string{}
}
settings["url"] = stub.server.URL
p, err := newProwlarr(
Config{ID: 1, Kind: KindProwlarr, Name: "prowlarr", Settings: settings},
func(string) (string, error) { return "test-key", nil },
slogDiscard(),
)
if err != nil {
t.Fatalf("newProwlarr: %v", err)
}
pr, ok := p.(*prowlarr)
if !ok {
t.Fatalf("provider is %T, want *prowlarr", p)
}
return pr
}
func TestProwlarrCheck(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
p := newStubProwlarr(t, stub, nil)
if err := p.Check(context.Background()); err != nil {
t.Errorf("Check: %v", err)
}
}
// Prowlarr with every indexer disabled will silently return nothing
// forever, which is worth surfacing at configuration time.
func TestProwlarrCheckRequiresEnabledIndexer(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
stub.indexers = []map[string]any{{"id": 1, "enable": false}}
p := newStubProwlarr(t, stub, nil)
if err := p.Check(context.Background()); !errors.Is(
err, ErrProwlarrNoIndexers,
) {
t.Errorf("error = %v, want ErrProwlarrNoIndexers", err)
}
}
// Prowlarr fills only the Searcher role, so its candidates must carry a
// protocol the pipeline can pair with a transport.
func TestProwlarrSearchMarksProtocols(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
stub.results = []prowlarrResult{
{
GUID: "a",
Title: "Radiohead - OK Computer [FLAC]",
Indexer: "SomeTracker",
Protocol: "torrent",
Seeders: 50,
Size: 400_000_000,
MagnetURL: "magnet:?xt=urn:btih:abc123",
},
{
GUID: "b",
Title: "Radiohead - OK Computer [MP3]",
Indexer: "SomeUsenet",
Protocol: "usenet",
Size: 90_000_000,
DownloadURL: "https://example.com/x.nzb",
},
}
p := newStubProwlarr(t, stub, nil)
got, err := p.Search(context.Background(), Request{
Artist: "Radiohead",
Album: "OK Computer",
})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d candidates, want 2", len(got))
}
byProtocol := map[Protocol]Candidate{}
for _, c := range got {
byProtocol[c.Protocol] = c
}
torrent, ok := byProtocol[ProtocolTorrent]
if !ok {
t.Fatal("no torrent candidate")
}
if torrent.Payload["link"] != "magnet:?xt=urn:btih:abc123" {
t.Errorf("torrent link = %q, want the magnet", torrent.Payload["link"])
}
usenet, ok := byProtocol[ProtocolUsenet]
if !ok {
t.Fatal("no usenet candidate")
}
if usenet.Payload["link"] != "https://example.com/x.nzb" {
t.Errorf("usenet link = %q, want the NZB URL", usenet.Payload["link"])
}
// Indexer results are opaque before fetching; claiming a file list
// would be inventing information.
if len(torrent.Files) != 0 {
t.Errorf("torrent candidate has %d files, want none", len(torrent.Files))
}
}
// A torrent nobody is seeding will never finish, so offering it wastes
// the user's choice.
func TestProwlarrFiltersDeadTorrents(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
stub.results = []prowlarrResult{
{
GUID: "dead", Title: "Dead", Protocol: "torrent",
Seeders: 0, MagnetURL: "magnet:?xt=urn:btih:dead",
},
{
GUID: "alive", Title: "Alive", Protocol: "torrent",
Seeders: 10, MagnetURL: "magnet:?xt=urn:btih:alive",
},
}
p := newStubProwlarr(t, stub, map[string]string{"minSeeders": "1"})
got, err := p.Search(context.Background(), Request{Query: "x"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d candidates, want 1", len(got))
}
if got[0].Title != "Alive" {
t.Errorf("kept %q, want the seeded torrent", got[0].Title)
}
}
// Searching without a category constraint returns films and software
// alongside music.
func TestProwlarrSearchesMusicCategory(t *testing.T) {
t.Parallel()
stub := newProwlarrStub(t)
p := newStubProwlarr(t, stub, nil)
if _, err := p.Search(
context.Background(), Request{Query: "radiohead"},
); err != nil {
t.Fatalf("Search: %v", err)
}
stub.mu.Lock()
query := stub.lastQuery
stub.mu.Unlock()
if !strings.Contains(query, "categories="+prowlarrMusicCategory) {
t.Errorf("query %q does not constrain to the music category", query)
}
}
func TestSwarmHealth(t *testing.T) {
t.Parallel()
// More seeders is never worse.
prev := -1.0
for _, seeders := range []int{0, 1, 3, 10, 50, 500} {
got := swarmHealth(ProtocolTorrent, seeders)
if got < prev {
t.Errorf("health fell at %d seeders: %f after %f", seeders, got, prev)
}
if got < 0 || got > 1 {
t.Errorf("health %f out of range at %d seeders", got, seeders)
}
prev = got
}
// Usenet has no swarm, so seeder count is meaningless there.
if a, b := swarmHealth(ProtocolUsenet, 0), swarmHealth(ProtocolUsenet, 99); a != b {
t.Errorf("usenet health varied with seeders: %f vs %f", a, b)
}
}
func TestProtocolFor(t *testing.T) {
t.Parallel()
tests := map[string]Protocol{
"torrent": ProtocolTorrent,
"Torrent": ProtocolTorrent,
"usenet": ProtocolUsenet,
"USENET": ProtocolUsenet,
"weird": ProtocolDirect,
"": ProtocolDirect,
}
for in, want := range tests {
if got := protocolFor(in); got != want {
t.Errorf("protocolFor(%q) = %q, want %q", in, got, want)
}
}
}
+595
View File
@@ -0,0 +1,595 @@
package download
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// qBittorrent is a pure transport: it never searches, it just takes a
// magnet link Prowlarr found and moves the bytes. That is the point of
// splitting Transporter out — this adapter knows nothing about music.
//
// Two things make it different from the others. Its auth is a session
// cookie rather than a header, so it needs its own client. And it can
// be told where to save, which means the transfer lands directly in our
// staging directory instead of needing to be collected afterwards —
// provided qBittorrent sees the same filesystem we do.
// qBittorrent provider errors.
var (
// ErrQbitUnreachable means the instance did not answer.
ErrQbitUnreachable = errors.New("qbittorrent is unreachable")
// ErrQbitAuth means the credentials were rejected.
ErrQbitAuth = errors.New("qbittorrent rejected the credentials")
// ErrQbitNoHash means the candidate carried no usable torrent
// identifier, so the transfer could not be tracked.
ErrQbitNoHash = errors.New("candidate has no torrent hash")
// ErrQbitTransferFailed means the torrent errored or stalled out.
ErrQbitTransferFailed = errors.New("qbittorrent transfer failed")
)
// qBittorrent tuning.
const (
// qbitHTTPTimeout bounds one API call.
qbitHTTPTimeout = 30 * time.Second
// qbitPollInterval is how often torrent state is checked.
qbitPollInterval = 5 * time.Second
// qbitAppearWait is how long to wait for a just-added torrent to
// show up in the torrent list before concluding it was rejected.
qbitAppearWait = 60 * time.Second
)
func init() {
Register(
Descriptor{
Kind: KindQBittorrent,
Name: "qBittorrent",
Summary: "Download torrents found by an indexer. " +
"Pairs with Prowlarr; does not search on its own.",
RequiresExternal: "qBittorrent",
Caps: Caps{
CanTransport: true,
CanCancel: true,
CanResume: true,
ReportsSize: true,
Transports: []Protocol{ProtocolTorrent},
},
Fields: []Field{
{
Key: "url",
Label: "qBittorrent URL",
Placeholder: "http://localhost:8080",
Required: true,
Default: "http://localhost:8080",
},
{
Key: "username",
Label: "Username",
Required: true,
Default: "admin",
},
{
Key: "password",
Label: "Password",
Secret: true,
Required: true,
},
{
Key: "category",
Label: "Category",
Help: "qBittorrent category to tag these downloads with. " +
"Useful for keeping them out of your other rules.",
Default: "yellowjacket",
},
},
},
newQBittorrent,
)
}
// qbittorrent is the qBittorrent transport.
type qbittorrent struct {
info ProviderInfo
logger *slog.Logger
client *http.Client
baseURL string
username string
password string
category string
// authMu guards the lazy login, so concurrent grabs share one
// session instead of racing to create several.
authMu sync.Mutex
authenticated bool
pollInterval time.Duration
}
// newQBittorrent builds the transport from config.
func newQBittorrent(
cfg Config,
secrets SecretLookup,
logger *slog.Logger,
) (Provider, error) {
base := strings.TrimRight(cfg.Setting("url", ""), "/")
if base == "" {
return nil, fmt.Errorf(
"%w: qBittorrent URL is required", ErrNotConfigured,
)
}
password := ""
if secrets != nil {
pw, err := secrets("password")
if err != nil {
return nil, fmt.Errorf("%w: no password stored", ErrNotConfigured)
}
password = pw
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("create cookie jar: %w", err)
}
return &qbittorrent{
info: ProviderInfo{
ID: cfg.ID,
Kind: KindQBittorrent,
Name: cfg.Name,
Enabled: cfg.Enabled,
Priority: cfg.Priority,
Caps: Caps{
CanTransport: true,
CanCancel: true,
CanResume: true,
ReportsSize: true,
Transports: []Protocol{ProtocolTorrent},
},
},
logger: logger.With("provider", "qbittorrent"),
client: &http.Client{
Timeout: qbitHTTPTimeout,
Jar: jar,
},
baseURL: base,
username: cfg.Setting("username", "admin"),
password: password,
category: cfg.Setting("category", "yellowjacket"),
pollInterval: qbitPollInterval,
}, nil
}
// Info returns the provider's identity.
func (q *qbittorrent) Info() ProviderInfo {
return q.info
}
// Close is a no-op; the session expires on its own.
func (q *qbittorrent) Close() error {
return nil
}
// Check logs in and asks for the version.
func (q *qbittorrent) Check(ctx context.Context) error {
if err := q.login(ctx); err != nil {
return err
}
_, err := q.call(ctx, "/api/v2/app/version", nil)
return err
}
// login establishes a session cookie. qBittorrent answers a bad login
// with 200 and the body "Fails.", not a 401, so the body is what has to
// be checked.
func (q *qbittorrent) login(ctx context.Context) error {
q.authMu.Lock()
defer q.authMu.Unlock()
form := url.Values{}
form.Set("username", q.username)
form.Set("password", q.password)
body, err := q.post(ctx, "/api/v2/auth/login", form)
if err != nil {
return err
}
if !strings.Contains(strings.ToLower(body), "ok") {
q.authenticated = false
return ErrQbitAuth
}
q.authenticated = true
return nil
}
// ensureAuth logs in if this client has not yet done so.
func (q *qbittorrent) ensureAuth(ctx context.Context) error {
q.authMu.Lock()
done := q.authenticated
q.authMu.Unlock()
if done {
return nil
}
return q.login(ctx)
}
// qbitTorrent is the subset of qBittorrent's torrent list used here.
type qbitTorrent struct {
Hash string `json:"hash"`
Name string `json:"name"`
State string `json:"state"`
Progress float64 `json:"progress"`
Size int64 `json:"size"`
Completed int64 `json:"completed"`
ContentPath string `json:"content_path"`
SavePath string `json:"save_path"`
}
// finished reports whether the torrent has all its data.
func (t qbitTorrent) finished() bool {
switch t.State {
case "uploading", "stalledUP", "queuedUP", "pausedUP", "forcedUP",
"checkingUP":
return true
default:
return t.Progress >= 1.0
}
}
// failed reports whether the torrent is in an unrecoverable state.
func (t qbitTorrent) failed() bool {
return t.State == "error" || t.State == "missingFiles"
}
// Grab adds the torrent, waits for it to complete, and collects its
// files into dst.
func (q *qbittorrent) Grab(
ctx context.Context,
c Candidate,
dst string,
onProgress ProgressFunc,
) (Result, error) {
if err := q.ensureAuth(ctx); err != nil {
return Result{}, err
}
link := c.Payload["link"]
if link == "" {
return Result{}, fmt.Errorf(
"%w: candidate has no magnet or torrent URL", ErrQbitNoHash,
)
}
form := url.Values{}
form.Set("urls", link)
form.Set("savepath", dst)
form.Set("category", q.category)
// Skip qBittorrent's own "move on completion" rules: the file must
// stay where we put it until the import step decides otherwise.
form.Set("autoTMM", "false")
if _, err := q.post(ctx, "/api/v2/torrents/add", form); err != nil {
return Result{}, err
}
hash, err := q.resolveHash(ctx, c, link)
if err != nil {
return Result{}, err
}
torrent, err := q.await(ctx, hash, c, onProgress)
if err != nil {
return Result{}, err
}
return collectTree(torrent.ContentPath, dst)
}
// resolveHash finds the torrent's hash, preferring the one the indexer
// supplied and falling back to matching the newest torrent in our
// category — qBittorrent's add endpoint returns nothing useful.
func (q *qbittorrent) resolveHash(
ctx context.Context,
c Candidate,
link string,
) (string, error) {
if h := c.Payload["infoHash"]; h != "" {
return strings.ToLower(h), nil
}
if h := infoHashFromMagnet(link); h != "" {
return h, nil
}
// Poll briefly for a torrent in our category that was not there
// before; the add is asynchronous.
deadline := time.Now().Add(qbitAppearWait)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return "", fmt.Errorf("%w: cancelled", ErrQbitTransferFailed)
case <-time.After(q.pollInterval):
}
torrents, err := q.list(ctx, "")
if err != nil {
continue
}
for _, t := range torrents {
if strings.EqualFold(t.Name, c.Title) {
return t.Hash, nil
}
}
}
return "", fmt.Errorf(
"%w: torrent never appeared in qBittorrent", ErrQbitNoHash,
)
}
// await polls until the torrent finishes or fails.
func (q *qbittorrent) await(
ctx context.Context,
hash string,
c Candidate,
onProgress ProgressFunc,
) (qbitTorrent, error) {
for {
select {
case <-ctx.Done():
return qbitTorrent{}, fmt.Errorf(
"%w: cancelled", ErrQbitTransferFailed,
)
case <-time.After(q.pollInterval):
}
torrents, err := q.list(ctx, hash)
if err != nil {
q.logger.Debug("qbittorrent poll failed", "error", err)
continue
}
if len(torrents) == 0 {
continue
}
t := torrents[0]
if onProgress != nil {
total := t.Size
if total == 0 {
total = c.TotalSize
}
onProgress(Progress{
Current: t.Completed,
Total: total,
Phase: "Downloading torrent (" + t.State + ")",
})
}
if t.failed() {
return qbitTorrent{}, fmt.Errorf(
"%w: state %s", ErrQbitTransferFailed, t.State,
)
}
if t.finished() {
return t, nil
}
}
}
// list returns torrents, optionally filtered to one hash.
func (q *qbittorrent) list(
ctx context.Context,
hash string,
) ([]qbitTorrent, error) {
params := url.Values{}
if hash != "" {
params.Set("hashes", hash)
}
var out []qbitTorrent
if err := q.getJSON(ctx, "/api/v2/torrents/info", params, &out); err != nil {
return nil, err
}
return out, nil
}
// infoHashFromMagnet extracts the btih hash from a magnet URI.
func infoHashFromMagnet(magnet string) string {
if !strings.HasPrefix(magnet, "magnet:") {
return ""
}
u, err := url.Parse(magnet)
if err != nil {
return ""
}
for _, xt := range u.Query()["xt"] {
if after, ok := strings.CutPrefix(xt, "urn:btih:"); ok {
return strings.ToLower(after)
}
}
return ""
}
// collectTree gathers every file under root into dst. A torrent may be
// a single file or a directory tree; either way the importer wants a
// flat set of paths inside the staging directory.
func collectTree(root, dst string) (Result, error) {
result := Result{Dir: dst, Files: []string{}}
info, err := os.Stat(root)
if err != nil {
return Result{}, fmt.Errorf("stat downloaded content: %w", err)
}
if !info.IsDir() {
target := filepath.Join(dst, filepath.Base(root))
if root != target {
if err := movePath(root, target); err != nil {
return Result{}, err
}
}
result.Files = append(result.Files, target)
result.BytesTransferred = info.Size()
return result, nil
}
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err //nolint:wrapcheck // walk error passthrough
}
fi, err := d.Info()
if err != nil || fi.Size() == 0 {
return nil //nolint:nilerr // skip unreadable entries
}
target := filepath.Join(dst, filepath.Base(path))
if path != target {
if err := movePath(path, target); err != nil {
return err
}
}
result.Files = append(result.Files, target)
result.BytesTransferred += fi.Size()
return nil
})
if err != nil {
return Result{}, fmt.Errorf("collect downloaded files: %w", err)
}
return result, nil
}
// ---------------------------------------------------------------------------
// HTTP
// ---------------------------------------------------------------------------
// call performs a GET and returns the raw body.
func (q *qbittorrent) call(
ctx context.Context,
endpoint string,
params url.Values,
) (string, error) {
target := q.baseURL + endpoint
if len(params) > 0 {
target += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return "", fmt.Errorf("build qbittorrent request: %w", err)
}
return q.send(req)
}
// getJSON performs a GET and decodes JSON.
func (q *qbittorrent) getJSON(
ctx context.Context,
endpoint string,
params url.Values,
out any,
) error {
body, err := q.call(ctx, endpoint, params)
if err != nil {
return err
}
if err := decodeJSON(body, out); err != nil {
return fmt.Errorf("decode qbittorrent response: %w", err)
}
return nil
}
// post performs a form POST and returns the raw body.
func (q *qbittorrent) post(
ctx context.Context,
endpoint string,
form url.Values,
) (string, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
q.baseURL+endpoint,
strings.NewReader(form.Encode()),
)
if err != nil {
return "", fmt.Errorf("build qbittorrent request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// qBittorrent rejects cross-origin requests unless Referer matches.
req.Header.Set("Referer", q.baseURL)
return q.send(req)
}
// send executes a request and normalizes failures.
func (q *qbittorrent) send(req *http.Request) (string, error) {
resp, err := q.client.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %w", ErrQbitUnreachable, err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
switch {
case resp.StatusCode == http.StatusForbidden:
return "", ErrQbitAuth
case resp.StatusCode >= 400:
return "", fmt.Errorf(
"%w: HTTP %d: %s",
ErrQbitUnreachable, resp.StatusCode, strings.TrimSpace(string(body)),
)
}
return string(body), nil
}
+388
View File
@@ -0,0 +1,388 @@
package download
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"time"
)
// SABnzbd is the usenet half of the split-role pair: Prowlarr finds an
// NZB, SABnzbd fetches and unpacks it.
//
// Like slskd, it writes to its own completed-downloads directory rather
// than one we choose, and unlike qBittorrent there is no per-job save
// path we can set reliably across versions. It does, however, report
// the final storage path in its history, so the collect step reads that
// rather than guessing.
// SABnzbd provider errors.
var (
// ErrSabUnreachable means the instance did not answer.
ErrSabUnreachable = errors.New("sabnzbd is unreachable")
// ErrSabAuth means the API key was rejected.
ErrSabAuth = errors.New("sabnzbd rejected the API key")
// ErrSabTransferFailed means the job failed or was removed.
ErrSabTransferFailed = errors.New("sabnzbd job failed")
// ErrSabNoJob means the queued job vanished from both queue and
// history without completing.
ErrSabNoJob = errors.New("sabnzbd job disappeared")
)
// SABnzbd tuning.
const (
// sabHTTPTimeout bounds one API call.
sabHTTPTimeout = 30 * time.Second
// sabPollInterval is how often job state is checked.
sabPollInterval = 5 * time.Second
)
func init() {
Register(
Descriptor{
Kind: KindSABnzbd,
Name: "SABnzbd",
Summary: "Download usenet releases found by an indexer. " +
"Pairs with Prowlarr; does not search on its own.",
RequiresExternal: "SABnzbd",
Caps: Caps{
CanTransport: true,
CanCancel: true,
ReportsSize: true,
Transports: []Protocol{ProtocolUsenet},
},
Fields: []Field{
{
Key: "url",
Label: "SABnzbd URL",
Placeholder: "http://localhost:8080",
Required: true,
Default: "http://localhost:8080",
},
{
Key: "apiKey",
Label: "API key",
Secret: true,
Required: true,
Help: "SABnzbd → Config → General → API Key.",
},
{
Key: "category",
Label: "Category",
Help: "SABnzbd category for these downloads. " +
"Its folder must be readable from this machine.",
Default: "music",
},
},
},
newSABnzbd,
)
}
// sabnzbd is the SABnzbd transport.
type sabnzbd struct {
info ProviderInfo
logger *slog.Logger
client *apiClient
apiKey string
category string
pollInterval time.Duration
}
// newSABnzbd builds the transport from config.
func newSABnzbd(
cfg Config,
secrets SecretLookup,
logger *slog.Logger,
) (Provider, error) {
base := strings.TrimRight(cfg.Setting("url", ""), "/")
if base == "" {
return nil, fmt.Errorf("%w: SABnzbd URL is required", ErrNotConfigured)
}
apiKey := ""
if secrets != nil {
key, err := secrets("apiKey")
if err != nil {
return nil, fmt.Errorf("%w: no API key stored", ErrNotConfigured)
}
apiKey = key
}
return &sabnzbd{
info: ProviderInfo{
ID: cfg.ID,
Kind: KindSABnzbd,
Name: cfg.Name,
Enabled: cfg.Enabled,
Priority: cfg.Priority,
Caps: Caps{
CanTransport: true,
CanCancel: true,
ReportsSize: true,
Transports: []Protocol{ProtocolUsenet},
},
},
logger: logger.With("provider", "sabnzbd"),
// SABnzbd authenticates by query parameter, not header, so the
// shared client carries no auth header here.
client: newAPIClient(
base, "", "", sabHTTPTimeout, ErrSabUnreachable, ErrSabAuth,
),
apiKey: apiKey,
category: cfg.Setting("category", "music"),
pollInterval: sabPollInterval,
}, nil
}
// Info returns the provider's identity.
func (s *sabnzbd) Info() ProviderInfo {
return s.info
}
// Close is a no-op.
func (s *sabnzbd) Close() error {
return nil
}
// sabResponse is SABnzbd's common envelope. It answers a bad API key
// with HTTP 200 and status:false, so the body has to be inspected.
type sabResponse struct {
Status bool `json:"status"`
Error string `json:"error"`
NzoIDs []string `json:"nzo_ids"`
}
// sabQueue is the queue view.
type sabQueue struct {
Queue struct {
Slots []sabQueueSlot `json:"slots"`
} `json:"queue"`
}
// sabQueueSlot is one in-flight job.
type sabQueueSlot struct {
NzoID string `json:"nzo_id"`
Filename string `json:"filename"`
Status string `json:"status"`
Percentage string `json:"percentage"`
MB string `json:"mb"`
MBLeft string `json:"mbleft"`
}
// sabHistory is the history view.
type sabHistory struct {
History struct {
Slots []sabHistorySlot `json:"slots"`
} `json:"history"`
}
// sabHistorySlot is one finished job. Storage is the unpacked path,
// which is the only reliable way to find what SABnzbd produced.
type sabHistorySlot struct {
NzoID string `json:"nzo_id"`
Name string `json:"name"`
Status string `json:"status"`
Storage string `json:"storage"`
FailMsg string `json:"fail_message"`
Bytes int64 `json:"bytes"`
}
// Check verifies the instance answers and the key is accepted.
func (s *sabnzbd) Check(ctx context.Context) error {
var resp struct {
Version string `json:"version"`
}
if err := s.call(ctx, url.Values{"mode": {"version"}}, &resp); err != nil {
return err
}
// version answers without auth, so make one authenticated call too.
var queue sabQueue
return s.call(ctx, url.Values{"mode": {"queue"}}, &queue)
}
// Grab adds the NZB, waits for SABnzbd to finish, and moves the
// unpacked files into dst.
func (s *sabnzbd) Grab(
ctx context.Context,
c Candidate,
dst string,
onProgress ProgressFunc,
) (Result, error) {
link := c.Payload["link"]
if link == "" {
return Result{}, fmt.Errorf(
"%w: candidate has no NZB URL", ErrSabTransferFailed,
)
}
if err := validateHTTPURL(link); err != nil {
return Result{}, err
}
var added sabResponse
if err := s.call(ctx, url.Values{
"mode": {"addurl"},
"name": {link},
"cat": {s.category},
"nzbname": {c.Title},
"priority": {"0"},
}, &added); err != nil {
return Result{}, err
}
if !added.Status || len(added.NzoIDs) == 0 {
return Result{}, fmt.Errorf(
"%w: %s", ErrSabTransferFailed, added.Error,
)
}
nzoID := added.NzoIDs[0]
slot, err := s.await(ctx, nzoID, onProgress)
if err != nil {
return Result{}, err
}
if !strings.EqualFold(slot.Status, "Completed") {
return Result{}, fmt.Errorf(
"%w: %s: %s", ErrSabTransferFailed, slot.Status, slot.FailMsg,
)
}
return collectTree(slot.Storage, dst)
}
// await polls the queue until the job leaves it, then reads history for
// the outcome. SABnzbd moves a job from queue to history when it
// finishes post-processing, so history is where completion is truthful.
func (s *sabnzbd) await(
ctx context.Context,
nzoID string,
onProgress ProgressFunc,
) (sabHistorySlot, error) {
for {
select {
case <-ctx.Done():
return sabHistorySlot{}, fmt.Errorf(
"%w: cancelled", ErrSabTransferFailed,
)
case <-time.After(s.pollInterval):
}
inQueue, slot, err := s.queueSlot(ctx, nzoID)
if err != nil {
s.logger.Debug("sabnzbd queue poll failed", "error", err)
continue
}
if inQueue {
if onProgress != nil {
onProgress(Progress{
Current: parseMB(slot.MB) - parseMB(slot.MBLeft),
Total: parseMB(slot.MB),
Phase: "Downloading from usenet (" + slot.Status + ")",
})
}
continue
}
found, hist, err := s.historySlot(ctx, nzoID)
if err != nil {
s.logger.Debug("sabnzbd history poll failed", "error", err)
continue
}
if found {
return hist, nil
}
// Not in the queue and not in history: the job was removed out
// from under us.
return sabHistorySlot{}, ErrSabNoJob
}
}
// queueSlot looks for a job in the queue.
func (s *sabnzbd) queueSlot(
ctx context.Context,
nzoID string,
) (bool, sabQueueSlot, error) {
var queue sabQueue
if err := s.call(ctx, url.Values{"mode": {"queue"}}, &queue); err != nil {
return false, sabQueueSlot{}, err
}
for _, slot := range queue.Queue.Slots {
if slot.NzoID == nzoID {
return true, slot, nil
}
}
return false, sabQueueSlot{}, nil
}
// historySlot looks for a job in history.
func (s *sabnzbd) historySlot(
ctx context.Context,
nzoID string,
) (bool, sabHistorySlot, error) {
var history sabHistory
if err := s.call(
ctx, url.Values{"mode": {"history"}}, &history,
); err != nil {
return false, sabHistorySlot{}, err
}
for _, slot := range history.History.Slots {
if slot.NzoID == nzoID {
return true, slot, nil
}
}
return false, sabHistorySlot{}, nil
}
// call performs one API request. SABnzbd puts everything on the query
// string of a single endpoint.
func (s *sabnzbd) call(ctx context.Context, params url.Values, out any) error {
params.Set("apikey", s.apiKey)
params.Set("output", "json")
return s.client.get(ctx, "/api?"+params.Encode(), out)
}
// parseMB converts SABnzbd's megabyte strings to bytes. Values are
// decimal strings like "1024.5"; a malformed one yields 0 rather than
// failing a transfer that is otherwise fine.
func parseMB(s string) int64 {
const bytesPerMB = 1024 * 1024
var mb float64
if _, err := fmt.Sscanf(strings.TrimSpace(s), "%f", &mb); err != nil {
return 0
}
return int64(mb * bytesPerMB)
}
+624
View File
@@ -0,0 +1,624 @@
package download
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
"time"
)
// Soulseek is reached through a user-run slskd daemon rather than the
// wire protocol. That trades a setup step for not having to implement
// peer connections, distributed search, and upload obligations — and
// keeps the user's Soulseek credentials in their daemon instead of in
// this process.
//
// One wrinkle shapes this adapter: slskd downloads into its own
// configured directory, not one we hand it. There is no API to stream
// a finished file back. So the user tells us where that directory is,
// and Grab waits for the transfer, then moves the files into staging.
// When slskd runs on another machine, that path has to be a mount —
// which is why Check verifies it exists rather than discovering the
// problem after a two-hour transfer.
// slskd provider errors.
var (
// ErrSlskdUnreachable means the daemon did not answer.
ErrSlskdUnreachable = errors.New("slskd is unreachable")
// ErrSlskdAuth means the API key was rejected.
ErrSlskdAuth = errors.New("slskd rejected the API key")
// ErrSlskdDownloadsPath means the configured downloads directory is
// missing or unreadable from this machine.
ErrSlskdDownloadsPath = errors.New(
"slskd downloads directory is not readable from here",
)
// ErrSlskdTransferFailed means a peer transfer ended badly.
ErrSlskdTransferFailed = errors.New("slskd transfer failed")
// ErrSlskdTimeout means a search or transfer outlived its budget.
ErrSlskdTimeout = errors.New("slskd timed out")
)
// slskd tuning.
const (
// slskdSearchPoll is how often an in-flight search is polled.
slskdSearchPoll = 1 * time.Second
// slskdSearchWait bounds a single search. Soulseek searches return
// results progressively; waiting the full budget gets noticeably
// more peers than bailing at the first response.
slskdSearchWait = 12 * time.Second
// slskdTransferPoll is how often transfer state is polled.
slskdTransferPoll = 3 * time.Second
// slskdMinFiles is the fewest audio files a folder needs before it
// is offered as a candidate. Soulseek returns a lot of one-file
// noise for common queries.
slskdMinFiles = 2
// slskdHTTPTimeout bounds one API call.
slskdHTTPTimeout = 20 * time.Second
)
func init() {
Register(
Descriptor{
Kind: KindSlskd,
Name: "Soulseek (slskd)",
Summary: "Search and download from the Soulseek network " +
"through your own slskd daemon.",
RequiresExternal: "slskd",
Caps: Caps{
CanSearch: true,
CanTransport: true,
CanCancel: true,
ReportsSize: true,
},
Fields: []Field{
{
Key: "url",
Label: "slskd URL",
Placeholder: "http://localhost:5030",
Required: true,
Default: "http://localhost:5030",
},
{
Key: "apiKey",
Label: "API key",
Secret: true,
Required: true,
Help: "From your slskd configuration under web.authentication.",
},
{
Key: "downloadsPath",
Label: "slskd downloads folder",
Placeholder: "/var/lib/slskd/downloads",
Required: true,
Help: "The folder slskd saves to, as this machine sees it. " +
"If slskd runs elsewhere, this must be a mounted share.",
},
},
},
newSlskd,
)
}
// slskd is the Soulseek provider.
type slskd struct {
info ProviderInfo
logger *slog.Logger
client *apiClient
downloadsPath string
// Poll intervals are fields rather than constants so tests can run
// the full search-and-transfer flow without sleeping through it.
searchPoll time.Duration
searchWait time.Duration
transferPoll time.Duration
}
// newSlskd builds the provider from config.
func newSlskd(
cfg Config,
secrets SecretLookup,
logger *slog.Logger,
) (Provider, error) {
base := strings.TrimRight(cfg.Setting("url", ""), "/")
if base == "" {
return nil, fmt.Errorf("%w: slskd URL is required", ErrNotConfigured)
}
downloads := cfg.Setting("downloadsPath", "")
if downloads == "" {
return nil, fmt.Errorf(
"%w: slskd downloads folder is required", ErrNotConfigured,
)
}
apiKey := ""
if secrets != nil {
key, err := secrets("apiKey")
if err != nil {
return nil, fmt.Errorf("%w: no API key stored", ErrNotConfigured)
}
apiKey = key
}
return &slskd{
info: ProviderInfo{
ID: cfg.ID,
Kind: KindSlskd,
Name: cfg.Name,
Enabled: cfg.Enabled,
Priority: cfg.Priority,
Caps: Caps{
CanSearch: true,
CanTransport: true,
CanCancel: true,
ReportsSize: true,
},
},
logger: logger.With("provider", "slskd"),
client: newAPIClient(
base, "X-Api-Key", apiKey, slskdHTTPTimeout,
ErrSlskdUnreachable, ErrSlskdAuth,
),
downloadsPath: downloads,
searchPoll: slskdSearchPoll,
searchWait: slskdSearchWait,
transferPoll: slskdTransferPoll,
}, nil
}
// Info returns the provider's identity.
func (s *slskd) Info() ProviderInfo {
return s.info
}
// Close is a no-op; the HTTP client holds no session.
func (s *slskd) Close() error {
return nil
}
// Check verifies the daemon answers, the key is accepted, and the
// downloads directory is readable from this machine.
func (s *slskd) Check(ctx context.Context) error {
var app map[string]any
if err := s.client.get(ctx, "/api/v0/application", &app); err != nil {
return err
}
info, err := os.Stat(s.downloadsPath)
if err != nil || !info.IsDir() {
return fmt.Errorf("%w: %s", ErrSlskdDownloadsPath, s.downloadsPath)
}
return nil
}
// ---------------------------------------------------------------------------
// API types
// ---------------------------------------------------------------------------
// slskdSearch is a search as slskd reports it.
type slskdSearch struct {
ID string `json:"id"`
IsComplete bool `json:"isComplete"`
Responses []slskdResponse `json:"responses"`
}
// slskdResponse is one peer's answer to a search.
type slskdResponse struct {
Username string `json:"username"`
HasFreeUploadSlot bool `json:"hasFreeUploadSlot"`
QueueLength int `json:"queueLength"`
UploadSpeed int64 `json:"uploadSpeed"`
Files []slskdFile `json:"files"`
LockedFileCount int `json:"lockedFileCount"`
FileCount int `json:"fileCount"`
FreeUploadSlotFlag bool `json:"freeUploadSlots"`
}
// slskdFile is one file a peer is offering.
type slskdFile struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
BitRate int `json:"bitRate"`
Length int `json:"length"`
}
// slskdTransfer is one download's state.
type slskdTransfer struct {
ID string `json:"id"`
Username string `json:"username"`
Filename string `json:"filename"`
State string `json:"state"`
Size int64 `json:"size"`
BytesTransferred int64 `json:"bytesTransferred"`
}
// done reports whether the transfer reached a terminal state, and
// whether it succeeded. slskd reports compound states such as
// "Completed, Succeeded" and "Completed, Errored".
func (t slskdTransfer) done() (finished, ok bool) {
if !strings.Contains(t.State, "Completed") {
return false, false
}
return true, strings.Contains(t.State, "Succeeded")
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
// Search runs a Soulseek search and groups the results into per-peer,
// per-folder candidates. A folder from one peer is the unit a user
// actually wants: Soulseek has no album concept, but people organise
// their shares by album directory.
func (s *slskd) Search(ctx context.Context, req Request) ([]Candidate, error) {
searchID := newID()
body := map[string]any{
"id": searchID,
"searchText": req.SearchText(),
}
if err := s.client.post(ctx, "/api/v0/searches", body, nil); err != nil {
return nil, err
}
search, err := s.awaitSearch(ctx, searchID)
if err != nil {
return nil, err
}
// Best effort cleanup; a left-behind search is harmless but clutters
// the slskd UI.
defer func() {
_ = s.client.delete(
context.WithoutCancel(ctx), "/api/v0/searches/"+searchID,
)
}()
return s.candidatesFrom(search), nil
}
// awaitSearch polls until the search completes or the budget runs out.
// A timeout is not an error: partial Soulseek results are normal and
// often good enough.
func (s *slskd) awaitSearch(
ctx context.Context,
searchID string,
) (slskdSearch, error) {
deadline := time.Now().Add(s.searchWait)
var last slskdSearch
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return last, fmt.Errorf("%w: search cancelled", ErrSlskdTimeout)
case <-time.After(s.searchPoll):
}
var search slskdSearch
if err := s.client.get(
ctx,
"/api/v0/searches/"+searchID+"?includeResponses=true",
&search,
); err != nil {
return last, err
}
last = search
if search.IsComplete {
return search, nil
}
}
return last, nil
}
// candidatesFrom groups a search's responses into candidates.
func (s *slskd) candidatesFrom(search slskdSearch) []Candidate {
out := make([]Candidate, 0, len(search.Responses))
for _, resp := range search.Responses {
for folder, files := range groupByFolder(resp.Files) {
audio := 0
cfiles := make([]CandidateFile, 0, len(files))
var total int64
for _, f := range files {
format, isAudio := FormatForPath(f.Filename)
if isAudio {
audio++
}
cfiles = append(cfiles, CandidateFile{
Path: f.Filename,
Size: f.Size,
Format: format,
Bitrate: f.BitRate,
IsAudio: isAudio,
})
total += f.Size
}
if audio < slskdMinFiles {
continue
}
out = append(out, Candidate{
ID: "slskd:" + resp.Username + ":" + folder,
Kind: KindSlskd,
Protocol: ProtocolDirect,
Title: path.Base(strings.ReplaceAll(folder, `\`, "/")),
Origin: resp.Username,
Files: cfiles,
TotalSize: total,
Health: peerHealth(resp),
Payload: map[string]string{"username": resp.Username},
})
}
}
return out
}
// groupByFolder buckets a peer's files by their containing directory.
func groupByFolder(files []slskdFile) map[string][]slskdFile {
out := map[string][]slskdFile{}
for _, f := range files {
norm := strings.ReplaceAll(f.Filename, `\`, "/")
out[path.Dir(norm)] = append(out[path.Dir(norm)], f)
}
return out
}
// peerHealth scores how likely a peer is to actually deliver, in 0..1.
// On Soulseek this matters more than it does for torrents: a queue of
// 40 behind a single upload slot means the transfer starts tomorrow,
// and that is the difference between a good candidate and a bad one no
// matter how good the files look.
func peerHealth(r slskdResponse) float64 {
score := 0.35
if r.HasFreeUploadSlot || r.FreeUploadSlotFlag {
score += 0.4
}
switch {
case r.QueueLength == 0:
score += 0.15
case r.QueueLength <= 3:
score += 0.08
case r.QueueLength > 20:
score -= 0.2
}
// Anything above roughly 1 MB/s is fast enough that more speed does
// not change the experience.
const fastEnough = 1_000_000
if r.UploadSpeed > 0 {
ratio := float64(r.UploadSpeed) / fastEnough
if ratio > 1 {
ratio = 1
}
score += 0.1 * ratio
}
return clamp01(score)
}
// ---------------------------------------------------------------------------
// Transfer
// ---------------------------------------------------------------------------
// Grab enqueues a candidate's files with slskd, waits for the peer to
// send them, then moves them out of slskd's download directory into the
// staging directory.
func (s *slskd) Grab(
ctx context.Context,
c Candidate,
dst string,
onProgress ProgressFunc,
) (Result, error) {
username := c.Payload["username"]
if username == "" {
return Result{}, fmt.Errorf(
"%w: candidate has no peer username", ErrSlskdTransferFailed,
)
}
wanted := make([]map[string]any, 0, len(c.Files))
for _, f := range c.Files {
wanted = append(wanted, map[string]any{
"filename": f.Path,
"size": f.Size,
})
}
if err := s.client.post(
ctx, "/api/v0/transfers/downloads/"+username, wanted, nil,
); err != nil {
return Result{}, err
}
if err := s.awaitTransfers(ctx, username, c, onProgress); err != nil {
return Result{}, err
}
return s.collect(c, dst)
}
// awaitTransfers polls until every requested file reaches a terminal
// state. Soulseek queues are measured in hours, so the only deadline
// is the caller's context.
func (s *slskd) awaitTransfers(
ctx context.Context,
username string,
c Candidate,
onProgress ProgressFunc,
) error {
wanted := make(map[string]bool, len(c.Files))
for _, f := range c.Files {
wanted[f.Path] = true
}
for {
select {
case <-ctx.Done():
return fmt.Errorf("%w: transfer cancelled", ErrSlskdTimeout)
case <-time.After(s.transferPoll):
}
transfers, err := s.transfersFor(ctx, username)
if err != nil {
// A blip talking to the daemon should not abandon a
// transfer that may be hours in.
s.logger.Debug("slskd transfer poll failed", "error", err)
continue
}
var (
done, failed int
current int64
)
for _, t := range transfers {
if !wanted[t.Filename] {
continue
}
current += t.BytesTransferred
finished, ok := t.done()
if !finished {
continue
}
if ok {
done++
} else {
failed++
}
}
if onProgress != nil {
onProgress(Progress{
Current: current,
Total: c.TotalSize,
Phase: fmt.Sprintf(
"Transferring from %s (%d/%d)", username, done, len(wanted),
),
})
}
if done+failed < len(wanted) {
continue
}
// Some files failing is normal — a peer goes offline mid-folder.
// Let the importer's completeness check decide whether what
// arrived is enough, rather than discarding it here.
if done == 0 {
return fmt.Errorf(
"%w: all %d files failed", ErrSlskdTransferFailed, failed,
)
}
return nil
}
}
// transfersFor returns a peer's current downloads. slskd nests
// transfers under directories, so this flattens them.
func (s *slskd) transfersFor(
ctx context.Context,
username string,
) ([]slskdTransfer, error) {
var raw struct {
Directories []struct {
Files []slskdTransfer `json:"files"`
} `json:"directories"`
}
if err := s.client.get(
ctx, "/api/v0/transfers/downloads/"+username, &raw,
); err != nil {
return nil, err
}
out := make([]slskdTransfer, 0, len(raw.Directories))
for _, d := range raw.Directories {
out = append(out, d.Files...)
}
return out, nil
}
// collect moves finished files out of slskd's download directory into
// staging. slskd lays them out as <downloads>/<folder>/<file>, so each
// wanted file is looked up by its base name under the folder slskd
// derived from the remote path.
func (s *slskd) collect(c Candidate, dst string) (Result, error) {
result := Result{Dir: dst, Files: make([]string, 0, len(c.Files))}
for _, f := range c.Files {
norm := strings.ReplaceAll(f.Path, `\`, "/")
folder := path.Base(path.Dir(norm))
base := path.Base(norm)
src := filepath.Join(s.downloadsPath, folder, base)
info, err := os.Stat(src)
if err != nil || info.Size() == 0 {
// Not every requested file arrives; that is expected and
// handled by completeness scoring downstream.
continue
}
target := filepath.Join(dst, base)
if err := movePath(src, target); err != nil {
return Result{}, fmt.Errorf("collect %s: %w", base, err)
}
result.Files = append(result.Files, target)
result.BytesTransferred += info.Size()
}
if len(result.Files) == 0 {
return Result{}, fmt.Errorf(
"%w: nothing found under %s",
ErrSlskdDownloadsPath, s.downloadsPath,
)
}
return result, nil
}
+567
View File
@@ -0,0 +1,567 @@
package download
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// slskd tests run against an httptest server shaped like the real API.
// No daemon, no Soulseek account, no network.
// slskdStub is a fake slskd daemon.
type slskdStub struct {
server *httptest.Server
mu sync.Mutex
// responses is what a search returns.
responses []slskdResponse
// transfers is what the downloads endpoint reports, in order; the
// last entry repeats.
transfers [][]slskdTransfer
pollCount int
// enqueued records what was requested for download.
enqueued []map[string]any
// unauthorized makes every call return 401.
unauthorized bool
}
func newSlskdStub(t *testing.T) *slskdStub {
t.Helper()
s := &slskdStub{}
mux := http.NewServeMux()
mux.HandleFunc("/api/v0/application", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
writeJSON(t, w, map[string]any{"version": "0.21.0"})
})
mux.HandleFunc("/api/v0/searches", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("/api/v0/searches/", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
if r.Method == http.MethodDelete {
w.WriteHeader(http.StatusNoContent)
return
}
s.mu.Lock()
responses := s.responses
s.mu.Unlock()
writeJSON(t, w, slskdSearch{
ID: "search-1",
IsComplete: true,
Responses: responses,
})
})
mux.HandleFunc("/api/v0/transfers/downloads/", func(w http.ResponseWriter, r *http.Request) {
if s.reject(w, r) {
return
}
if r.Method == http.MethodPost {
var body []map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode enqueue body: %v", err)
}
s.mu.Lock()
s.enqueued = body
s.mu.Unlock()
w.WriteHeader(http.StatusCreated)
return
}
s.mu.Lock()
idx := s.pollCount
if idx >= len(s.transfers) {
idx = len(s.transfers) - 1
} else {
s.pollCount++
}
var batch []slskdTransfer
if idx >= 0 && len(s.transfers) > 0 {
batch = s.transfers[idx]
}
s.mu.Unlock()
writeJSON(t, w, map[string]any{
"directories": []map[string]any{{"files": batch}},
})
})
s.server = httptest.NewServer(mux)
t.Cleanup(s.server.Close)
return s
}
// reject enforces API-key auth like the real daemon.
func (s *slskdStub) reject(w http.ResponseWriter, r *http.Request) bool {
s.mu.Lock()
unauthorized := s.unauthorized
s.mu.Unlock()
if unauthorized || r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return true
}
return false
}
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Errorf("encode response: %v", err)
}
}
// newStubSlskd builds the provider pointed at the stub, with a real
// temp directory standing in for slskd's downloads folder.
func newStubSlskd(t *testing.T, stub *slskdStub) (*slskd, string) {
t.Helper()
downloads := t.TempDir()
p, err := newSlskd(
Config{
ID: 1,
Kind: KindSlskd,
Name: "slskd",
Enabled: true,
Settings: map[string]string{
"url": stub.server.URL,
"downloadsPath": downloads,
},
},
func(string) (string, error) { return "test-key", nil },
slogDiscard(),
)
if err != nil {
t.Fatalf("newSlskd: %v", err)
}
s, ok := p.(*slskd)
if !ok {
t.Fatalf("provider is %T, want *slskd", p)
}
// Real intervals are tuned for Soulseek's pace; tests only care
// about the state machine, so run it at full speed.
s.searchPoll = time.Millisecond
s.searchWait = 200 * time.Millisecond
s.transferPoll = time.Millisecond
return s, downloads
}
func TestSlskdCheck(t *testing.T) {
t.Parallel()
stub := newSlskdStub(t)
s, _ := newStubSlskd(t, stub)
if err := s.Check(context.Background()); err != nil {
t.Errorf("Check: %v", err)
}
}
func TestSlskdCheckRejectsBadKey(t *testing.T) {
t.Parallel()
stub := newSlskdStub(t)
stub.unauthorized = true
s, _ := newStubSlskd(t, stub)
if err := s.Check(context.Background()); !errors.Is(err, ErrSlskdAuth) {
t.Errorf("error = %v, want ErrSlskdAuth", err)
}
}
// A downloads folder that is not readable from this machine is the
// classic slskd-on-a-NAS misconfiguration, and must surface at
// configuration time rather than after a long transfer.
func TestSlskdCheckRejectsUnreadableDownloadsPath(t *testing.T) {
t.Parallel()
stub := newSlskdStub(t)
p, err := newSlskd(
Config{
ID: 1,
Settings: map[string]string{
"url": stub.server.URL,
"downloadsPath": "/definitely/not/a/real/path",
},
},
func(string) (string, error) { return "test-key", nil },
slogDiscard(),
)
if err != nil {
t.Fatalf("newSlskd: %v", err)
}
if err := p.Check(context.Background()); !errors.Is(
err, ErrSlskdDownloadsPath,
) {
t.Errorf("error = %v, want ErrSlskdDownloadsPath", err)
}
}
// Soulseek has no album concept, so candidates are built by grouping a
// peer's files into the folders they live in.
func TestSlskdGroupsResultsByPeerAndFolder(t *testing.T) {
t.Parallel()
stub := newSlskdStub(t)
stub.responses = []slskdResponse{
{
Username: "peer-one",
HasFreeUploadSlot: true,
QueueLength: 0,
UploadSpeed: 2_000_000,
Files: []slskdFile{
{Filename: `@@x\Music\OK Computer\01 Airbag.flac`, Size: 30_000_000},
{Filename: `@@x\Music\OK Computer\02 Paranoid Android.flac`, Size: 40_000_000},
{Filename: `@@x\Music\Kid A\01 Everything.flac`, Size: 30_000_000},
{Filename: `@@x\Music\Kid A\02 Kid A.flac`, Size: 30_000_000},
},
},
{
Username: "peer-two",
HasFreeUploadSlot: false,
QueueLength: 40,
Files: []slskdFile{
{
Filename: `\share\OK Computer [320]\01 - Airbag.mp3`,
Size: 8_000_000,
BitRate: 320,
},
{
Filename: `\share\OK Computer [320]\02 - Paranoid Android.mp3`,
Size: 9_000_000,
BitRate: 320,
},
},
},
}
s, _ := newStubSlskd(t, stub)
got, err := s.Search(context.Background(), Request{
Artist: "Radiohead",
Album: "OK Computer",
})
if err != nil {
t.Fatalf("Search: %v", err)
}
// Two folders from peer-one, one from peer-two.
if len(got) != 3 {
t.Fatalf("got %d candidates, want 3", len(got))
}
byOrigin := map[string]int{}
for _, c := range got {
byOrigin[c.Origin]++
}
if byOrigin["peer-one"] != 2 {
t.Errorf("peer-one folders = %d, want 2", byOrigin["peer-one"])
}
if byOrigin["peer-two"] != 1 {
t.Errorf("peer-two folders = %d, want 1", byOrigin["peer-two"])
}
}
// A busy peer behind a long queue is a worse bet than a free one, no
// matter how good the files look.
func TestSlskdPeerHealthReflectsAvailability(t *testing.T) {
t.Parallel()
free := peerHealth(slskdResponse{
HasFreeUploadSlot: true,
QueueLength: 0,
UploadSpeed: 2_000_000,
})
busy := peerHealth(slskdResponse{
HasFreeUploadSlot: false,
QueueLength: 40,
})
if free <= busy {
t.Errorf("free peer health %f should exceed busy peer %f", free, busy)
}
if free > 1 || busy < 0 {
t.Errorf("health out of range: free=%f busy=%f", free, busy)
}
}
// Folders with almost nothing in them are Soulseek noise, not albums.
func TestSlskdSkipsTinyFolders(t *testing.T) {
t.Parallel()
stub := newSlskdStub(t)
stub.responses = []slskdResponse{{
Username: "peer",
Files: []slskdFile{
{Filename: `\share\Random\one.mp3`, Size: 5_000_000},
},
}}
s, _ := newStubSlskd(t, stub)
got, err := s.Search(context.Background(), Request{Query: "x"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 0 {
t.Errorf("got %d candidates, want 0 for a single-file folder", len(got))
}
}
func TestSlskdGrabCollectsFromDownloadsFolder(t *testing.T) {
t.Parallel()
stub := newSlskdStub(t)
stub.transfers = [][]slskdTransfer{
{
{
Filename: `\share\OK Computer\01 Airbag.flac`,
State: "InProgress",
BytesTransferred: 100,
},
{
Filename: `\share\OK Computer\02 Paranoid Android.flac`,
State: "InProgress",
},
},
{
{
Filename: `\share\OK Computer\01 Airbag.flac`,
State: "Completed, Succeeded",
BytesTransferred: 500,
},
{
Filename: `\share\OK Computer\02 Paranoid Android.flac`,
State: "Completed, Succeeded",
BytesTransferred: 500,
},
},
}
s, downloads := newStubSlskd(t, stub)
// slskd writes into <downloads>/<folder>/<file>.
folder := filepath.Join(downloads, "OK Computer")
if err := os.MkdirAll(folder, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
for _, name := range []string{
"01 Airbag.flac",
"02 Paranoid Android.flac",
} {
if err := os.WriteFile(
filepath.Join(folder, name), []byte("audio"), 0o600,
); err != nil {
t.Fatalf("write: %v", err)
}
}
c := Candidate{
ID: "slskd:peer:OK Computer",
Protocol: ProtocolDirect,
Files: []CandidateFile{
{Path: `\share\OK Computer\01 Airbag.flac`, Size: 500, IsAudio: true},
{Path: `\share\OK Computer\02 Paranoid Android.flac`, Size: 500, IsAudio: true},
},
TotalSize: 1000,
Payload: map[string]string{"username": "peer"},
}
dst := t.TempDir()
got, err := s.Grab(context.Background(), c, dst, nil)
if err != nil {
t.Fatalf("Grab: %v", err)
}
if len(got.Files) != 2 {
t.Fatalf("collected %d files, want 2", len(got.Files))
}
for _, f := range got.Files {
if !strings.HasPrefix(f, dst) {
t.Errorf("file %s is not inside the staging dir %s", f, dst)
}
if _, err := os.Stat(f); err != nil {
t.Errorf("collected file missing: %v", err)
}
}
// The enqueue request named the files the candidate listed.
stub.mu.Lock()
enqueued := len(stub.enqueued)
stub.mu.Unlock()
if enqueued != 2 {
t.Errorf("enqueued %d files, want 2", enqueued)
}
}
// A peer that drops mid-folder is normal; partial results go forward
// and the importer's completeness check decides.
func TestSlskdGrabToleratesPartialFailure(t *testing.T) {
t.Parallel()
stub := newSlskdStub(t)
stub.transfers = [][]slskdTransfer{{
{
Filename: `\s\Album\01 A.flac`,
State: "Completed, Succeeded",
BytesTransferred: 500,
},
{Filename: `\s\Album\02 B.flac`, State: "Completed, Errored"},
}}
s, downloads := newStubSlskd(t, stub)
folder := filepath.Join(downloads, "Album")
if err := os.MkdirAll(folder, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(
filepath.Join(folder, "01 A.flac"), []byte("audio"), 0o600,
); err != nil {
t.Fatalf("write: %v", err)
}
c := Candidate{
Files: []CandidateFile{
{Path: `\s\Album\01 A.flac`, Size: 500, IsAudio: true},
{Path: `\s\Album\02 B.flac`, Size: 500, IsAudio: true},
},
Payload: map[string]string{"username": "peer"},
}
got, err := s.Grab(context.Background(), c, t.TempDir(), nil)
if err != nil {
t.Fatalf("Grab: %v", err)
}
if len(got.Files) != 1 {
t.Errorf("collected %d files, want the 1 that succeeded", len(got.Files))
}
}
func TestSlskdGrabFailsWhenEverythingFails(t *testing.T) {
t.Parallel()
stub := newSlskdStub(t)
stub.transfers = [][]slskdTransfer{{
{Filename: `\s\Album\01 A.flac`, State: "Completed, Errored"},
}}
s, _ := newStubSlskd(t, stub)
c := Candidate{
Files: []CandidateFile{{Path: `\s\Album\01 A.flac`, IsAudio: true}},
Payload: map[string]string{"username": "peer"},
}
_, err := s.Grab(context.Background(), c, t.TempDir(), nil)
if !errors.Is(err, ErrSlskdTransferFailed) {
t.Errorf("error = %v, want ErrSlskdTransferFailed", err)
}
}
func TestSlskdRequiresConfiguration(t *testing.T) {
t.Parallel()
tests := []struct {
name string
settings map[string]string
secrets SecretLookup
}{
{
name: "no url",
settings: map[string]string{"downloadsPath": "/tmp"},
secrets: func(string) (string, error) { return "k", nil },
},
{
name: "no downloads path",
settings: map[string]string{"url": "http://localhost:5030"},
secrets: func(string) (string, error) { return "k", nil },
},
{
name: "no api key",
settings: map[string]string{
"url": "http://localhost:5030", "downloadsPath": "/tmp",
},
secrets: func(string) (string, error) {
return "", ErrSecretNotFound
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := newSlskd(
Config{Settings: tt.settings}, tt.secrets, slogDiscard(),
)
if !errors.Is(err, ErrNotConfigured) {
t.Errorf("error = %v, want ErrNotConfigured", err)
}
})
}
}
+532
View File
@@ -0,0 +1,532 @@
package download
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
// yt-dlp is the local-subprocess shape: no server for the user to run,
// no credentials, but everything comes back as text from a binary whose
// output format is not a stable contract. The defences are: pin a
// minimum version and check it before use, ask for JSON rather than
// parsing human output, and never build a shell command — every
// invocation is an argv slice, so a track title containing `; rm -rf`
// is an argument and not a command.
// yt-dlp provider errors.
var (
// ErrYtDlpMissing means the binary was not found.
ErrYtDlpMissing = errors.New("yt-dlp was not found")
// ErrYtDlpTooOld means the installed version predates the output
// format this adapter relies on.
ErrYtDlpTooOld = errors.New("yt-dlp is too old")
// ErrYtDlpFailed wraps a non-zero exit.
ErrYtDlpFailed = errors.New("yt-dlp failed")
// ErrUnsafeURL rejects a URL that is not plain http(s). yt-dlp
// accepts things like file:// that must never come from a search
// result.
ErrUnsafeURL = errors.New("refusing to fetch a non-http URL")
)
// minYtDlpVersion is the oldest release known to support the
// --progress-template and --dump-json output this adapter parses.
// yt-dlp versions are date-stamped, so this compares lexically.
const minYtDlpVersion = "2023.01.01"
// ytSearchCount is how many results to ask for per query.
const ytSearchCount = 5
// ytTrackConcurrency bounds parallel per-track searches when assembling
// an album. YouTube throttles aggressively; three is fast enough to
// finish inside the search timeout without tripping it.
const ytTrackConcurrency = 3
func init() {
Register(
Descriptor{
Kind: KindYtDlp,
Name: "yt-dlp",
Summary: "Download audio from YouTube, SoundCloud, Bandcamp and other sites yt-dlp supports.",
Caps: Caps{
CanSearch: true,
CanTransport: true,
CanCancel: true,
ReportsSize: true,
},
Fields: []Field{
{
Key: "binary",
Label: "yt-dlp path",
Placeholder: "yt-dlp",
Help: "Leave blank to find yt-dlp on your PATH.",
Default: "yt-dlp",
},
{
Key: "audioFormat",
Label: "Audio format",
Help: "flac, mp3, opus, m4a, or 'best' to keep the source format.",
Default: "flac",
},
{
Key: "searchPrefix",
Label: "Search source",
Help: "ytsearch for YouTube, ytmsearch for YouTube Music. " +
"Defaults to ytsearch.",
Default: "ytsearch",
},
},
},
newYtDlp,
)
}
// ytDlp is the yt-dlp provider.
type ytDlp struct {
info ProviderInfo
logger *slog.Logger
binary string
audioFormat string
searchPrefix string
}
// newYtDlp builds a yt-dlp provider from config.
func newYtDlp(
cfg Config,
_ SecretLookup,
logger *slog.Logger,
) (Provider, error) {
binary := cfg.Setting("binary", "yt-dlp")
resolved, err := exec.LookPath(binary)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrYtDlpMissing, binary)
}
return &ytDlp{
info: ProviderInfo{
ID: cfg.ID,
Kind: KindYtDlp,
Name: cfg.Name,
Enabled: cfg.Enabled,
Priority: cfg.Priority,
Caps: Caps{
CanSearch: true,
CanTransport: true,
CanCancel: true,
ReportsSize: true,
},
},
logger: logger.With("provider", "yt-dlp"),
binary: resolved,
audioFormat: cfg.Setting("audioFormat", "flac"),
searchPrefix: cfg.Setting("searchPrefix", "ytsearch"),
}, nil
}
// Info returns the provider's identity.
func (y *ytDlp) Info() ProviderInfo {
return y.info
}
// Close is a no-op; each invocation is its own process.
func (y *ytDlp) Close() error {
return nil
}
// Check verifies the binary runs and is new enough. Version drift is
// yt-dlp's defining trait, so this is the difference between a clear
// error at configuration time and garbled output at download time.
func (y *ytDlp) Check(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, y.binary, "--version").Output()
if err != nil {
return fmt.Errorf("%w: %w", ErrYtDlpFailed, err)
}
version := strings.TrimSpace(string(out))
if version < minYtDlpVersion {
return fmt.Errorf(
"%w: found %s, need %s or newer",
ErrYtDlpTooOld, version, minYtDlpVersion,
)
}
return nil
}
// ytEntry is the subset of yt-dlp's --dump-json output this adapter
// uses. yt-dlp emits far more; naming only what is needed means a
// field being added or reordered upstream cannot break parsing.
type ytEntry struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
WebURL string `json:"webpage_url"`
Uploader string `json:"uploader"`
Duration float64 `json:"duration"`
Filesize int64 `json:"filesize_approx"`
}
// link returns the entry's best usable URL.
func (e ytEntry) link() string {
if e.WebURL != "" {
return e.WebURL
}
return e.URL
}
// Search assembles candidates. With an expected tracklist it searches
// per track and offers the assembled album as one candidate, which is
// how yt-dlp is actually useful for albums — a single "full album"
// video is one file and cannot be imported as tracks. Without a
// tracklist it falls back to returning the top individual results.
func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
if len(req.Expected) > 0 {
c, err := y.assembleAlbum(ctx, req)
if err != nil {
return nil, err
}
if len(c.Files) > 0 {
return []Candidate{c}, nil
}
}
entries, err := y.search(ctx, req.SearchText(), ytSearchCount)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(entries))
for _, e := range entries {
link := e.link()
if link == "" {
continue
}
name := sanitizePathPart(e.Title) + "." + y.extension()
out = append(out, Candidate{
ID: "ytdlp:" + e.ID,
Kind: KindYtDlp,
Protocol: ProtocolDirect,
Title: e.Title,
Artist: e.Uploader,
Origin: "yt-dlp",
Files: []CandidateFile{{
Path: name,
Size: e.Filesize,
IsAudio: true,
}},
TotalSize: e.Filesize,
// yt-dlp results are always available; there is no peer to
// be offline, so health carries no information here.
Health: 0.75,
Payload: map[string]string{name: link},
})
}
return out, nil
}
// assembleAlbum searches once per expected track and builds a single
// multi-file candidate. Tracks that find no result are left out; the
// completeness score then reflects the gap, and the importer's
// threshold decides whether what arrived is enough.
func (y *ytDlp) assembleAlbum(
ctx context.Context,
req Request,
) (Candidate, error) {
type hit struct {
index int
entry ytEntry
}
var (
mu sync.Mutex
hits []hit
)
group, gctx := errgroup.WithContext(ctx)
group.SetLimit(ytTrackConcurrency)
for i, track := range req.Expected {
group.Go(func() error {
query := strings.TrimSpace(
req.Artist + " " + track.Title,
)
entries, err := y.search(gctx, query, 1)
if err != nil || len(entries) == 0 {
// One missing track is not a failed search. Recording
// nothing lets completeness scoring speak for it.
return nil //nolint:nilerr // partial results are expected
}
mu.Lock()
hits = append(hits, hit{index: i, entry: entries[0]})
mu.Unlock()
return nil
})
}
if err := group.Wait(); err != nil {
return Candidate{}, fmt.Errorf("assemble album: %w", err)
}
c := Candidate{
ID: "ytdlp:album:" + req.ID,
Kind: KindYtDlp,
Protocol: ProtocolDirect,
Title: req.Album,
Artist: req.Artist,
Origin: "yt-dlp (assembled per track)",
Health: 0.75,
Payload: map[string]string{},
Files: make([]CandidateFile, 0, len(hits)),
}
for _, h := range hits {
track := req.Expected[h.index]
link := h.entry.link()
if link == "" {
continue
}
// Name the staged file after the expected track, not the video
// title: the video is called whatever the uploader felt like,
// and the import step matches on filename.
name := trackToken(track.Position) + " - " +
sanitizePathPart(track.Title) + "." + y.extension()
c.Files = append(c.Files, CandidateFile{
Path: name,
Size: h.entry.Filesize,
IsAudio: true,
MatchedTo: track.Position,
})
c.TotalSize += h.entry.Filesize
c.Payload[name] = link
}
return c, nil
}
// search runs one yt-dlp search and decodes its JSON lines.
func (y *ytDlp) search(
ctx context.Context,
query string,
count int,
) ([]ytEntry, error) {
if strings.TrimSpace(query) == "" {
return nil, nil
}
// The search term is one argv element; yt-dlp parses the
// "ytsearchN:" prefix itself. No shell is involved at any point.
target := y.searchPrefix + strconv.Itoa(count) + ":" + query
args := []string{
"--dump-json",
"--flat-playlist",
"--no-warnings",
"--no-playlist",
"--ignore-config",
"--socket-timeout", "15",
target,
}
cmd := exec.CommandContext(ctx, y.binary, args...)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("%w: search: %w", ErrYtDlpFailed, err)
}
return decodeYtEntries(strings.NewReader(string(out))), nil
}
// decodeYtEntries reads newline-delimited JSON, skipping lines that do
// not parse. yt-dlp mixes warnings into stdout in some versions, and
// one bad line must not discard the rest of the results.
func decodeYtEntries(r io.Reader) []ytEntry {
var out []ytEntry
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "{") {
continue
}
var e ytEntry
if err := json.Unmarshal([]byte(line), &e); err != nil {
continue
}
if e.ID == "" {
continue
}
out = append(out, e)
}
return out
}
// Grab downloads each of the candidate's files into dst.
func (y *ytDlp) Grab(
ctx context.Context,
c Candidate,
dst string,
onProgress ProgressFunc,
) (Result, error) {
result := Result{Dir: dst, Files: make([]string, 0, len(c.Files))}
for i, f := range c.Files {
link, ok := c.Payload[f.Path]
if !ok {
continue
}
if err := validateHTTPURL(link); err != nil {
return Result{}, err
}
if onProgress != nil {
onProgress(Progress{
Current: int64(i),
Total: int64(len(c.Files)),
Phase: fmt.Sprintf(
"Downloading %d of %d", i+1, len(c.Files),
),
})
}
path, err := y.fetchOne(ctx, link, dst, f.Path)
if err != nil {
return Result{}, err
}
result.Files = append(result.Files, path)
}
return result, nil
}
// fetchOne downloads a single URL to a known filename inside dst.
func (y *ytDlp) fetchOne(
ctx context.Context,
link, dst, name string,
) (string, error) {
// Strip the extension from the output template: yt-dlp appends the
// real one after extraction, and forcing it here produces
// double-extensioned files.
stem := strings.TrimSuffix(name, filepath.Ext(name))
template := filepath.Join(dst, stem) + ".%(ext)s"
args := []string{
"--extract-audio",
"--no-playlist",
"--no-warnings",
"--ignore-config",
"--newline",
"--no-part",
"--socket-timeout", "30",
"--output", template,
}
if y.audioFormat != "" && y.audioFormat != "best" {
args = append(args, "--audio-format", y.audioFormat)
}
args = append(args, "--", link)
cmd := exec.CommandContext(ctx, y.binary, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf(
"%w: download: %w: %s",
ErrYtDlpFailed, err, lastLine(string(out)),
)
}
// The extracted extension is whatever yt-dlp produced, so find the
// file by stem rather than assuming.
matches, err := filepath.Glob(filepath.Join(dst, stem) + ".*")
if err != nil || len(matches) == 0 {
return "", fmt.Errorf(
"%w: no output file for %s", ErrYtDlpFailed, stem,
)
}
return matches[0], nil
}
// extension returns the file extension downloads will have.
func (y *ytDlp) extension() string {
if y.audioFormat == "" || y.audioFormat == "best" {
return "opus"
}
return y.audioFormat
}
// validateHTTPURL rejects anything that is not plain http(s). yt-dlp
// happily accepts file:// and other schemes, and a search result is
// untrusted input.
func validateHTTPURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("%w: %s", ErrUnsafeURL, raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%w: %s", ErrUnsafeURL, raw)
}
return nil
}
// lastLine returns the final non-empty line of output, which is where
// yt-dlp puts its error message.
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return line
}
}
return ""
}
+382
View File
@@ -0,0 +1,382 @@
package download
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// yt-dlp tests drive a stub shell script rather than the real binary:
// the adapter's contract is "what argv do we build and what do we do
// with the output", and a stub tests exactly that without a network,
// a YouTube account, or a 40MB dependency.
// stubYtDlp writes an executable script that echoes the given stdout
// and returns it as a provider config binary path.
func stubYtDlp(t *testing.T, script string) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("stub binary test uses a shell script")
}
path := filepath.Join(t.TempDir(), "yt-dlp")
if err := os.WriteFile(
path, []byte("#!/bin/sh\n"+script), 0o700,
); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// newStubYtDlp builds the provider over a stub binary.
func newStubYtDlp(t *testing.T, script string) *ytDlp {
t.Helper()
p, err := newYtDlp(
Config{
ID: 1,
Kind: KindYtDlp,
Name: "yt-dlp",
Enabled: true,
Priority: 50,
Settings: map[string]string{
"binary": stubYtDlp(t, script),
"audioFormat": "flac",
},
},
nil,
slogDiscard(),
)
if err != nil {
t.Fatalf("newYtDlp: %v", err)
}
y, ok := p.(*ytDlp)
if !ok {
t.Fatalf("provider is %T, want *ytDlp", p)
}
return y
}
func TestYtDlpCheckVersion(t *testing.T) {
t.Parallel()
t.Run("recent version passes", func(t *testing.T) {
t.Parallel()
y := newStubYtDlp(t, `echo "2024.08.06"`)
if err := y.Check(context.Background()); err != nil {
t.Errorf("Check: %v", err)
}
})
t.Run("old version is rejected", func(t *testing.T) {
t.Parallel()
y := newStubYtDlp(t, `echo "2021.01.01"`)
if err := y.Check(context.Background()); !errors.Is(
err, ErrYtDlpTooOld,
) {
t.Errorf("error = %v, want ErrYtDlpTooOld", err)
}
})
t.Run("non-zero exit is reported", func(t *testing.T) {
t.Parallel()
y := newStubYtDlp(t, `exit 1`)
if err := y.Check(context.Background()); !errors.Is(
err, ErrYtDlpFailed,
) {
t.Errorf("error = %v, want ErrYtDlpFailed", err)
}
})
}
func TestYtDlpMissingBinary(t *testing.T) {
t.Parallel()
_, err := newYtDlp(
Config{Settings: map[string]string{
"binary": "definitely-not-a-real-binary-xyzzy",
}},
nil,
slogDiscard(),
)
if !errors.Is(err, ErrYtDlpMissing) {
t.Errorf("error = %v, want ErrYtDlpMissing", err)
}
}
func TestYtDlpSearchParsesJSONLines(t *testing.T) {
t.Parallel()
y := newStubYtDlp(t, `
cat <<'EOF'
{"id":"aaa","title":"Airbag","webpage_url":"https://example.com/aaa","uploader":"Radiohead","duration":284,"filesize_approx":5000000}
{"id":"bbb","title":"Paranoid Android","webpage_url":"https://example.com/bbb","uploader":"Radiohead","duration":383}
EOF
`)
got, err := y.Search(context.Background(), Request{
Artist: "Radiohead",
Album: "OK Computer",
})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d candidates, want 2", len(got))
}
if got[0].Title != "Airbag" {
t.Errorf("title = %q, want Airbag", got[0].Title)
}
if got[0].Protocol != ProtocolDirect {
t.Errorf("protocol = %q, want direct", got[0].Protocol)
}
if len(got[0].Files) != 1 {
t.Fatalf("got %d files, want 1", len(got[0].Files))
}
link := got[0].Payload[got[0].Files[0].Path]
if link != "https://example.com/aaa" {
t.Errorf("payload url = %q, want the webpage_url", link)
}
}
// Warning text mixed into stdout must not discard valid results.
func TestYtDlpSearchSkipsUnparseableLines(t *testing.T) {
t.Parallel()
y := newStubYtDlp(t, `
cat <<'EOF'
WARNING: something happened
{"id":"aaa","title":"Airbag","webpage_url":"https://example.com/aaa"}
not json at all
{"id":"bbb","title":"Lucky","webpage_url":"https://example.com/bbb"}
EOF
`)
got, err := y.Search(context.Background(), Request{Query: "radiohead"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d candidates, want 2 valid ones", len(got))
}
}
// With a tracklist the adapter assembles an album from per-track
// searches, because a single "full album" video cannot be imported as
// separate tracks.
func TestYtDlpAssemblesAlbumFromTracklist(t *testing.T) {
t.Parallel()
y := newStubYtDlp(t, `
echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https://example.com/x","filesize_approx":4000000}'
`)
req := Request{
ID: "req-1",
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
Album: "OK Computer",
Expected: []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
{Position: 3, Title: "Subterranean Homesick Alien"},
},
}
got, err := y.Search(context.Background(), req)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d candidates, want 1 assembled album", len(got))
}
album := got[0]
if len(album.Files) != 3 {
t.Fatalf("got %d files, want 3", len(album.Files))
}
// Files are named after the expected tracks, not the video titles,
// because the import step matches on filename.
names := make([]string, 0, len(album.Files))
for _, f := range album.Files {
names = append(names, f.Path)
}
for _, want := range []string{
"01 - Airbag.flac",
"02 - Paranoid Android.flac",
"03 - Subterranean Homesick Alien.flac",
} {
if !containsString(names, want) {
t.Errorf("missing %q in %v", want, names)
}
}
if album.TotalSize != 12_000_000 {
t.Errorf("total size = %d, want 12000000", album.TotalSize)
}
}
// A track with no search result is left out, so completeness scoring
// can speak for the gap instead of the search failing outright.
func TestYtDlpAssembleToleratesMissingTracks(t *testing.T) {
t.Parallel()
y := newStubYtDlp(t, `
case "$*" in
*Airbag*) echo '{"id":"a","title":"Airbag","webpage_url":"https://example.com/a"}' ;;
*) exit 1 ;;
esac
`)
got, err := y.Search(context.Background(), Request{
ID: "req-1",
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
Album: "OK Computer",
Expected: []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
},
})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d candidates, want 1", len(got))
}
if len(got[0].Files) != 1 {
t.Errorf("got %d files, want just the one that was found", len(got[0].Files))
}
}
func TestYtDlpGrabWritesIntoStaging(t *testing.T) {
t.Parallel()
// The stub writes a file at whatever --output stem it is given,
// mimicking yt-dlp's post-extraction naming.
y := newStubYtDlp(t, `
out=""
while [ $# -gt 0 ]; do
case "$1" in
--output) out="$2"; shift 2 ;;
*) shift ;;
esac
done
target=$(printf '%s' "$out" | sed 's/%(ext)s/flac/')
printf 'audio' > "$target"
`)
dst := t.TempDir()
c := Candidate{
ID: "ytdlp:album:req-1",
Protocol: ProtocolDirect,
Files: []CandidateFile{
{Path: "01 - Airbag.flac", IsAudio: true},
},
Payload: map[string]string{
"01 - Airbag.flac": "https://example.com/a",
},
}
got, err := y.Grab(context.Background(), c, dst, nil)
if err != nil {
t.Fatalf("Grab: %v", err)
}
if len(got.Files) != 1 {
t.Fatalf("got %d files, want 1", len(got.Files))
}
if _, err := os.Stat(got.Files[0]); err != nil {
t.Errorf("downloaded file missing: %v", err)
}
if !strings.HasSuffix(got.Files[0], ".flac") {
t.Errorf("file = %s, want a .flac", got.Files[0])
}
}
// A search result is untrusted input, and yt-dlp accepts schemes that
// would read the local filesystem.
func TestYtDlpGrabRejectsNonHTTPURL(t *testing.T) {
t.Parallel()
y := newStubYtDlp(t, `exit 0`)
c := Candidate{
Files: []CandidateFile{{Path: "x.flac", IsAudio: true}},
Payload: map[string]string{"x.flac": "file:///etc/passwd"},
}
_, err := y.Grab(context.Background(), c, t.TempDir(), nil)
if !errors.Is(err, ErrUnsafeURL) {
t.Errorf("error = %v, want ErrUnsafeURL", err)
}
}
func TestValidateHTTPURL(t *testing.T) {
t.Parallel()
tests := []struct {
url string
wantErr bool
}{
{"https://example.com/a", false},
{"http://example.com/a", false},
{"file:///etc/passwd", true},
{"ftp://example.com/a", true},
{"javascript:alert(1)", true},
{"://nonsense", true},
}
for _, tt := range tests {
t.Run(tt.url, func(t *testing.T) {
t.Parallel()
err := validateHTTPURL(tt.url)
if (err != nil) != tt.wantErr {
t.Errorf("validateHTTPURL(%q) error = %v, wantErr %v",
tt.url, err, tt.wantErr)
}
})
}
}
func containsString(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}
+415
View File
@@ -0,0 +1,415 @@
package download
import (
"math"
"sort"
"strings"
"yellowjacket/backend/autotag"
)
// Ranking keeps two questions apart:
//
// match — is this the release the user asked for?
// quality — is it a good copy of it?
//
// They are reported separately because they fail differently and trade
// off against each other: a flawless FLAC of the wrong album is useless,
// a 128kbps rip of the right one is merely disappointing, and only the
// user knows which they will accept. A single blended number cannot be
// explained, and the review UI has to explain itself.
// Ranking weights. Match dominates, because a wrong album at any
// bitrate is a failed download.
const (
weightMatch = 0.72
weightQuality = 0.28
)
// Match sub-weights.
const (
weightTitleFit = 0.40
weightCompleteness = 0.30
weightAlbumFit = 0.18
weightArtistFit = 0.12
)
// Quality sub-weights.
const (
weightFormat = 0.45
weightBitrate = 0.25
weightHealth = 0.20
weightPriority = 0.10
)
// unanchoredCap bounds the match score of a free-text request. Without
// an MBID there is no tracklist to be right about, so a confident-
// looking score would be a lie — and auto-pick keys off this.
const unanchoredCap = 0.65
// Score fills a candidate's Match, Quality and Score fields.
func Score(req Request, c Candidate, priority int) Candidate {
c.Files = AnnotateFiles(c.Files)
audio := c.AudioFiles()
matched, titleFit := matchFiles(audio, req.Expected)
// Write the alignment back so the picker can show which file maps
// to which track.
c.Files = mergeMatched(c.Files, matched)
c.Match = scoreMatch(req, c, audio, titleFit)
c.Quality = scoreQuality(c, audio, priority)
c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall
return c
}
// scoreMatch answers whether this candidate is the requested release.
func scoreMatch(
req Request,
c Candidate,
audio []CandidateFile,
titleFit float64,
) MatchScore {
m := MatchScore{
Anchored: req.Anchored(),
TitleFit: titleFit,
}
m.Completeness = completeness(len(audio), len(req.Expected))
// The candidate's own title, and the folder its files sit in, are
// two independent guesses at the album name. Take the better one:
// providers vary in which is meaningful.
folder := ""
if len(audio) > 0 {
folder = ParsePath(audio[0].Path).Folder
}
m.AlbumFit = math.Max(
autotag.TitleSimilarity(req.Album, c.Title),
autotag.TitleSimilarity(req.Album, folder),
)
m.ArtistFit = artistFit(req.Artist, c)
// With no expected tracklist there is no title signal at all, so
// redistribute its weight onto the album/artist evidence rather
// than scoring every free-text result as half-wrong.
if len(req.Expected) == 0 {
m.Overall = 0.55*m.AlbumFit + 0.45*m.ArtistFit
} else {
m.Overall = weightTitleFit*m.TitleFit +
weightCompleteness*m.Completeness +
weightAlbumFit*m.AlbumFit +
weightArtistFit*m.ArtistFit
}
if !m.Anchored {
m.Overall = math.Min(m.Overall, unanchoredCap)
}
return m
}
// artistFit compares the requested artist against the candidate's
// artist field, its title, and the path of its first audio file, taking
// the best. Providers disagree about where the artist name lands.
func artistFit(want string, c Candidate) float64 {
if strings.TrimSpace(want) == "" {
return 0.5
}
best := autotag.TitleSimilarity(want, c.Artist)
if s := autotag.TitleSimilarity(want, c.Title); s > best {
best = s
}
// A path containing the artist name anywhere is weak but real
// evidence — most folders are "Artist - Album".
norm := autotag.Normalize(want)
if norm != "" {
for _, f := range c.Files {
if strings.Contains(autotag.Normalize(f.Path), norm) {
if best < 0.8 {
best = 0.8
}
break
}
}
}
return best
}
// completeness scores audio file count against the expected track
// count. Extra files are penalized far more gently than missing ones:
// a folder with bonus tracks or a stray intro is still the album, while
// a folder missing half the tracks is not.
func completeness(got, want int) float64 {
if want == 0 {
if got > 0 {
return 0.5
}
return 0
}
if got == 0 {
return 0
}
if got >= want {
extra := float64(got-want) / float64(want)
return math.Max(0.75, 1.0-0.25*extra)
}
return float64(got) / float64(want)
}
// scoreQuality answers whether this is a good copy.
func scoreQuality(
c Candidate,
audio []CandidateFile,
priority int,
) QualityScore {
q := QualityScore{
Health: clamp01(c.Health),
Priority: clamp01(float64(priority) / 100.0),
}
if len(audio) == 0 {
return q
}
// Format: score the worst file, not the average. A folder that is
// mostly FLAC with three MP3s transcoded in is a worse copy than
// its average suggests, and that is exactly what the user would
// want flagged.
worst := 1.0
first := audio[0].Format
for _, f := range audio {
if r := formatRank(f.Format); r < worst {
worst = r
}
if f.Format != first {
q.Mixed = true
}
}
q.FormatRank = worst
q.Bitrate = bitrateScore(audio)
q.Overall = weightFormat*q.FormatRank +
weightBitrate*q.Bitrate +
weightHealth*q.Health +
weightPriority*q.Priority
if q.Mixed {
q.Overall *= 0.9
}
return q
}
// formatRank scores a format on its own terms, in 0..1. Lossless
// formats top out; lossy formats sit below and are further separated by
// bitrate. Formats the player cannot decode are penalized but not
// zeroed — the user may be acquiring them deliberately.
func formatRank(f Format) float64 {
base := 0.0
switch f {
case FormatFLAC:
base = 1.0
case FormatALAC:
base = 0.95
case FormatWAV:
base = 0.85 // lossless, but untaggable and huge
case FormatMP3:
base = 0.6
case FormatAAC, FormatOpus:
base = 0.6
case FormatOGG:
base = 0.55
case FormatWMA:
base = 0.3
case FormatUnknown:
base = 0.2
default:
base = 0.2
}
if !f.Supported() && f != FormatUnknown {
base *= 0.8
}
return base
}
// bitrateScore maps the mean stated bitrate of lossy files onto 0..1.
// Lossless files score 1.0 and are excluded from the mean. Returns a
// neutral 0.5 when nothing states a bitrate, which is the common case
// for Soulseek results.
func bitrateScore(audio []CandidateFile) float64 {
var (
sum float64
count int
)
for _, f := range audio {
if f.Format.Lossless() {
sum += 1.0
count++
continue
}
if f.Bitrate == 0 {
continue
}
sum += lossyBitrateScore(f.Bitrate)
count++
}
if count == 0 {
return 0.5
}
return sum / float64(count)
}
// lossyBitrateScore maps kbps onto 0..1 with the knee where it belongs
// perceptually: the gap between 128 and 192 matters much more than the
// gap between 256 and 320.
func lossyBitrateScore(kbps int) float64 {
switch {
case kbps >= 320:
return 1.0
case kbps >= 256:
return 0.9
case kbps >= 224:
return 0.82
case kbps >= 192:
return 0.72
case kbps >= 160:
return 0.55
case kbps >= 128:
return 0.4
case kbps >= 96:
return 0.2
default:
return 0.1
}
}
// Rank scores every candidate and returns them best-first. Ties break
// on match, then on provider priority, then on file count, so the order
// is stable across runs rather than map-iteration dependent.
func Rank(
req Request,
candidates []Candidate,
priority func(providerID int64) int,
) []Candidate {
out := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
p := 50
if priority != nil {
p = priority(c.ProviderID)
}
out = append(out, Score(req, c, p))
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
if out[i].Match.Overall != out[j].Match.Overall {
return out[i].Match.Overall > out[j].Match.Overall
}
if out[i].Quality.Priority != out[j].Quality.Priority {
return out[i].Quality.Priority > out[j].Quality.Priority
}
return len(out[i].Files) > len(out[j].Files)
})
return out
}
// AutoPickable reports whether a ranked list has a clear enough winner
// to grab without asking. It demands an anchored request, a high match,
// decent quality, and daylight between first and second place — if two
// candidates are close, the choice is the user's.
func AutoPickable(req Request, ranked []Candidate) bool {
const (
minMatch = 0.85
minQuality = 0.5
minLead = 0.08
)
if !req.Anchored() || len(ranked) == 0 {
return false
}
// An anchor with no tracklist behind it is an anchor in name only:
// the match score then rests on album and artist text alone, which
// is exactly the evidence a wrong-album candidate also has. This
// matters most for the wanted list, where nobody is watching.
if len(req.Expected) == 0 {
return false
}
best := ranked[0]
if best.Match.Overall < minMatch || best.Quality.Overall < minQuality {
return false
}
if len(ranked) > 1 && best.Score-ranked[1].Score < minLead {
return false
}
return true
}
// mergeMatched copies MatchedTo assignments from the audio-only slice
// back onto the full file list.
func mergeMatched(all, matched []CandidateFile) []CandidateFile {
if len(matched) == 0 {
return all
}
byPath := make(map[string]int, len(matched))
for _, m := range matched {
byPath[m.Path] = m.MatchedTo
}
out := make([]CandidateFile, len(all))
copy(out, all)
for i := range out {
if pos, ok := byPath[out[i].Path]; ok {
out[i].MatchedTo = pos
}
}
return out
}
// clamp01 bounds a value to 0..1.
func clamp01(v float64) float64 {
return math.Max(0, math.Min(1, v))
}
+300
View File
@@ -0,0 +1,300 @@
package download
import "testing"
// okComputer is the reference request used across ranking tests.
func okComputer() Request {
return Request{
ReleaseMBID: "mbid-ok-computer",
Artist: "Radiohead",
Album: "OK Computer",
Expected: []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
{Position: 3, Title: "Subterranean Homesick Alien"},
{Position: 4, Title: "Exit Music (For a Film)"},
},
}
}
// candidateFor builds a candidate whose files follow "NN - Title.ext".
func candidateFor(id string, titles []string, ext string, size int64) Candidate {
files := make([]CandidateFile, 0, len(titles))
for i, tt := range titles {
files = append(files, CandidateFile{
Path: "Radiohead - OK Computer/" +
trackToken(i+1) + " - " + tt + ext,
Size: size,
})
}
return Candidate{
ID: id,
Protocol: ProtocolDirect,
Title: "Radiohead - OK Computer",
Artist: "Radiohead",
Files: files,
Health: 0.5,
}
}
func allTitles() []string {
return []string{
"Airbag",
"Paranoid Android",
"Subterranean Homesick Alien",
"Exit Music (For a Film)",
}
}
// The headline behaviour: a well-matched FLAC beats a well-matched
// 128kbps MP3, but a mismatched FLAC loses to both.
func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
t.Parallel()
req := okComputer()
flac := candidateFor("flac", allTitles(), ".flac", 30_000_000)
mp3 := candidateFor("mp3", allTitles(), ".mp3", 3_000_000)
for i := range mp3.Files {
mp3.Files[i].Bitrate = 128
}
ranked := Rank(req, []Candidate{mp3, flac}, nil)
if ranked[0].ID != "flac" {
t.Fatalf("winner = %s, want flac", ranked[0].ID)
}
if ranked[0].Match.Overall < 0.9 {
t.Errorf("flac match = %f, want high", ranked[0].Match.Overall)
}
if ranked[0].Quality.Overall <= ranked[1].Quality.Overall {
t.Errorf(
"flac quality %f should exceed mp3 %f",
ranked[0].Quality.Overall, ranked[1].Quality.Overall,
)
}
}
func TestRankMatchDominatesQuality(t *testing.T) {
t.Parallel()
req := okComputer()
// Right album, poor bitrate.
right := candidateFor("right", allTitles(), ".mp3", 2_000_000)
for i := range right.Files {
right.Files[i].Bitrate = 128
}
// Wrong album, pristine FLAC.
wrong := candidateFor("wrong", []string{
"Enter Sandman", "Sad But True", "Holier Than Thou", "The Unforgiven",
}, ".flac", 30_000_000)
wrong.Title = "Metallica - Metallica"
wrong.Artist = "Metallica"
for i := range wrong.Files {
wrong.Files[i].Path = "Metallica - Metallica/" +
trackToken(i+1) + " - x.flac"
}
ranked := Rank(req, []Candidate{wrong, right}, nil)
if ranked[0].ID != "right" {
t.Fatalf(
"winner = %s (score %f vs %f), want the correctly matched album",
ranked[0].ID, ranked[0].Score, ranked[1].Score,
)
}
}
func TestIncompleteCandidateScoresLower(t *testing.T) {
t.Parallel()
req := okComputer()
full := candidateFor("full", allTitles(), ".flac", 30_000_000)
partial := candidateFor("partial", allTitles()[:2], ".flac", 30_000_000)
ranked := Rank(req, []Candidate{partial, full}, nil)
if ranked[0].ID != "full" {
t.Fatalf("winner = %s, want full", ranked[0].ID)
}
if ranked[1].Match.Completeness >= ranked[0].Match.Completeness {
t.Errorf(
"partial completeness %f should be below full %f",
ranked[1].Match.Completeness, ranked[0].Match.Completeness,
)
}
}
func TestMixedFormatIsPenalized(t *testing.T) {
t.Parallel()
req := okComputer()
clean := candidateFor("clean", allTitles(), ".flac", 30_000_000)
mixed := candidateFor("mixed", allTitles(), ".flac", 30_000_000)
mixed.Files[2].Path = "Radiohead - OK Computer/03 - x.mp3"
mixed.Files[2].Format = FormatUnknown
ranked := Rank(req, []Candidate{mixed, clean}, nil)
var mixedScore QualityScore
for _, c := range ranked {
if c.ID == "mixed" {
mixedScore = c.Quality
}
}
if !mixedScore.Mixed {
t.Error("mixed-format candidate not flagged")
}
if ranked[0].ID != "clean" {
t.Errorf("winner = %s, want clean", ranked[0].ID)
}
}
// Without an MBID there is no tracklist to be right about, so the match
// score must not look confident regardless of how good the strings are.
func TestUnanchoredMatchIsCapped(t *testing.T) {
t.Parallel()
req := Request{Artist: "Radiohead", Album: "OK Computer"}
c := candidateFor("c", allTitles(), ".flac", 30_000_000)
scored := Score(req, c, 50)
if scored.Match.Anchored {
t.Error("free-text request reported as anchored")
}
if scored.Match.Overall > unanchoredCap {
t.Errorf(
"unanchored match = %f, want <= %f",
scored.Match.Overall, unanchoredCap,
)
}
}
func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
t.Parallel()
req := okComputer()
best := Score(req, candidateFor("a", allTitles(), ".flac", 30_000_000), 50)
t.Run("clear winner is auto-pickable", func(t *testing.T) {
t.Parallel()
weak := Score(
req,
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
50,
)
if !AutoPickable(req, []Candidate{best, weak}) {
t.Errorf(
"want auto-pickable: match %f quality %f lead %f",
best.Match.Overall, best.Quality.Overall, best.Score-weak.Score,
)
}
})
t.Run("two close candidates are not", func(t *testing.T) {
t.Parallel()
twin := best
twin.ID = "twin"
if AutoPickable(req, []Candidate{best, twin}) {
t.Error("identical candidates must not auto-pick")
}
})
t.Run("free text is never auto-pickable", func(t *testing.T) {
t.Parallel()
free := Request{Artist: "Radiohead", Album: "OK Computer"}
if AutoPickable(free, []Candidate{best}) {
t.Error("unanchored request must not auto-pick")
}
})
t.Run("empty list is not", func(t *testing.T) {
t.Parallel()
if AutoPickable(req, nil) {
t.Error("empty candidate list must not auto-pick")
}
})
}
func TestCompleteness(t *testing.T) {
t.Parallel()
tests := []struct {
name string
got int
want int
minScore float64
maxScore float64
}{
{"exact", 10, 10, 1.0, 1.0},
{"half missing", 5, 10, 0.49, 0.51},
{"one bonus track", 11, 10, 0.95, 1.0},
{"double", 20, 10, 0.74, 0.76},
{"nothing", 0, 10, 0, 0},
{"no expectation", 5, 0, 0.5, 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := completeness(tt.got, tt.want)
if got < tt.minScore || got > tt.maxScore {
t.Errorf(
"completeness(%d, %d) = %f, want in [%f, %f]",
tt.got, tt.want, got, tt.minScore, tt.maxScore,
)
}
})
}
}
func TestProviderPriorityBreaksTies(t *testing.T) {
t.Parallel()
req := okComputer()
a := candidateFor("a", allTitles(), ".flac", 30_000_000)
a.ProviderID = 1
b := candidateFor("b", allTitles(), ".flac", 30_000_000)
b.ProviderID = 2
priority := func(id int64) int {
if id == 2 {
return 90
}
return 10
}
ranked := Rank(req, []Candidate{a, b}, priority)
if ranked[0].ID != "b" {
t.Errorf("winner = %s, want b (higher provider priority)", ranked[0].ID)
}
}
+746
View File
@@ -0,0 +1,746 @@
package download
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
)
// The reconciler is the only thing that turns wants into downloads.
//
// It runs on a slow loop rather than reacting to events, because
// everything it cares about changes slowly: a release the user wants
// appears on a source days or weeks after they asked, an artist puts
// out an album once a year, and a library gains files by scan rather
// than by notification. A loop that wakes a few times a day is
// sufficient for all of it and costs nothing, where an event-driven
// design here would mean subscribing to three subsystems to learn the
// same facts later anyway.
//
// Each pass does four things, in this order and for this reason:
//
// 1. Expand artist subscriptions into per-album wants, so step 2 sees
// them this pass rather than next.
// 2. Retire wants the library already owns — including ones the user
// satisfied by other means, which is why ownership is checked
// rather than assumed from our own downloads.
// 3. Push the list to clients that keep their own (Lidarr), so the
// user's intent is expressed in both places.
// 4. Attempt a bounded batch of due wants.
//
// Nothing here fails a want. A want that cannot be found gets an
// attempt recorded and a longer backoff, and stays exactly as wanted as
// it was.
// CatalogPort is what the reconciler needs to know about the world of
// music, kept narrow so the download package does not depend on the
// explore package (and so tests can answer these four questions from a
// map).
type CatalogPort interface {
// ReleaseGroupsForArtist returns an artist's discography. An empty
// result is not an error: the explore index fetches discographies
// lazily, so the honest answer is often "not yet".
ReleaseGroupsForArtist(
ctx context.Context,
artistMBID string,
) ([]CatalogItem, error)
// Tracklist resolves a release group or release to the tracks it
// should contain. This is what makes a want's download safe to
// complete unattended, so a want with no tracklist is never
// auto-grabbed.
Tracklist(
ctx context.Context,
entity Entity,
mbid string,
) ([]ExpectedTrack, error)
// Owns reports whether the library already has the thing an MBID
// names.
Owns(ctx context.Context, entity Entity, mbid string) (bool, error)
// Describe fills in display text for a want the user added by MBID
// alone. Best-effort: an unknown MBID returns false and the want
// is still perfectly valid.
Describe(
ctx context.Context,
entity Entity,
mbid string,
) (CatalogItem, bool)
}
// CatalogItem is one thing the catalog knows about, in the download
// package's own terms.
type CatalogItem struct {
MBID string
Title string
Artist string
ArtistMBID string
// PrimaryType is the MusicBrainz release-group type ("Album",
// "Single", "EP").
PrimaryType string
// SecondaryTypes carries "Compilation", "Live", "Remix" and
// friends. Their presence is what an artist want's default scope
// filters out.
SecondaryTypes []string
// FirstReleaseDate is a MusicBrainz partial date: "1997",
// "1997-04", or "1997-04-22".
FirstReleaseDate string
InLibrary bool
}
// Reconciler defaults.
const (
// defaultReconcileInterval is how often the wanted list is worked.
// Four times a day is far more often than new music appears and far
// less often than any provider would object to.
defaultReconcileInterval = 6 * time.Hour
// startupDelay lets the app finish starting — library scan, explore
// index, provider construction — before the first pass. A wanted
// list worked against an index that has not loaded yet would record
// a pile of pointless attempts.
startupDelay = 3 * time.Minute
// maxExpandPerArtist bounds how many child wants one artist
// subscription creates in a single pass, so switching an artist to
// full-discography scope does not enqueue four hundred albums at
// once. The remainder is picked up next pass.
maxExpandPerArtist = 40
)
// Reconciler works the wanted list.
type Reconciler struct {
logger *slog.Logger
store *Store
manager *Manager
catalog CatalogPort
interval time.Duration
batch int
// now is injectable so tests can drive backoff without waiting.
now func() time.Time
// trigger is a nudge for an out-of-band pass, buffered to one
// because more than one pending "run now" is the same as one.
trigger chan struct{}
// onChange fires after any pass that altered the list, so the UI
// can refresh without polling.
onChange func()
stopOnce sync.Once
stop chan struct{}
// runMu serializes passes: two reconcilers racing would search for
// the same want twice.
runMu sync.Mutex
}
// NewReconciler builds a reconciler. catalog may be nil, in which case
// the wanted list still stores and lists wants but never acts on them —
// which is the right behaviour when the explore index is unavailable.
func NewReconciler(
logger *slog.Logger,
store *Store,
manager *Manager,
catalog CatalogPort,
) *Reconciler {
return &Reconciler{
logger: logger,
store: store,
manager: manager,
catalog: catalog,
interval: defaultReconcileInterval,
batch: defaultDueBatch,
now: time.Now,
trigger: make(chan struct{}, 1),
stop: make(chan struct{}),
}
}
// SetInterval overrides the pass interval.
func (r *Reconciler) SetInterval(d time.Duration) {
if d > 0 {
r.interval = d
}
}
// SetBatch overrides how many wants one pass attempts.
func (r *Reconciler) SetBatch(n int) {
if n > 0 {
r.batch = n
}
}
// SetOnChange registers a callback fired after a pass that changed the
// list.
func (r *Reconciler) SetOnChange(fn func()) {
r.onChange = fn
}
// Start runs the reconcile loop until ctx is done or Stop is called.
func (r *Reconciler) Start(ctx context.Context) {
go r.loop(ctx)
}
// Stop ends the loop.
func (r *Reconciler) Stop() {
r.stopOnce.Do(func() { close(r.stop) })
}
// Trigger asks for a pass as soon as possible without blocking the
// caller. Used when the user adds a want and expects something to
// happen.
func (r *Reconciler) Trigger() {
select {
case r.trigger <- struct{}{}:
default:
}
}
// loop is the reconcile timer.
func (r *Reconciler) loop(ctx context.Context) {
first := time.NewTimer(startupDelay)
defer first.Stop()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-r.stop:
return
case <-first.C:
case <-ticker.C:
case <-r.trigger:
}
if _, err := r.RunOnce(ctx); err != nil {
r.logger.Warn("wanted list reconcile failed", "error", err)
}
}
}
// Summary reports what a pass did, for logging and for the UI.
type Summary struct {
// Expanded is how many child wants artist subscriptions produced.
Expanded int `json:"expanded"`
// Satisfied is how many wants the library turned out to own.
Satisfied int `json:"satisfied"`
// Attempted is how many wants were searched for.
Attempted int `json:"attempted"`
// Started is how many of those found a clear enough winner to
// download unattended.
Started int `json:"started"`
// Synced is how many wants were pushed to an external list.
Synced int `json:"synced"`
}
// changed reports whether the pass altered anything worth refreshing
// the UI for.
func (s Summary) changed() bool {
return s.Expanded > 0 || s.Satisfied > 0 || s.Started > 0
}
// RunOnce works the wanted list once. It is safe to call directly, and
// the "search now" button does.
func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
r.runMu.Lock()
defer r.runMu.Unlock()
var summary Summary
if r.catalog == nil {
return summary, nil
}
expanded, err := r.expandArtists(ctx)
if err != nil {
return summary, err
}
summary.Expanded = expanded
satisfied, err := r.retireOwned(ctx)
if err != nil {
return summary, err
}
summary.Satisfied = satisfied
summary.Synced = r.syncExternalLists(ctx)
attempted, started, err := r.attemptDue(ctx)
if err != nil {
return summary, err
}
summary.Attempted = attempted
summary.Started = started
r.logger.Info(
"reconciled wanted list",
"expanded", summary.Expanded,
"satisfied", summary.Satisfied,
"attempted", summary.Attempted,
"started", summary.Started,
"synced", summary.Synced,
)
if summary.changed() && r.onChange != nil {
r.onChange()
}
return summary, nil
}
// ---------------------------------------------------------------------------
// Artist expansion
// ---------------------------------------------------------------------------
// expandArtists turns artist subscriptions into per-album wants.
//
// The expansion is idempotent: child wants are upserted on (mbid,
// library), so re-running adds only what is genuinely new. That is
// what makes an artist want a standing subscription rather than a
// one-time queue-filling operation — an album released next year gets
// picked up by the same code path that ran today.
func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
artists, err := r.store.ListArtistWants(ctx)
if err != nil {
return 0, err
}
created := 0
for _, artist := range artists {
n, err := r.expandArtist(ctx, artist)
if err != nil {
// One artist whose discography will not resolve must not
// stop the rest of the list.
r.logger.Warn(
"could not expand artist want",
"artist", artist.Label(),
"mbid", artist.MBID,
"error", err,
)
continue
}
created += n
}
return created, nil
}
// expandArtist expands one subscription.
func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error) {
groups, err := r.catalog.ReleaseGroupsForArtist(ctx, artist.MBID)
if err != nil {
return 0, err
}
created := 0
for _, rg := range groups {
if created >= maxExpandPerArtist {
break
}
if !wantsReleaseGroup(artist, rg) {
continue
}
// Existence is checked before inserting rather than relying on
// the upsert, because "how many albums are new this pass" is
// the number the UI reports and an upsert cannot tell an insert
// from a no-op. It also means a want the user pinned by hand
// is never quietly reparented under the artist.
if _, exists, err := r.store.FindWant(
ctx, rg.MBID, artist.LibraryID,
); err != nil || exists {
continue
}
credit := rg.Artist
if credit == "" {
credit = artist.Artist
}
if _, err := r.store.AddWant(ctx, Want{
MBID: rg.MBID,
Entity: EntityReleaseGroup,
LibraryID: artist.LibraryID,
Artist: credit,
Title: rg.Title,
ParentID: artist.ID,
}); err != nil {
r.logger.Warn(
"could not add derived want",
"release_group", rg.MBID,
"error", err,
)
continue
}
created++
}
return created, nil
}
// wantsReleaseGroup applies an artist subscription's filters to one
// release group.
func wantsReleaseGroup(artist Want, rg CatalogItem) bool {
if rg.MBID == "" || rg.InLibrary {
return false
}
if !artist.Secondary && len(rg.SecondaryTypes) > 0 {
return false
}
if artist.Scope == ScopeAll {
return true
}
// ScopeFuture: only releases the artist put out after the user
// subscribed. A partial MusicBrainz date is compared as a string,
// which sorts correctly for ISO dates and treats a bare year as the
// first of January — the conservative reading, since a release
// dated only "2026" against a subscription made in March 2026
// should not be assumed to be new.
return releaseDateAfter(rg.FirstReleaseDate, artist.CreatedAt)
}
// releaseDateAfter compares a MusicBrainz partial date against a
// timestamp. An unknown or unparseable date is treated as not-after,
// because a release with no date is almost always an old one.
func releaseDateAfter(date string, since time.Time) bool {
date = strings.TrimSpace(date)
if date == "" {
return false
}
// Pad a partial date to a full one so string comparison works:
// "1997" becomes "1997-01-01", "1997-04" becomes "1997-04-01".
switch len(date) {
case 4:
date += "-01-01"
case 7: //nolint:mnd // length of "YYYY-MM"
date += "-01"
}
return date > since.Format(time.DateOnly)
}
// ---------------------------------------------------------------------------
// Retiring what the library already has
// ---------------------------------------------------------------------------
// retireOwned satisfies wants the library turns out to own.
//
// Ownership is asked of the library rather than inferred from our own
// completed downloads on purpose: the user may have bought the album,
// ripped their CD, or copied it in from another machine, and a wanted
// list that keeps hunting for music already sitting on disk is worse
// than no wanted list at all.
func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
wants, err := r.store.ListWants(ctx)
if err != nil {
return 0, err
}
satisfied := 0
for _, w := range wants {
if w.State != WantStateWanted || w.Entity.Expands() {
continue
}
owned, err := r.catalog.Owns(ctx, w.Entity, w.MBID)
if err != nil {
r.logger.Debug(
"ownership check failed", "want", w.MBID, "error", err,
)
continue
}
if !owned {
continue
}
if err := r.store.SatisfyWant(ctx, w.ID); err != nil {
r.logger.Warn("could not satisfy want", "want", w.ID, "error", err)
continue
}
satisfied++
}
return satisfied, nil
}
// ---------------------------------------------------------------------------
// Attempting downloads
// ---------------------------------------------------------------------------
// attemptDue searches for a bounded batch of due wants and grabs the
// ones with a clear winner.
func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, err error) {
due, err := r.store.ListDueWants(ctx, r.batch)
if err != nil {
return 0, 0, err
}
for _, w := range due {
select {
case <-ctx.Done():
return attempted, started, nil
default:
}
attempted++
ok, reason := r.attempt(ctx, w)
if ok {
started++
continue
}
if err := r.store.RecordAttempt(
ctx, w.ID, w.Attempts, reason,
); err != nil {
r.logger.Warn(
"could not record want attempt", "want", w.ID, "error", err,
)
}
}
return attempted, started, nil
}
// tracklistFor resolves what a want should contain, which is the
// evidence an unattended download is checked against.
//
// A track want is its own tracklist: one entry, built from the title
// the want already carries. That single expected title is what lets
// filename matching score a track download at all — without it a
// request for one song would be scored as an album with no tracks and
// could never clear the auto-pick bar.
func (r *Reconciler) tracklistFor(
ctx context.Context,
w Want,
) ([]ExpectedTrack, error) {
if w.Entity != EntityRecording {
return r.catalog.Tracklist(ctx, w.Entity, w.MBID)
}
if w.Title == "" {
return nil, nil
}
return []ExpectedTrack{{
Position: 1,
Title: w.Title,
Artist: w.Artist,
}}, nil
}
// attempt tries one want. It returns false with a human-readable
// reason rather than an error, because none of the ways this does not
// work out are failures: no providers configured yet, nothing on any
// source, or nothing good enough to take without asking are all just
// "not today".
func (r *Reconciler) attempt(ctx context.Context, w Want) (bool, string) {
expected, err := r.tracklistFor(ctx, w)
if err != nil || len(expected) == 0 {
// Without a tracklist an unattended grab has nothing to verify
// itself against, so this want waits rather than guessing. The
// tracklist usually arrives on its own once the explore index
// fetches the release.
return false, "waiting for the tracklist to resolve"
}
req := w.ToRequest(newID())
req.Expected = expected
if req.Artist == "" || req.Album == "" {
if item, ok := r.catalog.Describe(ctx, w.Entity, w.MBID); ok {
if req.Artist == "" {
req.Artist = item.Artist
}
if req.Album == "" {
req.Album = item.Title
}
}
}
started, reason, err := r.manager.Attempt(ctx, req)
if err != nil {
if errors.Is(err, ErrNoProviders) {
return false, "no download clients are enabled"
}
if errors.Is(err, ErrNoCandidates) {
return false, "no source has it yet"
}
return false, err.Error()
}
return started, reason
}
// ---------------------------------------------------------------------------
// External list sync
// ---------------------------------------------------------------------------
// syncExternalLists pushes wants to providers that keep a persistent
// list of their own.
//
// The sync is one-directional by design. Two systems that both accept
// edits to the same list need conflict resolution, and the honest
// version of that here is "whichever the user touched last", which is
// not something we can observe. So this app's list is the source of
// truth and the external one is a projection of it — with the single
// exception of ImportExternal below, which the user runs deliberately.
func (r *Reconciler) syncExternalLists(ctx context.Context) int {
listers := r.manager.listers()
if len(listers) == 0 {
return 0
}
wants, err := r.store.ListWants(ctx)
if err != nil {
r.logger.Warn("could not list wants for sync", "error", err)
return 0
}
synced := 0
for _, w := range wants {
if w.State != WantStateWanted {
continue
}
external := w.ExternalIDs
if external == nil {
external = map[string]string{}
}
changed := false
for id, l := range listers {
key := strconv.FormatInt(id, 10)
if _, done := external[key]; done {
continue
}
externalID, err := l.PushWant(ctx, w)
if err != nil {
r.logger.Debug(
"could not push want to external list",
"want", w.MBID,
"provider", id,
"error", err,
)
continue
}
if externalID == "" {
continue
}
external[key] = externalID
changed = true
synced++
}
if !changed {
continue
}
if err := r.store.SetWantExternalIDs(ctx, w.ID, external); err != nil {
r.logger.Warn(
"could not record external want ids", "want", w.ID, "error", err,
)
}
}
return synced
}
// ImportExternal pulls an external manager's own list into the wanted
// list. This is the one place data flows the other way, and it is a
// deliberate user action ("import my monitored Lidarr artists") rather
// than part of the loop, because silently adopting whatever another
// system is monitoring is not something to do behind the user's back.
func (r *Reconciler) ImportExternal(
ctx context.Context,
providerID int64,
libraryID int64,
) (int, error) {
listers := r.manager.listers()
l, ok := listers[providerID]
if !ok {
return 0, fmt.Errorf(
"%w: provider %d keeps no list", ErrUnsupported, providerID,
)
}
external, err := l.ListWants(ctx)
if err != nil {
return 0, fmt.Errorf("list external wants: %w", err)
}
imported := 0
for _, w := range external {
w.LibraryID = libraryID
if _, err := r.store.AddWant(ctx, w); err != nil {
r.logger.Warn(
"could not import external want", "mbid", w.MBID, "error", err,
)
continue
}
imported++
}
if imported > 0 && r.onChange != nil {
r.onChange()
}
r.Trigger()
return imported, nil
}
+497
View File
@@ -0,0 +1,497 @@
package download
import (
"context"
"errors"
"sync"
"testing"
"time"
)
// fakeCatalog answers the reconciler's four questions from maps, so the
// wanted list can be tested without an explore index.
type fakeCatalog struct {
mu sync.Mutex
// discographies maps artist MBID to release groups.
discographies map[string][]CatalogItem
// tracklists maps an MBID to what it should contain.
tracklists map[string][]ExpectedTrack
// owned is the set of MBIDs the library has.
owned map[string]bool
// discographyErr is returned by ReleaseGroupsForArtist when set.
discographyErr error
}
func newFakeCatalog() *fakeCatalog {
return &fakeCatalog{
discographies: map[string][]CatalogItem{},
tracklists: map[string][]ExpectedTrack{},
owned: map[string]bool{},
}
}
func (c *fakeCatalog) ReleaseGroupsForArtist(
_ context.Context,
artistMBID string,
) ([]CatalogItem, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.discographyErr != nil {
return nil, c.discographyErr
}
return c.discographies[artistMBID], nil
}
func (c *fakeCatalog) Tracklist(
_ context.Context,
_ Entity,
mbid string,
) ([]ExpectedTrack, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.tracklists[mbid], nil
}
func (c *fakeCatalog) Owns(
_ context.Context,
_ Entity,
mbid string,
) (bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.owned[mbid], nil
}
func (c *fakeCatalog) Describe(
_ context.Context,
_ Entity,
_ string,
) (CatalogItem, bool) {
return CatalogItem{}, false
}
// reconcileFixture is a manager fixture plus a wanted list over it.
type reconcileFixture struct {
managerFixture
catalog *fakeCatalog
reconciler *Reconciler
}
func newReconcileFixture(t *testing.T) reconcileFixture {
t.Helper()
mf := newManagerFixture(t)
cat := newFakeCatalog()
r := NewReconciler(slogDiscard(), mf.store, mf.manager, cat)
return reconcileFixture{managerFixture: mf, catalog: cat, reconciler: r}
}
// An artist subscription becomes one want per album, and re-running
// adds nothing — which is what makes it a subscription rather than a
// one-time queue fill.
func TestExpandArtistIsIdempotent(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
f.catalog.discographies["artist-1"] = []CatalogItem{
{MBID: "rg-1", Title: "First", FirstReleaseDate: "2030-01-01"},
{MBID: "rg-2", Title: "Second", FirstReleaseDate: "2030-06-01"},
}
if _, err := f.store.AddWant(ctx, Want{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Artist: "Radiohead",
Scope: ScopeAll,
}); err != nil {
t.Fatalf("AddWant: %v", err)
}
first, err := f.reconciler.expandArtists(ctx)
if err != nil {
t.Fatalf("expandArtists: %v", err)
}
if first != 2 {
t.Fatalf("first pass created %d wants, want 2", first)
}
second, err := f.reconciler.expandArtists(ctx)
if err != nil {
t.Fatalf("expandArtists again: %v", err)
}
if second != 0 {
t.Errorf("second pass created %d wants, want 0", second)
}
// A new album appearing later is picked up by the same pass.
f.catalog.mu.Lock()
f.catalog.discographies["artist-1"] = append(
f.catalog.discographies["artist-1"],
CatalogItem{MBID: "rg-3", Title: "Third", FirstReleaseDate: "2031-01-01"},
)
f.catalog.mu.Unlock()
third, err := f.reconciler.expandArtists(ctx)
if err != nil {
t.Fatalf("expandArtists third: %v", err)
}
if third != 1 {
t.Errorf("third pass created %d wants, want 1", third)
}
}
// A default artist subscription takes new releases only, so subscribing
// does not silently queue a back catalogue.
func TestExpandArtistFutureScopeSkipsBackCatalogue(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
f.catalog.discographies["artist-1"] = []CatalogItem{
{MBID: "rg-old", Title: "Old", FirstReleaseDate: "1997-04-22"},
{MBID: "rg-new", Title: "New", FirstReleaseDate: "2099-01-01"},
}
if _, err := f.store.AddWant(ctx, Want{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Scope: ScopeFuture,
}); err != nil {
t.Fatalf("AddWant: %v", err)
}
if _, err := f.reconciler.expandArtists(ctx); err != nil {
t.Fatalf("expandArtists: %v", err)
}
wants, err := f.store.ListWants(ctx)
if err != nil {
t.Fatalf("ListWants: %v", err)
}
for _, w := range wants {
if w.MBID == "rg-old" {
t.Error("future scope queued a back-catalogue album")
}
}
if len(wants) != 2 {
t.Errorf("got %d wants (artist + new album), want 2", len(wants))
}
}
// One artist whose discography will not resolve must not stop the rest
// of the list being expanded.
func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
f.catalog.discographyErr = errors.New("index not ready") //nolint:err113 // test
if _, err := f.store.AddWant(ctx, Want{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
}); err != nil {
t.Fatalf("AddWant: %v", err)
}
created, err := f.reconciler.expandArtists(ctx)
if err != nil {
t.Fatalf("expandArtists returned an error for one bad artist: %v", err)
}
if created != 0 {
t.Errorf("created %d wants from a failing catalog, want 0", created)
}
}
// Something the library already owns is retired, however it got there.
func TestRetireOwnedSatisfiesWants(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
id, err := f.store.AddWant(ctx, Want{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
})
if err != nil {
t.Fatalf("AddWant: %v", err)
}
f.catalog.owned["rg-1"] = true
n, err := f.reconciler.retireOwned(ctx)
if err != nil {
t.Fatalf("retireOwned: %v", err)
}
if n != 1 {
t.Fatalf("retired %d wants, want 1", n)
}
w, err := f.store.GetWant(ctx, id)
if err != nil {
t.Fatalf("GetWant: %v", err)
}
if w.State != WantStateSatisfied {
t.Errorf("state = %q, want satisfied", w.State)
}
}
// The end-to-end case: a want with a clear winner downloads without
// anyone watching, and is satisfied when the files land.
func TestReconcileDownloadsAndSatisfies(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddWant(ctx, Want{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if summary.Attempted != 1 || summary.Started != 1 {
t.Fatalf(
"attempted=%d started=%d, want 1 and 1 (last error: see want)",
summary.Attempted, summary.Started,
)
}
waitFor(t, func() bool {
w, err := f.store.GetWant(ctx, id)
return err == nil && w.State == WantStateSatisfied
}, "want was never satisfied after its download completed")
if provider.GrabCalls != 1 {
t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
}
}
// Nothing good enough is not a failure. The want stays wanted, gains
// an attempt and a reason, and leaves no request row behind.
func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
// A provider that finds only an unrelated album: enough to return
// candidates, nowhere near enough to auto-pick.
provider := NewFakeProvider(1, "weak", Caps{CanSearch: true, CanTransport: true})
provider.Candidates = []Candidate{candidateFor(
"weak-1", []string{"Something Else Entirely"}, ".mp3", 3_000_000,
)}
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddWant(ctx, Want{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if summary.Started != 0 {
t.Fatalf("started %d downloads, want 0", summary.Started)
}
w, err := f.store.GetWant(ctx, id)
if err != nil {
t.Fatalf("GetWant: %v", err)
}
if w.State != WantStateWanted {
t.Errorf("state = %q, want it still wanted", w.State)
}
if w.Attempts != 1 {
t.Errorf("attempts = %d, want 1", w.Attempts)
}
if w.LastError == "" {
t.Error("no reason was recorded for the user")
}
if !w.NextTryAt.After(time.Now()) {
t.Errorf("next try at %v, want it in the future", w.NextTryAt)
}
// The whole point of Attempt over Start: an unsuccessful pass
// leaves no request row to clutter the downloads list.
requests, err := f.store.ListRequests(ctx, 50)
if err != nil {
t.Fatalf("ListRequests: %v", err)
}
if len(requests) != 0 {
t.Errorf("got %d request rows from a fruitless pass, want 0", len(requests))
}
}
// A want with no resolvable tracklist waits rather than guessing.
func TestReconcileWaitsWithoutTracklist(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
if _, err := f.store.AddWant(ctx, Want{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
}); err != nil {
t.Fatalf("AddWant: %v", err)
}
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if summary.Started != 0 {
t.Errorf("started %d downloads with no tracklist, want 0", summary.Started)
}
if provider.SearchCalls != 0 {
t.Errorf(
"searched %d times with no tracklist to verify against, want 0",
provider.SearchCalls,
)
}
}
// Artist subscriptions are never attempted as downloads: they expand.
func TestArtistWantsAreNeverDue(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
if _, err := f.store.AddWant(ctx, Want{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
}); err != nil {
t.Fatalf("AddWant: %v", err)
}
due, err := f.store.ListDueWants(ctx, 10)
if err != nil {
t.Fatalf("ListDueWants: %v", err)
}
if len(due) != 0 {
t.Errorf("got %d due wants, want 0 — artists expand, not download", len(due))
}
}
// A pass attempts at most its batch size, so a large list is worked
// through steadily rather than in one flood.
func TestReconcileRespectsBatchSize(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
f.reconciler.SetBatch(2)
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
if _, err := f.store.AddWant(ctx, Want{
MBID: mbid,
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "Album " + mbid,
}); err != nil {
t.Fatalf("AddWant: %v", err)
}
f.catalog.tracklists[mbid] = fourTrackRequest().Expected
}
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if summary.Attempted != 2 {
t.Errorf("attempted %d wants, want 2 (the batch size)", summary.Attempted)
}
}
// waitFor polls a condition, failing the test if it never holds. Used
// where the pipeline hands work to a goroutine.
func waitFor(t *testing.T, cond func() bool, msg string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal(msg)
}
+269
View File
@@ -0,0 +1,269 @@
package download
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"yellowjacket/backend/system"
)
// Provider credentials — slskd API keys, Lidarr tokens, qBittorrent
// passwords — must not sit in the TOML config, which is world-readable
// by default and gets pasted into bug reports.
//
// They live in a separate 0600 JSON file in the user data directory.
// That is deliberately not encryption: a key stored beside the data it
// unlocks protects nothing, and pretending otherwise is worse than
// being clear about it. What the file mode does buy is protection from
// other local users and from the config file being shared casually.
//
// An OS keyring backend (libsecret / Keychain / DPAPI) is the right
// long-term answer and the Store interface exists so it can be added
// without touching any provider.
// ErrSecretNotFound is returned when a named secret has never been set.
var ErrSecretNotFound = errors.New("secret not found")
// secretsFileName is the store's file inside the user data directory.
const secretsFileName = "download-secrets.json"
// secretsFileMode is owner read/write only.
const secretsFileMode = 0o600
// SecretStore holds provider credentials.
type SecretStore interface {
// Get returns the secret for a provider's named field.
Get(providerID int64, name string) (string, error)
// Set stores a secret. An empty value deletes it.
Set(providerID int64, name, value string) error
// DeleteProvider removes every secret belonging to a provider.
DeleteProvider(providerID int64) error
}
// fileSecretStore is the default SecretStore: a 0600 JSON file.
type fileSecretStore struct {
path string
mu sync.RWMutex
loaded bool
data map[string]string
}
// NewFileSecretStore returns a SecretStore backed by a 0600 file in the
// user data directory.
func NewFileSecretStore() (SecretStore, error) {
dir, err := system.GetUserDataDirPath()
if err != nil {
return nil, fmt.Errorf("resolve user data dir: %w", err)
}
return &fileSecretStore{
path: filepath.Join(dir, secretsFileName),
data: map[string]string{},
}, nil
}
// NewFileSecretStoreAt returns a store backed by an explicit path.
// Tests use this; production goes through NewFileSecretStore.
func NewFileSecretStoreAt(path string) SecretStore {
return &fileSecretStore{path: path, data: map[string]string{}}
}
// secretKey namespaces a secret by provider so two slskd instances do
// not share credentials.
func secretKey(providerID int64, name string) string {
return strconv.FormatInt(providerID, 10) + ":" + name
}
// load reads the file once. A missing file is an empty store, not an
// error — nothing has been configured yet.
func (s *fileSecretStore) load() error {
if s.loaded {
return nil
}
raw, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
s.data = map[string]string{}
s.loaded = true
return nil
}
return fmt.Errorf("read secrets file: %w", err)
}
data := map[string]string{}
if err := json.Unmarshal(raw, &data); err != nil {
return fmt.Errorf("parse secrets file: %w", err)
}
s.data = data
s.loaded = true
return nil
}
// save writes the file atomically with restrictive permissions.
func (s *fileSecretStore) save() error {
raw, err := json.Marshal(s.data)
if err != nil {
return fmt.Errorf("encode secrets: %w", err)
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("create secrets dir: %w", err)
}
// Write to a temp file in the same directory, chmod before the
// rename, so the secret is never briefly world-readable.
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, raw, secretsFileMode); err != nil {
return fmt.Errorf("write secrets file: %w", err)
}
if err := os.Chmod(tmp, secretsFileMode); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("chmod secrets file: %w", err)
}
if err := os.Rename(tmp, s.path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace secrets file: %w", err)
}
return nil
}
// Get returns a stored secret.
func (s *fileSecretStore) Get(providerID int64, name string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.load(); err != nil {
return "", err
}
v, ok := s.data[secretKey(providerID, name)]
if !ok {
return "", fmt.Errorf("%w: %s", ErrSecretNotFound, name)
}
return v, nil
}
// Set stores or clears a secret.
func (s *fileSecretStore) Set(providerID int64, name, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.load(); err != nil {
return err
}
key := secretKey(providerID, name)
if value == "" {
delete(s.data, key)
} else {
s.data[key] = value
}
return s.save()
}
// DeleteProvider drops every secret for a provider.
func (s *fileSecretStore) DeleteProvider(providerID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.load(); err != nil {
return err
}
prefix := strconv.FormatInt(providerID, 10) + ":"
for k := range s.data {
if len(k) > len(prefix) && k[:len(prefix)] == prefix {
delete(s.data, k)
}
}
return s.save()
}
// lookupFor binds a store to one provider, producing the SecretLookup
// handed to constructors.
func lookupFor(store SecretStore, providerID int64) SecretLookup {
return func(name string) (string, error) {
if store == nil {
return "", fmt.Errorf("%w: %s", ErrSecretNotFound, name)
}
return store.Get(providerID, name)
}
}
// memSecretStore is an in-memory SecretStore for tests.
type memSecretStore struct {
mu sync.RWMutex
data map[string]string
}
// NewMemSecretStore returns an in-memory SecretStore.
func NewMemSecretStore() SecretStore {
return &memSecretStore{data: map[string]string{}}
}
func (s *memSecretStore) Get(providerID int64, name string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.data[secretKey(providerID, name)]
if !ok {
return "", fmt.Errorf("%w: %s", ErrSecretNotFound, name)
}
return v, nil
}
func (s *memSecretStore) Set(providerID int64, name, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
if value == "" {
delete(s.data, secretKey(providerID, name))
return nil
}
s.data[secretKey(providerID, name)] = value
return nil
}
func (s *memSecretStore) DeleteProvider(providerID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
prefix := strconv.FormatInt(providerID, 10) + ":"
for k := range s.data {
if len(k) > len(prefix) && k[:len(prefix)] == prefix {
delete(s.data, k)
}
}
return nil
}
+142
View File
@@ -0,0 +1,142 @@
package download
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestFileSecretStoreRoundTrip(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "secrets.json")
store := NewFileSecretStoreAt(path)
if err := store.Set(1, "apiKey", "hunter2"); err != nil {
t.Fatalf("Set: %v", err)
}
got, err := store.Get(1, "apiKey")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got != "hunter2" {
t.Errorf("Get = %q, want hunter2", got)
}
// A fresh store over the same file must see the value.
if got, err := NewFileSecretStoreAt(path).Get(1, "apiKey"); err != nil ||
got != "hunter2" {
t.Errorf("reload got %q, err %v; want hunter2", got, err)
}
}
// Credentials must not be readable by other local users.
func TestFileSecretStorePermissions(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "secrets.json")
store := NewFileSecretStoreAt(path)
if err := store.Set(1, "apiKey", "hunter2"); err != nil {
t.Fatalf("Set: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if perm := info.Mode().Perm(); perm != secretsFileMode {
t.Errorf("mode = %o, want %o", perm, secretsFileMode)
}
}
// Two configured instances of the same provider kind must not share
// credentials.
func TestFileSecretStoreNamespacesByProvider(t *testing.T) {
t.Parallel()
store := NewFileSecretStoreAt(filepath.Join(t.TempDir(), "secrets.json"))
if err := store.Set(1, "apiKey", "first"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := store.Set(2, "apiKey", "second"); err != nil {
t.Fatalf("Set: %v", err)
}
for id, want := range map[int64]string{1: "first", 2: "second"} {
got, err := store.Get(id, "apiKey")
if err != nil {
t.Fatalf("Get(%d): %v", id, err)
}
if got != want {
t.Errorf("Get(%d) = %q, want %q", id, got, want)
}
}
}
func TestFileSecretStoreDeleteProvider(t *testing.T) {
t.Parallel()
store := NewFileSecretStoreAt(filepath.Join(t.TempDir(), "secrets.json"))
if err := store.Set(1, "apiKey", "a"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := store.Set(1, "password", "b"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := store.Set(2, "apiKey", "keep"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := store.DeleteProvider(1); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
for _, name := range []string{"apiKey", "password"} {
if _, err := store.Get(1, name); !errors.Is(err, ErrSecretNotFound) {
t.Errorf("Get(1, %q) error = %v, want ErrSecretNotFound", name, err)
}
}
if got, err := store.Get(2, "apiKey"); err != nil || got != "keep" {
t.Errorf("other provider's secret was removed: %q, %v", got, err)
}
}
func TestFileSecretStoreMissingFileIsEmpty(t *testing.T) {
t.Parallel()
store := NewFileSecretStoreAt(filepath.Join(t.TempDir(), "nope.json"))
if _, err := store.Get(1, "apiKey"); !errors.Is(err, ErrSecretNotFound) {
t.Errorf("error = %v, want ErrSecretNotFound", err)
}
}
func TestSetEmptyValueDeletes(t *testing.T) {
t.Parallel()
store := NewFileSecretStoreAt(filepath.Join(t.TempDir(), "secrets.json"))
if err := store.Set(1, "apiKey", "x"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := store.Set(1, "apiKey", ""); err != nil {
t.Fatalf("Set empty: %v", err)
}
if _, err := store.Get(1, "apiKey"); !errors.Is(err, ErrSecretNotFound) {
t.Errorf("error = %v, want ErrSecretNotFound", err)
}
}
+598
View File
@@ -0,0 +1,598 @@
package download
import (
"context"
"fmt"
"log/slog"
"strconv"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
// Service is the frontend-facing surface of the download subsystem.
// Its methods are bound into Wails and called from TypeScript, so
// signatures use plain types and return errors the UI can render.
type Service struct {
logger *slog.Logger
manager *Manager
store *Store
secrets SecretStore
// reconciler works the wanted list. Optional; nil means wants are
// stored but never acted on.
reconciler *Reconciler
ctx context.Context
}
// NewService builds the bound service.
func NewService(
logger *slog.Logger,
manager *Manager,
store *Store,
secrets SecretStore,
) *Service {
return &Service{
logger: logger,
manager: manager,
store: store,
secrets: secrets,
}
}
// SetContext injects the Wails runtime context for event emission.
func (s *Service) SetContext(ctx context.Context) {
s.ctx = ctx
}
// emit publishes an event, tolerating a service that has no runtime
// context yet. Emitting on a non-runtime context is fatal in Wails, so
// the nil check is load-bearing rather than defensive.
func (s *Service) emit(name string, data ...any) {
if s.ctx == nil {
return
}
runtime.EventsEmit(s.ctx, name, data...)
}
// ---------------------------------------------------------------------------
// Provider configuration
// ---------------------------------------------------------------------------
// ProviderKinds returns every provider type that can be added, with the
// settings each one needs. The settings page renders its forms from
// this, so a new adapter needs no frontend change.
func (s *Service) ProviderKinds() []Descriptor {
return Descriptors()
}
// ListProviders returns the user's configured download clients.
func (s *Service) ListProviders() ([]Config, error) {
return s.store.ListProviders(context.Background())
}
// AddProvider creates a provider and stores any secret settings
// separately. Secrets arrive in the same map as ordinary settings
// because that is what the form submits; they are split out here and
// never written to the provider row.
func (s *Service) AddProvider(
kind string,
name string,
settings map[string]string,
) (int64, error) {
desc, ok := DescriptorFor(Kind(kind))
if !ok {
return 0, fmt.Errorf("%w: %s", ErrUnknownKind, kind)
}
plain, secret := splitSecrets(desc, settings)
id, err := s.store.CreateProvider(context.Background(), Config{
Kind: Kind(kind),
Name: name,
Enabled: true,
Priority: 50,
Settings: plain,
})
if err != nil {
return 0, err
}
for k, v := range secret {
if err := s.secrets.Set(id, k, v); err != nil {
return 0, err
}
}
if err := s.manager.Reload(context.Background()); err != nil {
return 0, err
}
s.emit(events.DownloadProvidersChanged)
return id, nil
}
// UpdateProvider saves changes to a provider. A secret field left
// blank keeps its stored value rather than clearing it — the form does
// not echo secrets back, so an empty box means "unchanged", not
// "delete".
func (s *Service) UpdateProvider(
id int64,
name string,
enabled bool,
priority int,
settings map[string]string,
) error {
ctx := context.Background()
existing, err := s.store.GetProvider(ctx, id)
if err != nil {
return err
}
desc, ok := DescriptorFor(existing.Kind)
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownKind, existing.Kind)
}
plain, secret := splitSecrets(desc, settings)
if err := s.store.UpdateProvider(ctx, Config{
ID: id,
Kind: existing.Kind,
Name: name,
Enabled: enabled,
Priority: priority,
Settings: plain,
}); err != nil {
return err
}
for k, v := range secret {
if v == "" {
continue
}
if err := s.secrets.Set(id, k, v); err != nil {
return err
}
}
if err := s.manager.Reload(ctx); err != nil {
return err
}
s.emit(events.DownloadProvidersChanged)
return nil
}
// DeleteProvider removes a provider and its credentials.
func (s *Service) DeleteProvider(id int64) error {
ctx := context.Background()
if err := s.store.DeleteProvider(ctx, id); err != nil {
return err
}
if err := s.secrets.DeleteProvider(id); err != nil {
s.logger.Warn(
"could not delete provider secrets", "provider", id, "error", err,
)
}
if err := s.manager.Reload(ctx); err != nil {
return err
}
s.emit(events.DownloadProvidersChanged)
return nil
}
// TestProvider backs the "test connection" button. It builds the
// provider from its stored config and asks it to check itself, so the
// result reflects exactly what a real search would use.
func (s *Service) TestProvider(id int64) error {
ctx := context.Background()
cfg, err := s.store.GetProvider(ctx, id)
if err != nil {
return err
}
p, err := New(cfg, lookupFor(s.secrets, id), s.logger)
if err != nil {
return err
}
defer func() { _ = p.Close() }()
if err := p.Check(ctx); err != nil {
return fmt.Errorf("%s: %w", cfg.Name, err)
}
return nil
}
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
// SearchRequest is what the frontend submits to start a download.
type SearchRequest struct {
LibraryID int64 `json:"libraryId"`
ReleaseMBID string `json:"releaseMbid"`
ReleaseGroupMBID string `json:"releaseGroupMbid"`
Artist string `json:"artist"`
Album string `json:"album"`
Query string `json:"query"`
Expected []ExpectedTrack `json:"expected"`
}
// StartResult is what the picker needs after a search.
type StartResult struct {
RequestID string `json:"requestId"`
Candidates []Candidate `json:"candidates"`
// AutoPicked reports that the pipeline already chose and is
// downloading, so the picker should show progress rather than a
// list of choices.
AutoPicked bool `json:"autoPicked"`
}
// Start searches for a release and either auto-picks a clear winner or
// returns ranked candidates for the user to choose from.
func (s *Service) Start(req SearchRequest) (StartResult, error) {
r := Request{
ID: newID(),
LibraryID: req.LibraryID,
ReleaseMBID: req.ReleaseMBID,
ReleaseGroupMBID: req.ReleaseGroupMBID,
Artist: req.Artist,
Album: req.Album,
Query: req.Query,
Expected: req.Expected,
}
candidates, err := s.manager.Start(context.Background(), r)
if err != nil {
return StartResult{}, err
}
result := StartResult{
RequestID: r.ID,
Candidates: candidates,
AutoPicked: AutoPickable(r, candidates),
}
s.emit(events.DownloadsChanged)
return result, nil
}
// Pick starts the transfer for the candidate the user chose.
func (s *Service) Pick(requestID, candidateID string) error {
if err := s.manager.Pick(
context.Background(), requestID, candidateID,
); err != nil {
return err
}
s.emit(events.DownloadsChanged)
return nil
}
// Cancel aborts a live request.
func (s *Service) Cancel(requestID string) error {
if err := s.manager.Cancel(context.Background(), requestID); err != nil {
return err
}
s.emit(events.DownloadsChanged)
return nil
}
// Candidates returns the ranked candidates of a live request, so the
// picker can be reopened without searching again.
func (s *Service) Candidates(requestID string) []Candidate {
return s.manager.Candidates(requestID)
}
// RequestView is one row of the downloads list.
type RequestView struct {
Request
State State `json:"state"`
Error string `json:"error,omitempty"`
Items []Item `json:"items"`
}
// ListRequests returns recent download requests, newest first.
func (s *Service) ListRequests(limit int) ([]RequestView, error) {
const defaultLimit = 50
if limit <= 0 {
limit = defaultLimit
}
ctx := context.Background()
requests, err := s.store.ListRequests(ctx, limit)
if err != nil {
return nil, err
}
out := make([]RequestView, 0, len(requests))
for _, r := range requests {
state, errText, err := s.store.GetRequestState(ctx, r.ID)
if err != nil {
return nil, err
}
items, err := s.store.ListItemsForRequest(ctx, r.ID)
if err != nil {
return nil, err
}
out = append(out, RequestView{
Request: r,
State: state,
Error: errText,
Items: items,
})
}
return out, nil
}
// ClearFinished removes terminal requests from the list.
func (s *Service) ClearFinished() error {
if err := s.store.ClearFinished(context.Background()); err != nil {
return err
}
s.emit(events.DownloadsChanged)
return nil
}
// ---------------------------------------------------------------------------
// Wanted list
// ---------------------------------------------------------------------------
// SetReconciler wires the wanted-list loop. Optional: without it the
// wanted list still stores and lists wants, it just never acts on them.
func (s *Service) SetReconciler(r *Reconciler) {
s.reconciler = r
}
// WantRequest is what the frontend submits to want something. It is
// one MBID and the type of thing it names, because that is genuinely
// all a want is.
type WantRequest struct {
MBID string `json:"mbid"`
Entity string `json:"entity"`
LibraryID int64 `json:"libraryId"`
// Artist and Title are display text only, and optional: the
// reconciler fills them in from the catalog when the caller has
// nothing but an MBID.
Artist string `json:"artist"`
Title string `json:"title"`
// Scope and Secondary apply to artist wants.
Scope string `json:"scope"`
Secondary bool `json:"secondary"`
}
// AddWant puts something on the wanted list and asks for a reconcile
// pass, so the user sees something happen rather than waiting six hours
// for the next scheduled one.
func (s *Service) AddWant(req WantRequest) (int64, error) {
entity := Entity(req.Entity)
if !entity.Valid() {
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, req.Entity)
}
scope := WantScope(req.Scope)
if scope != ScopeAll {
scope = ScopeFuture
}
id, err := s.store.AddWant(context.Background(), Want{
MBID: req.MBID,
Entity: entity,
LibraryID: req.LibraryID,
Artist: req.Artist,
Title: req.Title,
Scope: scope,
Secondary: req.Secondary,
})
if err != nil {
return 0, err
}
s.emit(events.WantedListChanged)
if s.reconciler != nil {
s.reconciler.Trigger()
}
return id, nil
}
// ListWants returns the whole wanted list.
func (s *Service) ListWants() ([]Want, error) {
return s.store.ListWants(context.Background())
}
// IsWanted answers the Explore pages' question — should this album show
// "want" or "wanted?" — without making them load the whole list.
func (s *Service) IsWanted(mbid string, libraryID int64) (bool, error) {
_, found, err := s.store.FindWant(context.Background(), mbid, libraryID)
return found, err
}
// RemoveWant takes something off the list. Removing an artist takes
// its derived albums with it, by cascade; an album the user pinned
// themselves has no parent and survives.
func (s *Service) RemoveWant(id int64) error {
ctx := context.Background()
// Tell any external list first, while the row is still readable.
s.withdrawExternal(ctx, id)
if err := s.store.DeleteWant(ctx, id); err != nil {
return err
}
s.emit(events.WantedListChanged)
return nil
}
// PauseWant stops attempts without forgetting the want.
func (s *Service) PauseWant(id int64, paused bool) error {
state := WantStateWanted
if paused {
state = WantStatePaused
}
if err := s.store.SetWantState(
context.Background(), id, state, "",
); err != nil {
return err
}
s.emit(events.WantedListChanged)
return nil
}
// ClearSatisfiedWants drops everything already owned.
func (s *Service) ClearSatisfiedWants() error {
if err := s.store.ClearSatisfiedWants(context.Background()); err != nil {
return err
}
s.emit(events.WantedListChanged)
return nil
}
// ReconcileWanted runs a pass now and reports what it did. This backs
// the "check now" button, so it runs synchronously: the user pressed it
// and is waiting for an answer.
func (s *Service) ReconcileWanted() (Summary, error) {
if s.reconciler == nil {
return Summary{}, fmt.Errorf(
"%w: the wanted list is not running", ErrUnsupported,
)
}
summary, err := s.reconciler.RunOnce(context.Background())
if err != nil {
return summary, err
}
s.emit(events.WantedListChanged)
return summary, nil
}
// ImportExternalWants adopts a provider's own list — "import the
// artists Lidarr is already monitoring".
func (s *Service) ImportExternalWants(
providerID int64,
libraryID int64,
) (int, error) {
if s.reconciler == nil {
return 0, fmt.Errorf(
"%w: the wanted list is not running", ErrUnsupported,
)
}
n, err := s.reconciler.ImportExternal(
context.Background(), providerID, libraryID,
)
if err != nil {
return 0, err
}
s.emit(events.WantedListChanged)
return n, nil
}
// withdrawExternal best-effort unmonitors a want in the external lists
// it was pushed to. Failures are logged and ignored: the user asked to
// remove it from *this* list, and an unreachable Lidarr is not a reason
// to refuse.
func (s *Service) withdrawExternal(ctx context.Context, id int64) {
w, err := s.store.GetWant(ctx, id)
if err != nil || len(w.ExternalIDs) == 0 {
return
}
for key, externalID := range w.ExternalIDs {
providerID, err := strconv.ParseInt(key, 10, 64)
if err != nil {
continue
}
l, ok := s.manager.listers()[providerID]
if !ok {
continue
}
if err := l.RemoveWant(ctx, externalID); err != nil {
s.logger.Debug(
"could not withdraw want from external list",
"want", id,
"provider", providerID,
"error", err,
)
}
}
}
// splitSecrets partitions a submitted settings map into plain values,
// which go in the provider row, and secret values, which go in the
// secret store. The split is driven by the descriptor so a provider
// declaring a field secret is enough to keep it out of the database.
func splitSecrets(
desc Descriptor,
settings map[string]string,
) (plain, secret map[string]string) {
plain = make(map[string]string, len(settings))
secret = make(map[string]string)
secretKeys := make(map[string]bool, len(desc.Fields))
for _, f := range desc.Fields {
if f.Secret {
secretKeys[f.Key] = true
}
}
for k, v := range settings {
if secretKeys[k] {
secret[k] = v
continue
}
plain[k] = v
}
return plain, secret
}
+272
View File
@@ -0,0 +1,272 @@
package download
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"yellowjacket/backend/system"
)
// Downloads land here first and only move into the library once they
// have been verified and tagged. The reason is not tidiness: the
// library scanner watches library paths, and a half-written file or a
// mislabelled Soulseek folder that lands there gets ingested, indexed
// and surfaced to the user before anyone can check it. Staging makes
// the import step the single writer into library paths.
// stagingDirName is the staging root inside the user data directory.
const stagingDirName = "downloads"
// staleAge is how long an abandoned staging directory survives before
// the startup sweep removes it. Long enough that a download
// interrupted by a crash can still be inspected; short enough that a
// failed grab does not sit on disk forever.
const staleAge = 48 * time.Hour
// ErrEscapesStaging is returned when a provider reports a file path
// outside the directory it was given.
var ErrEscapesStaging = errors.New("path escapes the staging directory")
// Staging owns the download staging area.
type Staging struct {
root string
logger *slog.Logger
}
// NewStaging creates the staging area under the user data directory.
func NewStaging(logger *slog.Logger) (*Staging, error) {
dir, err := system.GetUserDataDirPath()
if err != nil {
return nil, fmt.Errorf("resolve user data dir: %w", err)
}
return NewStagingAt(filepath.Join(dir, stagingDirName), logger)
}
// NewStagingAt creates a staging area at an explicit root.
func NewStagingAt(root string, logger *slog.Logger) (*Staging, error) {
if err := os.MkdirAll(root, 0o750); err != nil {
return nil, fmt.Errorf("create staging root: %w", err)
}
return &Staging{root: root, logger: logger}, nil
}
// Root returns the staging root directory.
func (s *Staging) Root() string {
return s.root
}
// Reserve creates and returns a directory for one download item.
func (s *Staging) Reserve(itemID string) (string, error) {
dir := filepath.Join(s.root, sanitizeSegment(itemID))
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", fmt.Errorf("create staging dir: %w", err)
}
return dir, nil
}
// Release removes a download item's staging directory and everything
// in it. Called after a successful import and after a failed grab.
func (s *Staging) Release(dir string) error {
if !s.contains(dir) {
return fmt.Errorf("%w: %s", ErrEscapesStaging, dir)
}
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("remove staging dir: %w", err)
}
return nil
}
// contains reports whether dir is inside the staging root. Guards
// every destructive operation, because dir ultimately comes from a
// database row a provider wrote.
func (s *Staging) contains(dir string) bool {
absRoot, err := filepath.Abs(s.root)
if err != nil {
return false
}
absDir, err := filepath.Abs(dir)
if err != nil {
return false
}
rel, err := filepath.Rel(absRoot, absDir)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// Verify checks that every path a provider reported is a real file
// inside dir, and returns them cleaned. A transport that reports a
// path outside its directory is either buggy or hostile; either way the
// import must not follow it.
func (s *Staging) Verify(dir string, files []string) ([]string, error) {
if !s.contains(dir) {
return nil, fmt.Errorf("%w: %s", ErrEscapesStaging, dir)
}
absDir, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("resolve staging dir: %w", err)
}
out := make([]string, 0, len(files))
for _, f := range files {
abs := f
if !filepath.IsAbs(abs) {
abs = filepath.Join(absDir, f)
}
abs = filepath.Clean(abs)
rel, err := filepath.Rel(absDir, abs)
if err != nil ||
rel == ".." ||
strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("%w: %s", ErrEscapesStaging, f)
}
info, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("stat downloaded file %s: %w", rel, err)
}
if info.IsDir() || info.Size() == 0 {
continue
}
out = append(out, abs)
}
return out, nil
}
// Sweep removes staging directories left behind by a previous run.
// Anything still present at startup belongs to a download that did not
// finish, since a completed import releases its directory.
//
// Directories younger than staleAge are kept: a grab may legitimately
// be resumed, and deleting a partial transfer the user is waiting on
// would be worse than leaving a few megabytes on disk.
func (s *Staging) Sweep() (removed int, err error) {
entries, err := os.ReadDir(s.root)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("read staging root: %w", err)
}
cutoff := time.Now().Add(-staleAge)
for _, e := range entries {
if !e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().After(cutoff) {
continue
}
dir := filepath.Join(s.root, e.Name())
if err := os.RemoveAll(dir); err != nil {
s.logger.Warn(
"could not remove stale staging directory",
"dir", dir,
"error", err,
)
continue
}
removed++
}
if removed > 0 {
s.logger.Info("removed stale staging directories", "count", removed)
}
return removed, nil
}
// SweepOrphans removes staging directories whose item IDs are not in
// the live set. Called after the item store is loaded, so a directory
// belonging to a download the database no longer knows about goes away
// even if it is recent.
func (s *Staging) SweepOrphans(live map[string]bool) (removed int, err error) {
entries, err := os.ReadDir(s.root)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("read staging root: %w", err)
}
for _, e := range entries {
if !e.IsDir() || live[e.Name()] {
continue
}
if err := os.RemoveAll(filepath.Join(s.root, e.Name())); err != nil {
s.logger.Warn(
"could not remove orphaned staging directory",
"dir", e.Name(),
"error", err,
)
continue
}
removed++
}
return removed, nil
}
// sanitizeSegment reduces a string to a safe single path segment.
func sanitizeSegment(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := b.String()
if out == "" {
return "item"
}
return out
}
+198
View File
@@ -0,0 +1,198 @@
package download
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func newTestStaging(t *testing.T) *Staging {
t.Helper()
s, err := NewStagingAt(t.TempDir(), slogDiscard())
if err != nil {
t.Fatalf("NewStagingAt: %v", err)
}
return s
}
func TestStagingReserveAndRelease(t *testing.T) {
t.Parallel()
s := newTestStaging(t)
dir, err := s.Reserve("item-1")
if err != nil {
t.Fatalf("Reserve: %v", err)
}
if _, err := os.Stat(dir); err != nil {
t.Fatalf("staging dir not created: %v", err)
}
if err := s.Release(dir); err != nil {
t.Fatalf("Release: %v", err)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Error("staging dir still exists after Release")
}
}
// A provider reporting a path outside its staging directory is either
// buggy or hostile. Either way the import must refuse to follow it,
// because the next step moves those paths into the library.
func TestStagingVerifyRejectsEscape(t *testing.T) {
t.Parallel()
s := newTestStaging(t)
dir, err := s.Reserve("item-1")
if err != nil {
t.Fatalf("Reserve: %v", err)
}
outside := filepath.Join(s.Root(), "elsewhere.flac")
if err := os.WriteFile(outside, []byte("x"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
tests := []string{
"../elsewhere.flac",
outside,
filepath.Join(dir, "..", "elsewhere.flac"),
}
for _, path := range tests {
t.Run(path, func(t *testing.T) {
t.Parallel()
if _, err := s.Verify(dir, []string{path}); !errors.Is(
err, ErrEscapesStaging,
) {
t.Errorf("Verify(%q) error = %v, want ErrEscapesStaging", path, err)
}
})
}
}
func TestStagingReleaseRejectsOutsideRoot(t *testing.T) {
t.Parallel()
s := newTestStaging(t)
other := t.TempDir()
if err := s.Release(other); !errors.Is(err, ErrEscapesStaging) {
t.Errorf("Release outside root error = %v, want ErrEscapesStaging", err)
}
if _, err := os.Stat(other); err != nil {
t.Error("Release removed a directory outside the staging root")
}
}
func TestStagingVerifySkipsEmptyFiles(t *testing.T) {
t.Parallel()
s := newTestStaging(t)
dir, err := s.Reserve("item-1")
if err != nil {
t.Fatalf("Reserve: %v", err)
}
good := filepath.Join(dir, "good.flac")
empty := filepath.Join(dir, "empty.flac")
if err := os.WriteFile(good, []byte("data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(empty, nil, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
files, err := s.Verify(dir, []string{good, empty})
if err != nil {
t.Fatalf("Verify: %v", err)
}
if len(files) != 1 || files[0] != good {
t.Errorf("Verify = %v, want just %s", files, good)
}
}
func TestStagingSweepKeepsRecentDirs(t *testing.T) {
t.Parallel()
s := newTestStaging(t)
recent, err := s.Reserve("recent")
if err != nil {
t.Fatalf("Reserve: %v", err)
}
stale, err := s.Reserve("stale")
if err != nil {
t.Fatalf("Reserve: %v", err)
}
old := time.Now().Add(-staleAge - time.Hour)
if err := os.Chtimes(stale, old, old); err != nil {
t.Fatalf("Chtimes: %v", err)
}
removed, err := s.Sweep()
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if removed != 1 {
t.Errorf("removed = %d, want 1", removed)
}
if _, err := os.Stat(recent); err != nil {
t.Error("sweep removed a recent staging dir")
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Error("sweep kept a stale staging dir")
}
}
func TestStagingSweepOrphans(t *testing.T) {
t.Parallel()
s := newTestStaging(t)
live, err := s.Reserve("live")
if err != nil {
t.Fatalf("Reserve: %v", err)
}
orphan, err := s.Reserve("orphan")
if err != nil {
t.Fatalf("Reserve: %v", err)
}
removed, err := s.SweepOrphans(map[string]bool{"live": true})
if err != nil {
t.Fatalf("SweepOrphans: %v", err)
}
if removed != 1 {
t.Errorf("removed = %d, want 1", removed)
}
if _, err := os.Stat(live); err != nil {
t.Error("sweep removed a live staging dir")
}
if _, err := os.Stat(orphan); !os.IsNotExist(err) {
t.Error("sweep kept an orphaned staging dir")
}
}
+521
View File
@@ -0,0 +1,521 @@
package download
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// ErrNotFound is returned when a request or item ID is unknown.
var ErrNotFound = errors.New("not found")
// Store is the download subsystem's persistence layer. It owns the
// JSON encoding of the blob columns so nothing above it has to know
// that candidates are stored as text.
type Store struct {
db *database.DB
}
// NewStore returns a Store over the application database.
func NewStore(db *database.DB) *Store {
return &Store{db: db}
}
// ---------------------------------------------------------------------------
// Providers
// ---------------------------------------------------------------------------
// ListProviders returns every configured provider, best priority first.
func (s *Store) ListProviders(ctx context.Context) ([]Config, error) {
rows, err := s.db.ReadQueries.ListDownloadProviders(ctx)
if err != nil {
return nil, fmt.Errorf("list download providers: %w", err)
}
out := make([]Config, 0, len(rows))
for _, r := range rows {
out = append(out, providerRowToConfig(r))
}
return out, nil
}
// GetProvider returns one provider's config.
func (s *Store) GetProvider(ctx context.Context, id int64) (Config, error) {
row, err := s.db.ReadQueries.GetDownloadProvider(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Config{}, fmt.Errorf("%w: provider %d", ErrNotFound, id)
}
return Config{}, fmt.Errorf("get download provider: %w", err)
}
return providerRowToConfig(row), nil
}
// CreateProvider inserts a provider and returns its new ID.
func (s *Store) CreateProvider(ctx context.Context, cfg Config) (int64, error) {
settings, err := json.Marshal(cfg.Settings)
if err != nil {
return 0, fmt.Errorf("encode provider settings: %w", err)
}
id, err := s.db.Queries.CreateDownloadProvider(
ctx,
sqlcgen.CreateDownloadProviderParams{
Kind: string(cfg.Kind),
Name: cfg.Name,
Enabled: boolToInt(cfg.Enabled),
Priority: int64(cfg.Priority),
Settings: string(settings),
},
)
if err != nil {
return 0, fmt.Errorf("create download provider: %w", err)
}
return id, nil
}
// UpdateProvider saves changes to an existing provider.
func (s *Store) UpdateProvider(ctx context.Context, cfg Config) error {
settings, err := json.Marshal(cfg.Settings)
if err != nil {
return fmt.Errorf("encode provider settings: %w", err)
}
if err := s.db.Queries.UpdateDownloadProvider(
ctx,
sqlcgen.UpdateDownloadProviderParams{
Name: cfg.Name,
Enabled: boolToInt(cfg.Enabled),
Priority: int64(cfg.Priority),
Settings: string(settings),
ID: cfg.ID,
},
); err != nil {
return fmt.Errorf("update download provider: %w", err)
}
return nil
}
// DeleteProvider removes a provider row. Its secrets are removed
// separately by the manager, which owns the secret store.
func (s *Store) DeleteProvider(ctx context.Context, id int64) error {
if err := s.db.Queries.DeleteDownloadProvider(ctx, id); err != nil {
return fmt.Errorf("delete download provider: %w", err)
}
return nil
}
// providerRowToConfig decodes a stored provider row. A settings blob
// that fails to parse yields an empty map rather than an error: the
// provider will fail its own Check with a useful message, which beats
// making the whole settings page unloadable.
func providerRowToConfig(r sqlcgen.DownloadProvider) Config {
settings := map[string]string{}
_ = json.Unmarshal([]byte(r.Settings), &settings)
return Config{
ID: r.ID,
Kind: Kind(r.Kind),
Name: r.Name,
Enabled: r.Enabled != 0,
Priority: int(r.Priority),
Settings: settings,
}
}
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
// CreateRequest persists a new request.
func (s *Store) CreateRequest(ctx context.Context, req Request) error {
expected, err := json.Marshal(req.Expected)
if err != nil {
return fmt.Errorf("encode expected tracks: %w", err)
}
source := req.Source
if source == "" {
source = "manual"
}
wantID := sql.NullInt64{}
if req.WantID != 0 {
wantID = sql.NullInt64{Int64: req.WantID, Valid: true}
}
if err := s.db.Queries.CreateDownloadRequest(
ctx,
sqlcgen.CreateDownloadRequestParams{
ID: req.ID,
LibraryID: req.LibraryID,
Source: source,
WantID: wantID,
ReleaseMbid: toNullString(req.ReleaseMBID),
ReleaseGroupMbid: toNullString(req.ReleaseGroupMBID),
RecordingMbid: toNullString(req.RecordingMBID),
Artist: req.Artist,
Album: req.Album,
Query: req.Query,
Expected: string(expected),
State: string(StateSearching),
},
); err != nil {
return fmt.Errorf("create download request: %w", err)
}
return nil
}
// GetRequest loads a request by ID.
func (s *Store) GetRequest(ctx context.Context, id string) (Request, error) {
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Request{}, fmt.Errorf("%w: request %s", ErrNotFound, id)
}
return Request{}, fmt.Errorf("get download request: %w", err)
}
return requestRowToRequest(row), nil
}
// GetRequestState returns a request's current state and error text.
// Kept separate from GetRequest because state is the one field that
// changes constantly while the rest of the row is immutable.
func (s *Store) GetRequestState(
ctx context.Context,
id string,
) (State, string, error) {
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", "", fmt.Errorf("%w: request %s", ErrNotFound, id)
}
return "", "", fmt.Errorf("get download request state: %w", err)
}
return State(row.State), row.Error, nil
}
// ListRequests returns the most recent requests, newest first.
func (s *Store) ListRequests(ctx context.Context, limit int) ([]Request, error) {
rows, err := s.db.ReadQueries.ListDownloadRequests(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("list download requests: %w", err)
}
out := make([]Request, 0, len(rows))
for _, r := range rows {
out = append(out, requestRowToRequest(r))
}
return out, nil
}
// SetRequestState updates a request's state and error text.
func (s *Store) SetRequestState(
ctx context.Context,
id string,
state State,
errText string,
) error {
if err := s.db.Queries.SetDownloadRequestState(
ctx,
sqlcgen.SetDownloadRequestStateParams{
State: string(state),
Error: errText,
ID: id,
},
); err != nil {
return fmt.Errorf("set download request state: %w", err)
}
return nil
}
// DeleteRequest removes a request and, by cascade, its items.
func (s *Store) DeleteRequest(ctx context.Context, id string) error {
if err := s.db.Queries.DeleteDownloadRequest(ctx, id); err != nil {
return fmt.Errorf("delete download request: %w", err)
}
return nil
}
// ClearFinished removes every terminal request.
func (s *Store) ClearFinished(ctx context.Context) error {
if err := s.db.Queries.DeleteFinishedDownloadRequests(ctx); err != nil {
return fmt.Errorf("clear finished download requests: %w", err)
}
return nil
}
// requestRowToRequest decodes a stored request row.
func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
var expected []ExpectedTrack
_ = json.Unmarshal([]byte(r.Expected), &expected)
return Request{
ID: r.ID,
LibraryID: r.LibraryID,
Source: r.Source,
WantID: r.WantID.Int64,
ReleaseMBID: r.ReleaseMbid.String,
ReleaseGroupMBID: r.ReleaseGroupMbid.String,
RecordingMBID: r.RecordingMbid.String,
Artist: r.Artist,
Album: r.Album,
Query: r.Query,
Expected: expected,
CreatedAt: r.CreatedAt,
}
}
// ---------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------
// Item is one grab attempt, as stored.
type Item struct {
ID string `json:"id"`
RequestID string `json:"requestId"`
ProviderID int64 `json:"providerId"`
Transport int64 `json:"transportId,omitempty"`
ExternalID string `json:"externalId,omitempty"`
Candidate Candidate `json:"candidate"`
State State `json:"state"`
StagingDir string `json:"-"`
BytesDone int64 `json:"bytesDone"`
BytesTotal int64 `json:"bytesTotal"`
Imported []string `json:"-"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// CreateItem persists a grab attempt.
func (s *Store) CreateItem(ctx context.Context, item Item) error {
candidate, err := json.Marshal(item.Candidate)
if err != nil {
return fmt.Errorf("encode candidate: %w", err)
}
transport := sql.NullInt64{}
if item.Transport != 0 {
transport = sql.NullInt64{Int64: item.Transport, Valid: true}
}
if err := s.db.Queries.CreateDownloadItem(
ctx,
sqlcgen.CreateDownloadItemParams{
ID: item.ID,
RequestID: item.RequestID,
ProviderID: item.ProviderID,
TransportID: transport,
ExternalID: item.ExternalID,
Candidate: string(candidate),
State: string(item.State),
StagingDir: item.StagingDir,
BytesTotal: item.BytesTotal,
},
); err != nil {
return fmt.Errorf("create download item: %w", err)
}
return nil
}
// GetItem loads one item.
func (s *Store) GetItem(ctx context.Context, id string) (Item, error) {
row, err := s.db.ReadQueries.GetDownloadItem(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Item{}, fmt.Errorf("%w: item %s", ErrNotFound, id)
}
return Item{}, fmt.Errorf("get download item: %w", err)
}
return itemRowToItem(row), nil
}
// ListItemsForRequest returns a request's grab attempts, oldest first.
func (s *Store) ListItemsForRequest(
ctx context.Context,
requestID string,
) ([]Item, error) {
rows, err := s.db.ReadQueries.ListDownloadItemsForRequest(ctx, requestID)
if err != nil {
return nil, fmt.Errorf("list download items: %w", err)
}
out := make([]Item, 0, len(rows))
for _, r := range rows {
out = append(out, itemRowToItem(r))
}
return out, nil
}
// ListLiveItems returns every non-terminal item. Called at startup to
// decide what to resume, reconcile or abandon.
func (s *Store) ListLiveItems(ctx context.Context) ([]Item, error) {
rows, err := s.db.ReadQueries.ListLiveDownloadItems(ctx)
if err != nil {
return nil, fmt.Errorf("list live download items: %w", err)
}
out := make([]Item, 0, len(rows))
for _, r := range rows {
out = append(out, itemRowToItem(r))
}
return out, nil
}
// SetItemState updates an item's state and error text.
func (s *Store) SetItemState(
ctx context.Context,
id string,
state State,
errText string,
) error {
if err := s.db.Queries.SetDownloadItemState(
ctx,
sqlcgen.SetDownloadItemStateParams{
State: string(state),
Error: errText,
ID: id,
},
); err != nil {
return fmt.Errorf("set download item state: %w", err)
}
return nil
}
// SetItemProgress records transfer progress.
func (s *Store) SetItemProgress(
ctx context.Context,
id string,
done, total int64,
) error {
if err := s.db.Queries.SetDownloadItemProgress(
ctx,
sqlcgen.SetDownloadItemProgressParams{
BytesDone: done,
BytesTotal: total,
ID: id,
},
); err != nil {
return fmt.Errorf("set download item progress: %w", err)
}
return nil
}
// SetItemExternalID records a delegating manager's own identifier.
func (s *Store) SetItemExternalID(
ctx context.Context,
id, externalID string,
) error {
if err := s.db.Queries.SetDownloadItemExternalID(
ctx,
sqlcgen.SetDownloadItemExternalIDParams{
ExternalID: externalID,
ID: id,
},
); err != nil {
return fmt.Errorf("set download item external id: %w", err)
}
return nil
}
// SetItemImported records the library paths files landed at and marks
// the item complete.
func (s *Store) SetItemImported(
ctx context.Context,
id string,
paths []string,
) error {
encoded, err := json.Marshal(paths)
if err != nil {
return fmt.Errorf("encode imported paths: %w", err)
}
if err := s.db.Queries.SetDownloadItemImported(
ctx,
sqlcgen.SetDownloadItemImportedParams{
ImportedPaths: string(encoded),
ID: id,
},
); err != nil {
return fmt.Errorf("set download item imported: %w", err)
}
return nil
}
// itemRowToItem decodes a stored item row.
func itemRowToItem(r sqlcgen.DownloadItem) Item {
var (
candidate Candidate
imported []string
)
_ = json.Unmarshal([]byte(r.Candidate), &candidate)
_ = json.Unmarshal([]byte(r.ImportedPaths), &imported)
return Item{
ID: r.ID,
RequestID: r.RequestID,
ProviderID: r.ProviderID,
Transport: r.TransportID.Int64,
ExternalID: r.ExternalID,
Candidate: candidate,
State: State(r.State),
StagingDir: r.StagingDir,
BytesDone: r.BytesDone,
BytesTotal: r.BytesTotal,
Imported: imported,
Error: r.Error,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
// boolToInt converts a bool to SQLite's integer boolean.
func boolToInt(b bool) int64 {
if b {
return 1
}
return 0
}
// toNullString wraps a possibly-empty string for a nullable column.
func toNullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: s != ""}
}
+667
View File
@@ -0,0 +1,667 @@
package download
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// ---------------------------------------------------------------------------
// qBittorrent
// ---------------------------------------------------------------------------
// qbitStub is a fake qBittorrent Web API.
type qbitStub struct {
server *httptest.Server
mu sync.Mutex
// loginOK controls whether auth succeeds.
loginOK bool
// torrentStates is what the info endpoint returns, in order; the
// last entry repeats.
torrentStates [][]qbitTorrent
pollCount int
// addedForm records the add-torrent parameters.
addedForm url.Values
}
func newQbitStub(t *testing.T) *qbitStub {
t.Helper()
s := &qbitStub{loginOK: true}
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/auth/login", func(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
ok := s.loginOK
s.mu.Unlock()
if ok {
_, _ = w.Write([]byte("Ok."))
return
}
// qBittorrent answers a bad login with 200 and "Fails.".
_, _ = w.Write([]byte("Fails."))
})
mux.HandleFunc("/api/v2/app/version", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("v4.6.0"))
})
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Errorf("parse add form: %v", err)
}
s.mu.Lock()
s.addedForm = r.PostForm
s.mu.Unlock()
_, _ = w.Write([]byte("Ok."))
})
mux.HandleFunc("/api/v2/torrents/info", func(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
idx := s.pollCount
if idx >= len(s.torrentStates) {
idx = len(s.torrentStates) - 1
} else {
s.pollCount++
}
var batch []qbitTorrent
if idx >= 0 && len(s.torrentStates) > 0 {
batch = s.torrentStates[idx]
}
s.mu.Unlock()
writeJSON(t, w, batch)
})
s.server = httptest.NewServer(mux)
t.Cleanup(s.server.Close)
return s
}
func newStubQbit(t *testing.T, stub *qbitStub) *qbittorrent {
t.Helper()
p, err := newQBittorrent(
Config{
ID: 1,
Kind: KindQBittorrent,
Name: "qbittorrent",
Settings: map[string]string{
"url": stub.server.URL,
"username": "admin",
},
},
func(string) (string, error) { return "secret", nil },
slogDiscard(),
)
if err != nil {
t.Fatalf("newQBittorrent: %v", err)
}
q, ok := p.(*qbittorrent)
if !ok {
t.Fatalf("provider is %T, want *qbittorrent", p)
}
q.pollInterval = time.Millisecond
return q
}
func TestQbitCheck(t *testing.T) {
t.Parallel()
stub := newQbitStub(t)
q := newStubQbit(t, stub)
if err := q.Check(context.Background()); err != nil {
t.Errorf("Check: %v", err)
}
}
// qBittorrent returns HTTP 200 with "Fails." for a bad password, so a
// status-code-only check would report success.
func TestQbitRejectsBadPassword(t *testing.T) {
t.Parallel()
stub := newQbitStub(t)
stub.loginOK = false
q := newStubQbit(t, stub)
if err := q.Check(context.Background()); !errors.Is(err, ErrQbitAuth) {
t.Errorf("error = %v, want ErrQbitAuth", err)
}
}
// The transport must be a pure transport: no search role.
func TestQbitDeclaresTransportOnly(t *testing.T) {
t.Parallel()
stub := newQbitStub(t)
q := newStubQbit(t, stub)
caps := q.Info().Caps
if caps.CanSearch || caps.CanDelegate {
t.Errorf("qBittorrent should transport only, got %+v", caps)
}
if !caps.Handles(ProtocolTorrent) {
t.Error("qBittorrent should handle the torrent protocol")
}
if caps.Handles(ProtocolUsenet) {
t.Error("qBittorrent should not claim usenet")
}
}
func TestQbitGrabCollectsCompletedTorrent(t *testing.T) {
t.Parallel()
stub := newQbitStub(t)
// The torrent's content lands in a directory qBittorrent owns.
content := t.TempDir()
for _, name := range []string{"01 Airbag.flac", "02 Paranoid Android.flac"} {
if err := os.WriteFile(
filepath.Join(content, name), []byte("audio"), 0o600,
); err != nil {
t.Fatalf("write: %v", err)
}
}
stub.torrentStates = [][]qbitTorrent{
{{
Hash: "abc123", State: "downloading",
Progress: 0.4, Size: 1000, Completed: 400,
}},
{{
Hash: "abc123", State: "uploading",
Progress: 1.0, Size: 1000, Completed: 1000,
ContentPath: content,
}},
}
q := newStubQbit(t, stub)
dst := t.TempDir()
c := Candidate{
ID: "prowlarr:x",
Protocol: ProtocolTorrent,
Title: "Radiohead - OK Computer",
Payload: map[string]string{
"link": "magnet:?xt=urn:btih:abc123",
"infoHash": "abc123",
},
}
got, err := q.Grab(context.Background(), c, dst, nil)
if err != nil {
t.Fatalf("Grab: %v", err)
}
if len(got.Files) != 2 {
t.Fatalf("collected %d files, want 2", len(got.Files))
}
for _, f := range got.Files {
if !strings.HasPrefix(f, dst) {
t.Errorf("file %s is outside the staging dir", f)
}
}
// The torrent was saved into our staging directory and kept out of
// qBittorrent's own move-on-completion rules.
stub.mu.Lock()
form := stub.addedForm
stub.mu.Unlock()
if form.Get("savepath") != dst {
t.Errorf("savepath = %q, want the staging dir %q", form.Get("savepath"), dst)
}
if form.Get("autoTMM") != "false" {
t.Errorf("autoTMM = %q, want false", form.Get("autoTMM"))
}
}
func TestQbitGrabFailsOnErrorState(t *testing.T) {
t.Parallel()
stub := newQbitStub(t)
stub.torrentStates = [][]qbitTorrent{
{{Hash: "abc123", State: "error"}},
}
q := newStubQbit(t, stub)
c := Candidate{
Protocol: ProtocolTorrent,
Payload: map[string]string{
"link": "magnet:?xt=urn:btih:abc123", "infoHash": "abc123",
},
}
_, err := q.Grab(context.Background(), c, t.TempDir(), nil)
if !errors.Is(err, ErrQbitTransferFailed) {
t.Errorf("error = %v, want ErrQbitTransferFailed", err)
}
}
func TestInfoHashFromMagnet(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want string
}{
{"magnet:?xt=urn:btih:ABC123&dn=x", "abc123"},
{"magnet:?dn=x&xt=urn:btih:def456", "def456"},
{"magnet:?dn=no-hash", ""},
{"https://example.com/x.torrent", ""},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
t.Parallel()
if got := infoHashFromMagnet(tt.in); got != tt.want {
t.Errorf("infoHashFromMagnet(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// SABnzbd
// ---------------------------------------------------------------------------
// sabStub is a fake SABnzbd API.
type sabStub struct {
server *httptest.Server
mu sync.Mutex
// queueSlots and historySlots are returned in order per mode; the
// last entry repeats.
queueSlots [][]sabQueueSlot
queuePolls int
historySlots []sabHistorySlot
addStatus bool
addError string
badKey bool
}
func newSabStub(t *testing.T) *sabStub {
t.Helper()
s := &sabStub{addStatus: true}
s.server = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
badKey := s.badKey
s.mu.Unlock()
if badKey {
// SABnzbd reports a bad key with HTTP 200 and a body.
writeJSON(t, w, map[string]any{
"status": false, "error": "API Key Incorrect",
})
return
}
switch r.URL.Query().Get("mode") {
case "version":
writeJSON(t, w, map[string]any{"version": "4.1.0"})
case "addurl":
s.mu.Lock()
status, errMsg := s.addStatus, s.addError
s.mu.Unlock()
writeJSON(t, w, map[string]any{
"status": status, "error": errMsg,
"nzo_ids": []string{"SABnzbd_nzo_1"},
})
case "queue":
s.mu.Lock()
idx := s.queuePolls
if idx >= len(s.queueSlots) {
idx = len(s.queueSlots) - 1
} else {
s.queuePolls++
}
var slots []sabQueueSlot
if idx >= 0 && len(s.queueSlots) > 0 {
slots = s.queueSlots[idx]
}
s.mu.Unlock()
writeJSON(t, w, map[string]any{
"queue": map[string]any{"slots": slots},
})
case "history":
s.mu.Lock()
slots := s.historySlots
s.mu.Unlock()
writeJSON(t, w, map[string]any{
"history": map[string]any{"slots": slots},
})
default:
w.WriteHeader(http.StatusBadRequest)
}
},
))
t.Cleanup(s.server.Close)
return s
}
func newStubSab(t *testing.T, stub *sabStub) *sabnzbd {
t.Helper()
p, err := newSABnzbd(
Config{
ID: 1,
Kind: KindSABnzbd,
Name: "sabnzbd",
Settings: map[string]string{"url": stub.server.URL},
},
func(string) (string, error) { return "test-key", nil },
slogDiscard(),
)
if err != nil {
t.Fatalf("newSABnzbd: %v", err)
}
s, ok := p.(*sabnzbd)
if !ok {
t.Fatalf("provider is %T, want *sabnzbd", p)
}
s.pollInterval = time.Millisecond
return s
}
func TestSabCheck(t *testing.T) {
t.Parallel()
stub := newSabStub(t)
s := newStubSab(t, stub)
if err := s.Check(context.Background()); err != nil {
t.Errorf("Check: %v", err)
}
}
func TestSabDeclaresUsenetOnly(t *testing.T) {
t.Parallel()
stub := newSabStub(t)
s := newStubSab(t, stub)
caps := s.Info().Caps
if caps.CanSearch || caps.CanDelegate {
t.Errorf("SABnzbd should transport only, got %+v", caps)
}
if !caps.Handles(ProtocolUsenet) {
t.Error("SABnzbd should handle the usenet protocol")
}
if caps.Handles(ProtocolTorrent) {
t.Error("SABnzbd should not claim torrents")
}
}
// Completion is read from history, not from the queue emptying: a job
// leaves the queue before post-processing finishes.
func TestSabGrabWaitsForHistory(t *testing.T) {
t.Parallel()
stub := newSabStub(t)
storage := t.TempDir()
for _, name := range []string{"01 Airbag.flac", "02 Paranoid Android.flac"} {
if err := os.WriteFile(
filepath.Join(storage, name), []byte("audio"), 0o600,
); err != nil {
t.Fatalf("write: %v", err)
}
}
stub.queueSlots = [][]sabQueueSlot{
{{
NzoID: "SABnzbd_nzo_1", Status: "Downloading",
MB: "100.0", MBLeft: "60.0",
}},
// Second poll: gone from the queue.
{},
}
stub.historySlots = []sabHistorySlot{{
NzoID: "SABnzbd_nzo_1", Status: "Completed", Storage: storage,
}}
s := newStubSab(t, stub)
dst := t.TempDir()
c := Candidate{
Protocol: ProtocolUsenet,
Title: "Radiohead - OK Computer",
Payload: map[string]string{"link": "https://example.com/x.nzb"},
}
got, err := s.Grab(context.Background(), c, dst, nil)
if err != nil {
t.Fatalf("Grab: %v", err)
}
if len(got.Files) != 2 {
t.Fatalf("collected %d files, want 2", len(got.Files))
}
for _, f := range got.Files {
if !strings.HasPrefix(f, dst) {
t.Errorf("file %s is outside the staging dir", f)
}
}
}
func TestSabGrabFailsOnFailedJob(t *testing.T) {
t.Parallel()
stub := newSabStub(t)
stub.queueSlots = [][]sabQueueSlot{{}}
stub.historySlots = []sabHistorySlot{{
NzoID: "SABnzbd_nzo_1",
Status: "Failed",
FailMsg: "Unpacking failed",
}}
s := newStubSab(t, stub)
c := Candidate{
Payload: map[string]string{"link": "https://example.com/x.nzb"},
}
_, err := s.Grab(context.Background(), c, t.TempDir(), nil)
if !errors.Is(err, ErrSabTransferFailed) {
t.Errorf("error = %v, want ErrSabTransferFailed", err)
}
}
// A job that vanishes from both queue and history was removed out from
// under us, which must not look like success.
func TestSabGrabDetectsVanishedJob(t *testing.T) {
t.Parallel()
stub := newSabStub(t)
stub.queueSlots = [][]sabQueueSlot{{}}
stub.historySlots = nil
s := newStubSab(t, stub)
c := Candidate{
Payload: map[string]string{"link": "https://example.com/x.nzb"},
}
_, err := s.Grab(context.Background(), c, t.TempDir(), nil)
if !errors.Is(err, ErrSabNoJob) {
t.Errorf("error = %v, want ErrSabNoJob", err)
}
}
// An NZB URL is untrusted input from an indexer.
func TestSabGrabRejectsNonHTTPURL(t *testing.T) {
t.Parallel()
stub := newSabStub(t)
s := newStubSab(t, stub)
c := Candidate{Payload: map[string]string{"link": "file:///etc/passwd"}}
_, err := s.Grab(context.Background(), c, t.TempDir(), nil)
if !errors.Is(err, ErrUnsafeURL) {
t.Errorf("error = %v, want ErrUnsafeURL", err)
}
}
func TestParseMB(t *testing.T) {
t.Parallel()
tests := []struct {
in string
want int64
}{
{"1.0", 1024 * 1024},
{"0", 0},
{" 2.5 ", int64(2.5 * 1024 * 1024)},
{"garbage", 0},
{"", 0},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
t.Parallel()
if got := parseMB(tt.in); got != tt.want {
t.Errorf("parseMB(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// Shared
// ---------------------------------------------------------------------------
// collectTree flattens whatever shape the transport produced, because
// the importer wants a flat set of paths inside staging.
func TestCollectTree(t *testing.T) {
t.Parallel()
t.Run("directory tree is flattened", func(t *testing.T) {
t.Parallel()
root := t.TempDir()
nested := filepath.Join(root, "CD1")
if err := os.MkdirAll(nested, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(
filepath.Join(root, "a.flac"), []byte("x"), 0o600,
); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(
filepath.Join(nested, "b.flac"), []byte("y"), 0o600,
); err != nil {
t.Fatalf("write: %v", err)
}
dst := t.TempDir()
got, err := collectTree(root, dst)
if err != nil {
t.Fatalf("collectTree: %v", err)
}
if len(got.Files) != 2 {
t.Fatalf("collected %d files, want 2", len(got.Files))
}
})
t.Run("single file", func(t *testing.T) {
t.Parallel()
root := t.TempDir()
file := filepath.Join(root, "single.flac")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
dst := t.TempDir()
got, err := collectTree(file, dst)
if err != nil {
t.Fatalf("collectTree: %v", err)
}
if len(got.Files) != 1 {
t.Fatalf("collected %d files, want 1", len(got.Files))
}
if filepath.Dir(got.Files[0]) != dst {
t.Errorf("file landed at %s, want inside %s", got.Files[0], dst)
}
})
t.Run("missing path errors", func(t *testing.T) {
t.Parallel()
if _, err := collectTree(
filepath.Join(t.TempDir(), "nope"), t.TempDir(),
); err == nil {
t.Error("want an error for a missing content path")
}
})
}
+351
View File
@@ -0,0 +1,351 @@
// Package download acquires music from user-configured external
// services and imports it into the library.
//
// The services users connect are not the same kind of thing: some
// search, some move bytes, some are whole automation systems we hand a
// request to. Rather than one interface every adapter half-implements,
// a provider fills one or more of three roles — Searcher, Transporter,
// Delegator — and declares which in its Caps. The pipeline composes
// them: a search-only provider (Prowlarr) is paired with a transport
// (qBittorrent, SABnzbd) by protocol at grab time, while providers that
// do both (slskd, yt-dlp) pair with themselves.
//
// Nothing here downloads into the library. Grabs land in a staging
// directory, are verified and tagged against the release the user
// actually asked for, and only then move into library paths.
package download
import (
"slices"
"strings"
"time"
)
// Kind identifies a provider implementation. It is stored in the
// database and used to look up the constructor in the registry, so
// values are stable strings and never renamed.
type Kind string
// Provider kinds.
const (
KindSlskd Kind = "slskd"
KindYtDlp Kind = "yt-dlp"
KindLidarr Kind = "lidarr"
KindProwlarr Kind = "prowlarr"
KindQBittorrent Kind = "qbittorrent"
KindSABnzbd Kind = "sabnzbd"
// KindFake is an in-memory provider used by tests. It is never
// offered in the UI.
KindFake Kind = "fake"
)
// Protocol is how a candidate's bytes are moved. Search-only providers
// report it so the pipeline can pick a compatible transport; providers
// that transport their own results use ProtocolDirect.
type Protocol string
// Transport protocols.
const (
// ProtocolDirect means the finding provider also does the fetch.
ProtocolDirect Protocol = "direct"
ProtocolTorrent Protocol = "torrent"
ProtocolUsenet Protocol = "usenet"
)
// Caps declares which roles a provider fills and which optional
// behaviours it supports. The frontend renders controls from this
// rather than switching on Kind, so a provider that gains resume
// support later needs no frontend change.
type Caps struct {
// Roles.
CanSearch bool `json:"canSearch"`
CanTransport bool `json:"canTransport"`
CanDelegate bool `json:"canDelegate"`
// CanList marks a provider that keeps a persistent wanted list of
// its own, which the reconciler mirrors this app's list into.
CanList bool `json:"canList"`
// Optional behaviours.
CanResume bool `json:"canResume"`
CanCancel bool `json:"canCancel"`
ReportsSize bool `json:"reportsSize"`
// Protocols this provider can transport. Empty for providers that
// only fetch their own search results.
Transports []Protocol `json:"transports"`
}
// Handles reports whether the provider can transport the given protocol.
func (c Caps) Handles(p Protocol) bool {
return slices.Contains(c.Transports, p)
}
// Request is what the user asked for. Requests that carry a MusicBrainz
// anchor are far more reliable than free-text ones, because the anchor
// gives the import step an expected tracklist to match against — so the
// pipeline records which it got and refuses to auto-pick without one.
type Request struct {
ID string `json:"id"`
// Anchors. Any may be empty; all empty means free-text.
ReleaseMBID string `json:"releaseMbid,omitempty"`
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
// RecordingMBID anchors a single-track request. Its Expected holds
// exactly that one track, which is what lets a track request be
// scored — and therefore auto-picked — on the same footing as an
// album.
RecordingMBID string `json:"recordingMbid,omitempty"`
// WantID links back to the wanted-list row this request was raised
// for, or 0 for a request the user started by hand. The reconciler
// writes the outcome back through it.
WantID int64 `json:"wantId,omitempty"`
// Source records where the request came from, for the downloads
// list. Empty means "manual".
Source string `json:"source,omitempty"`
// Display and query text. Artist/Album are what searches are built
// from; Query overrides them when the user typed something raw.
Artist string `json:"artist"`
Album string `json:"album"`
Query string `json:"query,omitempty"`
// Expected is the tracklist the anchor resolves to, used for
// completeness scoring and for the autotag match at import. Empty
// for free-text requests.
Expected []ExpectedTrack `json:"expected,omitempty"`
// LibraryID is the library imported files belong to.
LibraryID int64 `json:"libraryId"`
CreatedAt time.Time `json:"createdAt"`
}
// Anchored reports whether the request carries a MusicBrainz ID. Only
// anchored requests are eligible for auto-pick.
func (r Request) Anchored() bool {
return r.ReleaseMBID != "" ||
r.ReleaseGroupMBID != "" ||
r.RecordingMBID != ""
}
// SearchText returns the string to hand a provider's search endpoint.
func (r Request) SearchText() string {
if r.Query != "" {
return r.Query
}
return strings.TrimSpace(r.Artist + " " + r.Album)
}
// ExpectedTrack is one track of the release the user asked for.
type ExpectedTrack struct {
Position int `json:"position"`
DiscNumber int `json:"discNumber"`
Title string `json:"title"`
Artist string `json:"artist"`
LengthMillis int64 `json:"lengthMillis"`
}
// Candidate is one acquirable thing a provider found: a Soulseek user's
// folder, a torrent, a YouTube playlist. Providers fill the descriptive
// fields; the ranker fills Match, Quality and Score.
type Candidate struct {
// ID is unique within the provider that produced it, and is what
// gets handed back to Grab.
ID string `json:"id"`
ProviderID int64 `json:"providerId"`
Kind Kind `json:"kind"`
// Protocol determines which transport can fetch this.
Protocol Protocol `json:"protocol"`
// Descriptive.
Title string `json:"title"`
Artist string `json:"artist,omitempty"`
Origin string `json:"origin,omitempty"` // peer username, indexer name, channel
Files []CandidateFile `json:"files"`
TotalSize int64 `json:"totalSize"`
// Health is the provider's own availability signal, normalized to
// 0..1: seeder count for torrents, free upload slots and queue
// length for Soulseek. 0.5 when the provider has no signal.
Health float64 `json:"health"`
// Scores, filled by the ranker.
Match MatchScore `json:"match"`
Quality QualityScore `json:"quality"`
Score float64 `json:"score"`
// Payload is provider-private data needed to fetch this candidate
// (magnet URI, NZB URL, slskd file list). Never shown to the user.
Payload map[string]string `json:"-"`
}
// CandidateFile is one file inside a candidate. Soulseek and torrent
// results give paths and sizes but no tags, so Format and duration are
// inferred from the path and size where possible.
type CandidateFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
Format Format `json:"format"`
Bitrate int `json:"bitrate,omitempty"` // kbps, 0 when unknown
IsAudio bool `json:"isAudio"`
MatchedTo int `json:"matchedTo,omitempty"` // expected track position
}
// Format is a normalized audio container/codec name.
type Format string
// Audio formats, ordered by the quality ranking in formatRank.
const (
FormatUnknown Format = ""
FormatFLAC Format = "flac"
FormatALAC Format = "alac"
FormatWAV Format = "wav"
FormatMP3 Format = "mp3"
FormatAAC Format = "aac"
FormatOGG Format = "ogg"
FormatOpus Format = "opus"
FormatWMA Format = "wma"
)
// Lossless reports whether the format preserves the source exactly.
func (f Format) Lossless() bool {
return f == FormatFLAC || f == FormatALAC || f == FormatWAV
}
// Supported reports whether the player can decode this format. Grabs
// of unsupported formats are still allowed — the user may want them —
// but they rank below playable ones.
func (f Format) Supported() bool {
switch f {
case FormatMP3, FormatFLAC, FormatOGG, FormatWAV:
return true
case FormatUnknown, FormatALAC, FormatAAC, FormatOpus, FormatWMA:
return false
default:
return false
}
}
// MatchScore answers "is this the release the user asked for?" It is
// deliberately separate from QualityScore: a perfect match at 128kbps
// and a mediocre match in FLAC are different failures, and collapsing
// them into one number makes the ranking impossible to explain.
type MatchScore struct {
// Overall is 0..1.
Overall float64 `json:"overall"`
TitleFit float64 `json:"titleFit"` // filenames vs expected titles
ArtistFit float64 `json:"artistFit"` // path/origin vs expected artist
AlbumFit float64 `json:"albumFit"` // folder name vs album title
Completeness float64 `json:"completeness"` // audio files vs expected count
// Anchored records whether an MBID drove this score. Unanchored
// matches are capped, because there is nothing to be right about.
Anchored bool `json:"anchored"`
}
// QualityScore answers "is this a good copy?".
type QualityScore struct {
// Overall is 0..1.
Overall float64 `json:"overall"`
FormatRank float64 `json:"formatRank"` // FLAC > V0 > 320 > lower
Bitrate float64 `json:"bitrate"`
Health float64 `json:"health"` // seeders, free slots
Priority float64 `json:"priority"` // user's per-provider preference
// Mixed marks a candidate whose files are not all the same format,
// which usually means a hand-assembled folder rather than a rip.
Mixed bool `json:"mixed"`
}
// AudioFiles returns only the audio entries of a candidate.
func (c Candidate) AudioFiles() []CandidateFile {
out := make([]CandidateFile, 0, len(c.Files))
for _, f := range c.Files {
if f.IsAudio {
out = append(out, f)
}
}
return out
}
// State is the lifecycle position of a download item.
type State string
// Download item states. Searching through Importing are live;
// Complete, Cancelled and Failed are terminal.
const (
StateSearching State = "searching"
StateFound State = "found"
StateQueued State = "queued"
StateGrabbing State = "grabbing"
StateVerifying State = "verifying"
StateTagging State = "tagging"
StateImporting State = "importing"
StateComplete State = "complete"
StateCancelled State = "cancelled"
StateFailed State = "failed"
)
// IsTerminal reports whether the state means no further progress will
// happen without a new attempt.
func (s State) IsTerminal() bool {
return s == StateComplete || s == StateCancelled || s == StateFailed
}
// Progress is a transport's periodic report. Total is 0 when the
// provider cannot say how large the transfer is.
type Progress struct {
Current int64
Total int64
Phase string
}
// ProgressFunc receives transport progress. Implementations must
// tolerate being called from any goroutine and at high frequency.
type ProgressFunc func(Progress)
// Result is what a transport produced.
type Result struct {
// Dir is the staging directory the files landed in.
Dir string
// Files are absolute paths, all under Dir.
Files []string
// BytesTransferred is what actually moved, for reporting.
BytesTransferred int64
// Delegated marks a result produced by an external manager that has
// already imported the files into its own library. Files are then
// absolute paths outside staging, and the pipeline records them
// where they are instead of tagging and moving them.
Delegated bool
}
// DelegateStatus is a delegating manager's answer to "are we there
// yet?".
type DelegateStatus struct {
State State
// Progress is 0..1 when the manager reports it, -1 when it does not.
Progress float64
// ImportedPaths are files the manager has already placed on disk.
// A delegate that imports into its own library reports them here so
// the pipeline can reconcile rather than re-import.
ImportedPaths []string
Message string
}
+236
View File
@@ -0,0 +1,236 @@
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
}
+378
View File
@@ -0,0 +1,378 @@
package download
import (
"context"
"testing"
"time"
)
func TestNextRetryClimbsAndCaps(t *testing.T) {
t.Parallel()
now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
// The jitter makes exact equality wrong to assert, so each step is
// checked as a band around its nominal delay.
tests := []struct {
attempts int
nominal time.Duration
}{
{attempts: 1, nominal: wantRetryBase},
{attempts: 2, nominal: 2 * wantRetryBase},
{attempts: 3, nominal: 4 * wantRetryBase},
{attempts: 20, nominal: wantRetryMax},
{attempts: 500, nominal: wantRetryMax},
}
for _, tt := range tests {
got := nextRetry(now, tt.attempts).Sub(now)
lo := time.Duration(float64(tt.nominal) * (1 - wantRetryJitter))
hi := time.Duration(float64(tt.nominal) * (1 + wantRetryJitter))
if got < lo || got > hi {
t.Errorf(
"attempts=%d delay=%v, want within [%v, %v]",
tt.attempts, got, lo, hi,
)
}
}
}
// A want that has been retried for years must not overflow into a
// negative delay, which would make it due forever.
func TestNextRetryNeverGoesBackwards(t *testing.T) {
t.Parallel()
now := time.Now()
for _, attempts := range []int{0, 1, 64, 1000, 1 << 20} {
if next := nextRetry(now, attempts); !next.After(now) {
t.Errorf("attempts=%d scheduled %v, not after now", attempts, next)
}
}
}
func TestReleaseDateAfter(t *testing.T) {
t.Parallel()
since := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
date string
want bool
}{
{name: "later full date", date: "2026-06-01", want: true},
{name: "earlier full date", date: "2026-01-01", want: false},
{name: "same day is not after", date: "2026-03-15", want: false},
{name: "later year", date: "2027", want: true},
// A bare year is read as its 1 January, so the year of the
// subscription itself does not count as new.
{name: "same year, bare", date: "2026", want: false},
{name: "later month, bare", date: "2026-08", want: true},
{name: "earlier month, bare", date: "2026-02", want: false},
{name: "unknown date is old", date: "", want: false},
}
for _, tt := range tests {
if got := releaseDateAfter(tt.date, since); got != tt.want {
t.Errorf("%s: releaseDateAfter(%q) = %v, want %v",
tt.name, tt.date, got, tt.want)
}
}
}
func TestWantsReleaseGroupFilters(t *testing.T) {
t.Parallel()
subscribed := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
future := Want{Scope: ScopeFuture, CreatedAt: subscribed}
all := Want{Scope: ScopeAll, CreatedAt: subscribed}
allSecondary := Want{
Scope: ScopeAll, Secondary: true, CreatedAt: subscribed,
}
newAlbum := CatalogItem{MBID: "a", FirstReleaseDate: "2026-09-01"}
oldAlbum := CatalogItem{MBID: "b", FirstReleaseDate: "1997-04-22"}
ownedAlbum := CatalogItem{
MBID: "c", FirstReleaseDate: "2026-09-01", InLibrary: true,
}
liveAlbum := CatalogItem{
MBID: "d",
FirstReleaseDate: "2026-09-01",
SecondaryTypes: []string{"Live"},
}
tests := []struct {
name string
artist Want
rg CatalogItem
want bool
}{
{name: "future takes new", artist: future, rg: newAlbum, want: true},
{name: "future skips old", artist: future, rg: oldAlbum, want: false},
{name: "all takes old", artist: all, rg: oldAlbum, want: true},
{name: "owned is never wanted", artist: all, rg: ownedAlbum, want: false},
{name: "secondary off skips live", artist: all, rg: liveAlbum, want: false},
{
name: "secondary on takes live",
artist: allSecondary,
rg: liveAlbum,
want: true,
},
{
name: "no mbid is unusable",
artist: all,
rg: CatalogItem{FirstReleaseDate: "2026-09-01"},
want: false,
},
}
for _, tt := range tests {
if got := wantsReleaseGroup(tt.artist, tt.rg); got != tt.want {
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
}
}
}
func TestWantToRequestAnchors(t *testing.T) {
t.Parallel()
tests := []struct {
entity Entity
check func(Request) string
}{
{
entity: EntityReleaseGroup,
check: func(r Request) string {
return r.ReleaseGroupMBID
},
},
{
entity: EntityRelease,
check: func(r Request) string { return r.ReleaseMBID },
},
{
entity: EntityRecording,
check: func(r Request) string { return r.RecordingMBID },
},
}
for _, tt := range tests {
w := Want{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
req := w.ToRequest("req-1")
if got := tt.check(req); got != "mbid-x" {
t.Errorf("%s: anchor = %q, want mbid-x", tt.entity, got)
}
if !req.Anchored() {
t.Errorf("%s: request is not anchored", tt.entity)
}
if req.WantID != 7 {
t.Errorf("%s: WantID = %d, want 7", tt.entity, req.WantID)
}
if req.Source != wantSource {
t.Errorf("%s: Source = %q, want %q", tt.entity, req.Source, wantSource)
}
}
}
// An anchored request with no tracklist has nothing to verify itself
// against, so it must not be auto-picked no matter how good the
// candidate looks.
func TestAutoPickableRequiresTracklist(t *testing.T) {
t.Parallel()
req := Request{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
ranked := []Candidate{{
Match: MatchScore{Overall: 0.99, Anchored: true},
Quality: QualityScore{Overall: 0.9},
Score: 0.95,
}}
if AutoPickable(req, ranked) {
t.Error("auto-picked a request with no expected tracklist")
}
req.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
if !AutoPickable(req, ranked) {
t.Error("did not auto-pick a well-anchored, well-matched request")
}
}
// The wanted list's identity is the MBID, so the same one arriving
// twice — in a different case, with whitespace — is one row.
func TestAddWantNormalizesAndDeduplicates(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
ctx := context.Background()
first, err := f.store.AddWant(ctx, Want{
MBID: " ABC-123 ",
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
}
second, err := f.store.AddWant(ctx, Want{
MBID: "abc-123",
Entity: EntityReleaseGroup,
LibraryID: 1,
})
if err != nil {
t.Fatalf("AddWant again: %v", err)
}
if first != second {
t.Errorf("ids %d and %d, want the same row", first, second)
}
// Re-adding with no title must not wipe the one we have.
w, err := f.store.GetWant(ctx, first)
if err != nil {
t.Fatalf("GetWant: %v", err)
}
if w.Title != "OK Computer" {
t.Errorf("title = %q, want it preserved", w.Title)
}
if w.MBID != "abc-123" {
t.Errorf("mbid = %q, want normalized", w.MBID)
}
}
func TestWantStoreLifecycle(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
ctx := context.Background()
id, err := f.store.AddWant(ctx, Want{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
}
// A brand new want is due immediately.
due, err := f.store.ListDueWants(ctx, 10)
if err != nil {
t.Fatalf("ListDueWants: %v", err)
}
if len(due) != 1 {
t.Fatalf("got %d due wants, want 1", len(due))
}
// Recording an attempt pushes it out of the due set without
// changing its state: a want that was not found is still wanted.
if err := f.store.RecordAttempt(ctx, id, 0, "no source has it yet"); err != nil {
t.Fatalf("RecordAttempt: %v", err)
}
due, err = f.store.ListDueWants(ctx, 10)
if err != nil {
t.Fatalf("ListDueWants after attempt: %v", err)
}
if len(due) != 0 {
t.Errorf("got %d due wants after an attempt, want 0", len(due))
}
w, err := f.store.GetWant(ctx, id)
if err != nil {
t.Fatalf("GetWant: %v", err)
}
if w.State != WantStateWanted {
t.Errorf("state = %q, want it still wanted", w.State)
}
if w.Attempts != 1 {
t.Errorf("attempts = %d, want 1", w.Attempts)
}
if w.LastError == "" {
t.Error("last error was not recorded")
}
if err := f.store.SatisfyWant(ctx, id); err != nil {
t.Fatalf("SatisfyWant: %v", err)
}
w, err = f.store.GetWant(ctx, id)
if err != nil {
t.Fatalf("GetWant after satisfy: %v", err)
}
if w.State != WantStateSatisfied {
t.Errorf("state = %q, want satisfied", w.State)
}
}
// Removing an artist subscription takes the albums it derived with it,
// so a user who unsubscribes does not keep downloading that artist.
func TestDeleteArtistWantCascadesToChildren(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
ctx := context.Background()
artist, err := f.store.AddWant(ctx, Want{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Artist: "Radiohead",
})
if err != nil {
t.Fatalf("AddWant artist: %v", err)
}
if _, err := f.store.AddWant(ctx, Want{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
ParentID: artist,
}); err != nil {
t.Fatalf("AddWant child: %v", err)
}
children, err := f.store.ListChildWants(ctx, artist)
if err != nil {
t.Fatalf("ListChildWants: %v", err)
}
if len(children) != 1 {
t.Fatalf("got %d children, want 1", len(children))
}
if err := f.store.DeleteWant(ctx, artist); err != nil {
t.Fatalf("DeleteWant: %v", err)
}
all, err := f.store.ListWants(ctx)
if err != nil {
t.Fatalf("ListWants: %v", err)
}
if len(all) != 0 {
t.Errorf("got %d wants after deleting the artist, want 0", len(all))
}
}
+307
View File
@@ -0,0 +1,307 @@
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,
}
}
+217
View File
@@ -0,0 +1,217 @@
package backend
import (
"context"
"yellowjacket/backend/download"
"yellowjacket/backend/explore"
)
// exploreCatalog adapts the explore service to the narrow view of the
// music world the wanted list needs.
//
// The adapter lives here, in the composition root, rather than in
// either package: explore should not know that downloads exist, and
// download should not pull in the whole explore index to ask four
// questions. Everything below is a translation, with no policy of its
// own — policy belongs to the reconciler.
type exploreCatalog struct {
explore *explore.Service
}
// newExploreCatalog wires the wanted list to the explore index.
func newExploreCatalog(e *explore.Service) download.CatalogPort {
return &exploreCatalog{explore: e}
}
// ReleaseGroupsForArtist returns an artist's discography.
//
// An empty result is not an error. The explore index fetches
// discographies lazily, so the first time an artist is subscribed to
// the honest answer is "not indexed yet" — and BrowseReleaseGroups has
// already kicked off the background fetch that makes the next pass
// useful.
func (c *exploreCatalog) ReleaseGroupsForArtist(
_ context.Context,
artistMBID string,
) ([]download.CatalogItem, error) {
groups, err := c.explore.BrowseReleaseGroups(artistMBID)
if err != nil {
return nil, err
}
out := make([]download.CatalogItem, 0, len(groups))
for _, rg := range groups {
out = append(out, download.CatalogItem{
MBID: rg.MBID,
Title: rg.Title,
Artist: rg.ArtistCredit,
ArtistMBID: rg.ArtistMBID,
PrimaryType: rg.PrimaryType,
SecondaryTypes: rg.SecondaryTypes,
FirstReleaseDate: rg.FirstReleaseDate,
InLibrary: rg.InLibrary,
})
}
return out, nil
}
// Tracklist resolves what a want should contain.
//
// For an album this is the tracklist of its best release, which is what
// the download pipeline verifies an unattended grab against. For a
// single track it is that one track, built from the want's own cached
// title — there is no recording lookup on the index, and inventing one
// to learn a title the UI already passed in would be work for its own
// sake.
func (c *exploreCatalog) Tracklist(
_ context.Context,
entity download.Entity,
mbid string,
) ([]download.ExpectedTrack, error) {
if entity == download.EntityRecording {
// Handled by the reconciler from the want's own fields; see
// recordingTracklist below.
return nil, nil
}
releases, err := c.explore.BrowseReleases(mbid)
if err != nil {
return nil, err
}
best := bestRelease(releases, mbid)
if best == nil {
return nil, nil
}
out := make([]download.ExpectedTrack, 0, len(best.Tracks))
for _, t := range best.Tracks {
out = append(out, download.ExpectedTrack{
Position: t.Position,
DiscNumber: t.DiscNumber,
Title: t.Title,
Artist: best.ArtistCredit,
LengthMillis: int64(t.Length),
})
}
return out, nil
}
// bestRelease picks which edition of an album to match a download
// against.
//
// When the want named a specific release, that one. Otherwise the one
// with the most tracks that still has a tracklist at all: a download
// scored against a truncated tracklist looks incomplete when it is
// fine, and a false "missing tracks" reading is what stops a good
// candidate from clearing the auto-pick bar.
func bestRelease(releases []explore.MBRelease, wantedMBID string) *explore.MBRelease {
var best *explore.MBRelease
for i := range releases {
r := &releases[i]
if r.MBID == wantedMBID && len(r.Tracks) > 0 {
return r
}
if len(r.Tracks) == 0 {
continue
}
if best == nil || len(r.Tracks) > len(best.Tracks) {
best = r
}
}
return best
}
// Owns reports whether the library already has what an MBID names.
//
// Releases are the gap: the library records release groups and
// recordings, not specific editions, so a want for one particular
// pressing is only retired when its own download completes. That is
// the right failure — quietly satisfying a "want the 1997 Japanese
// pressing" because some edition is owned would be answering a
// different question than the one asked.
func (c *exploreCatalog) Owns(
_ context.Context,
entity download.Entity,
mbid string,
) (bool, error) {
if mbid == "" || entity == download.EntityRelease {
return false, nil
}
found := c.explore.CheckLibraryMBIDs([]string{mbid})
kind, ok := found[mbid]
if !ok {
return false, nil
}
switch entity {
case download.EntityReleaseGroup:
return kind == "release_group", nil
case download.EntityRecording:
return kind == "recording", nil
case download.EntityArtist:
return kind == "artist", nil
case download.EntityRelease:
return false, nil
default:
return false, nil
}
}
// Describe fills in display text for a want added as a bare MBID.
func (c *exploreCatalog) Describe(
_ context.Context,
entity download.Entity,
mbid string,
) (download.CatalogItem, bool) {
switch entity {
case download.EntityArtist:
artist, err := c.explore.LookupArtist(mbid)
if err != nil || artist == nil {
return download.CatalogItem{}, false
}
return download.CatalogItem{
MBID: mbid,
Artist: artist.Name,
Title: artist.Name,
}, true
case download.EntityReleaseGroup, download.EntityRelease:
rg, err := c.explore.LookupReleaseGroup(mbid)
if err != nil || rg == nil {
return download.CatalogItem{}, false
}
return download.CatalogItem{
MBID: mbid,
Title: rg.Title,
Artist: rg.ArtistCredit,
ArtistMBID: rg.ArtistMBID,
PrimaryType: rg.PrimaryType,
SecondaryTypes: rg.SecondaryTypes,
FirstReleaseDate: rg.FirstReleaseDate,
InLibrary: rg.InLibrary,
}, true
case download.EntityRecording:
// No recording lookup on the index; the caller keeps whatever
// title it was given.
return download.CatalogItem{}, false
default:
return download.CatalogItem{}, false
}
}
+17
View File
@@ -117,4 +117,21 @@ const (
// the album detail page can re-fetch its versions / tracklist without
// the initial request having blocked on a live MusicBrainz browse.
AlbumReleasesReady = "AlbumReleasesReady"
// DownloadProvidersChanged fires after a download client is added,
// edited, enabled/disabled or removed, so the settings page and any
// open download picker re-read the provider list.
DownloadProvidersChanged = "DownloadProvidersChanged"
// DownloadsChanged fires when the set of download requests changes
// (started, picked, cancelled, cleared). Per-transfer progress does
// not use this — it flows through the jobs registry's JobsChanged,
// which already coalesces high-frequency updates.
DownloadsChanged = "DownloadsChanged"
// WantedListChanged fires when the wanted list gains, loses or
// retires an entry — including from a background reconcile pass,
// which is why the list is event-driven rather than fetched once on
// mount.
WantedListChanged = "WantedListChanged"
)
+87
View File
@@ -0,0 +1,87 @@
package explore
import (
"context"
"yellowjacket/backend/jobs"
)
// Fetching and merging the prebuilt catalog artifact.
//
// This is the whole catalog build on a user's machine. The import that
// derives the catalog from the MetaBrainz dumps lives behind the
// `indexbuild` tag and runs only in CI (see dumpbuild_stub.go).
// Artifact build stages, shown in the jobs panel.
const (
artifactStageDownload = iota
artifactStageMerge
)
var artifactStageNames = [...]string{
"Downloading catalog",
"Merging catalog",
}
// tryCoreArtifact fetches and merges the prebuilt catalog. Every
// failure path is non-fatal by design: the caller falls back, and a
// fresh install with no network still gets its own library in Explore.
func (si *SearchIndex) tryCoreArtifact(ctx context.Context) error {
si.mu.Lock()
si.buildStatus = IndexStatus{
Building: true,
Tiers: []TierStatus{
{Name: artifactStageNames[artifactStageDownload], State: "pending"},
{Name: artifactStageNames[artifactStageMerge], State: "pending"},
},
}
si.mu.Unlock()
si.refreshStatusCounts()
fetcher, err := newArtifactFetcher(si)
if err != nil {
return err
}
si.setTierStatus(artifactStageNames[artifactStageDownload], "running", 0, 0)
si.logIndexJob(jobs.LevelInfo, "Fetching prebuilt catalog")
path, err := fetcher.fetch(ctx)
if err != nil {
si.setTierStatus(artifactStageNames[artifactStageDownload], "error", 0, 0)
return err
}
si.setTierStatus(artifactStageNames[artifactStageDownload], "complete", 0, 0)
si.setTierStatus(artifactStageNames[artifactStageMerge], "running", 0, 0)
if err := si.importCoreArtifact(ctx, path); err != nil {
si.setTierStatus(artifactStageNames[artifactStageMerge], "error", 0, 0)
si.removeArtifactFile(path)
return err
}
si.removeArtifactFile(path)
si.setTierStatus(artifactStageNames[artifactStageMerge], "complete", 0, 0)
si.mu.Lock()
si.buildStatus.Building = false
si.mu.Unlock()
// Fold the user's own library into the freshly-merged catalog:
// owned entities the artifact does not cover are inserted, and
// covered ones are flagged in_library.
si.PopulateLocalCrossReferences()
si.refreshStatusCounts()
si.scheduleChampionRebuild()
return nil
}
// artifactAlreadyMerged reports whether this index already carries a
// merged artifact, so a restart does not re-download one.
func (si *SearchIndex) artifactAlreadyMerged() bool {
return si.hasMeta(coreArtifactVersionKey)
}
+415
View File
@@ -0,0 +1,415 @@
package explore
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/klauspost/compress/zstd"
"yellowjacket/backend/jobs"
"yellowjacket/backend/system"
)
// Download of the prebuilt core index artifact.
//
// The artifact is published by .gitea/workflows/index-artifact.yml to a
// Gitea generic package registry under a fixed "latest" version, so the
// client needs no package-listing API (which requires a token) — only an
// anonymous GET of a predictable URL.
//
// Everything here degrades to "no artifact" rather than failing: an
// offline install, a 404 before the first artifact is published, or a
// corrupt download all leave the caller free to fall back to the normal
// build path. A missing artifact must never be the reason Explore is
// broken.
const (
// defaultCoreArtifactBaseURL is where the CI-built artifact lives.
// Overridable via YJ_CORE_INDEX_URL for testing and for anyone
// self-hosting their own index.
defaultCoreArtifactBaseURL = "https://git.ljones.me/api/packages/yonlu/" +
"generic/yellowjacket-core-index/latest/"
// coreArtifactFile is the compressed artifact's filename, and
// coreArtifactChecksumFile its detached sha256.
coreArtifactFile = "core-index.db.zst"
coreArtifactChecksum = "core-index.db.zst.sha256"
// artifactURLEnv overrides the base URL.
artifactURLEnv = "YJ_CORE_INDEX_URL"
// artifactDiscoverTimeout bounds the checksum fetch, which doubles as
// the availability probe. Short: a first run should not sit for
// minutes deciding whether an artifact exists.
artifactDiscoverTimeout = 30 * time.Second
// artifactMinFreeBytes is the free disk needed to fetch and unpack.
// The compressed artifact plus its expansion plus merge headroom —
// two orders of magnitude below the full dump import's 6GB floor,
// which is much of the point.
artifactMinFreeBytes = 1 << 30
// artifactMaxRetries bounds resume attempts for the body download.
artifactMaxRetries = 5
)
// ErrArtifactUnavailable means no artifact could be fetched. It is an
// expected outcome (offline, not yet published), not a failure.
var ErrArtifactUnavailable = errors.New("core index artifact unavailable")
// coreArtifactBaseURL resolves the artifact location, honouring the
// environment override.
func coreArtifactBaseURL() string {
if v := strings.TrimSpace(os.Getenv(artifactURLEnv)); v != "" {
if !strings.HasSuffix(v, "/") {
return v + "/"
}
return v
}
return defaultCoreArtifactBaseURL
}
// artifactFetcher downloads and unpacks the core index artifact.
type artifactFetcher struct {
si *SearchIndex
client *http.Client
baseURL string
stagingDir string
}
func newArtifactFetcher(si *SearchIndex) (*artifactFetcher, error) {
dataDir, err := system.GetUserDataDirPath()
if err != nil {
return nil, fmt.Errorf("core artifact: data dir: %w", err)
}
stagingDir := filepath.Join(dataDir, "explore-staging")
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
return nil, fmt.Errorf("core artifact: staging dir: %w", err)
}
return &artifactFetcher{
si: si,
// No client-level timeout: a 70MB body over a slow link can take
// a while. Stalls are handled by resume rather than by killing
// the whole download.
client: &http.Client{},
baseURL: coreArtifactBaseURL(),
stagingDir: stagingDir,
}, nil
}
// compressedPath and unpackedPath are the two staging files. Both live
// beside the dump importer's own staging data and are removed on success.
func (f *artifactFetcher) compressedPath() string {
return filepath.Join(f.stagingDir, coreArtifactFile)
}
func (f *artifactFetcher) unpackedPath() string {
return filepath.Join(f.stagingDir, "core-index.db")
}
// fetch downloads, verifies and decompresses the artifact, returning the
// path to the ready-to-merge database.
func (f *artifactFetcher) fetch(ctx context.Context) (string, error) {
if err := checkFreeDisk(f.stagingDir, artifactMinFreeBytes); err != nil {
return "", err
}
want, err := f.fetchChecksum(ctx)
if err != nil {
return "", err
}
if err := f.download(ctx); err != nil {
return "", err
}
got, err := fileSHA256(f.compressedPath())
if err != nil {
return "", err
}
if got != want {
// A partial file that resumed against a newer published artifact
// would fail here forever; discard it so the next attempt starts
// clean rather than re-resuming into the same mismatch.
_ = os.Remove(f.compressedPath())
return "", fmt.Errorf("%w: checksum mismatch (got %s, want %s)",
ErrArtifactUnusable, got, want)
}
if err := f.decompress(ctx); err != nil {
return "", err
}
// The compressed copy is dead weight once unpacked.
_ = os.Remove(f.compressedPath())
return f.unpackedPath(), nil
}
// fetchChecksum retrieves the expected sha256. This doubles as the
// availability probe: it is a few bytes, so a missing or unreachable
// artifact is discovered without starting a large download.
func (f *artifactFetcher) fetchChecksum(ctx context.Context) (string, error) {
probeCtx, cancel := context.WithTimeout(ctx, artifactDiscoverTimeout)
defer cancel()
req, err := http.NewRequestWithContext(
probeCtx, http.MethodGet, f.baseURL+coreArtifactChecksum, nil)
if err != nil {
return "", fmt.Errorf("%w: checksum request: %w", ErrArtifactUnavailable, err)
}
req.Header.Set("User-Agent", lbUserAgent)
resp, err := f.client.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %w", ErrArtifactUnavailable, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: HTTP %d fetching checksum",
ErrArtifactUnavailable, resp.StatusCode)
}
// The file is `sha256sum` output: "<hex> <filename>".
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return "", fmt.Errorf("%w: read checksum: %w", ErrArtifactUnavailable, err)
}
sum := strings.TrimSpace(string(body))
if i := strings.IndexAny(sum, " \t"); i > 0 {
sum = sum[:i]
}
if len(sum) != sha256.Size*2 {
return "", fmt.Errorf("%w: malformed checksum %q", ErrArtifactUnusable, sum)
}
return strings.ToLower(sum), nil
}
// download fetches the artifact body, resuming a partial file with a
// Range request rather than restarting it.
func (f *artifactFetcher) download(ctx context.Context) error {
var lastErr error
for attempt := range artifactMaxRetries {
if err := ctx.Err(); err != nil {
return err
}
if attempt > 0 {
delay := min(streamRetryBaseDelay<<(attempt-1), streamRetryMaxDelay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
err := f.downloadOnce(ctx)
if err == nil {
return nil
}
lastErr = err
f.si.logger.Warn("core artifact: download attempt failed",
"attempt", attempt+1, "error", err,
)
}
return fmt.Errorf("%w: after %d attempts: %w",
ErrArtifactUnavailable, artifactMaxRetries, lastErr)
}
func (f *artifactFetcher) downloadOnce(ctx context.Context) error {
// A file left by a previous attempt is resumed from its length.
var have int64
if fi, err := os.Stat(f.compressedPath()); err == nil {
have = fi.Size()
}
req, err := http.NewRequestWithContext(
ctx, http.MethodGet, f.baseURL+coreArtifactFile, nil)
if err != nil {
return fmt.Errorf("artifact request: %w", err)
}
req.Header.Set("User-Agent", lbUserAgent)
if have > 0 {
req.Header.Set("Range", "bytes="+strconv.FormatInt(have, 10)+"-")
}
resp, err := f.client.Do(req)
if err != nil {
return fmt.Errorf("artifact fetch: %w", err)
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusPartialContent:
// Resuming: append.
case http.StatusOK:
// The server ignored the Range header (or there was nothing to
// resume), so this is a whole-file body and the partial must go.
have = 0
default:
return fmt.Errorf("%w: HTTP %d fetching artifact",
ErrArtifactUnavailable, resp.StatusCode)
}
flags := os.O_CREATE | os.O_WRONLY
if have > 0 {
flags |= os.O_APPEND
} else {
flags |= os.O_TRUNC
}
file, err := os.OpenFile(f.compressedPath(), flags, 0o644)
if err != nil {
return fmt.Errorf("open artifact file: %w", err)
}
defer func() { _ = file.Close() }()
total := have + resp.ContentLength
if _, err := io.Copy(file, f.progressReader(resp.Body, have, total)); err != nil {
return fmt.Errorf("artifact download: %w", err)
}
return file.Close()
}
// progressReader wraps the body so download progress reaches the jobs
// panel, since this is the one visible wait on a fresh install.
func (f *artifactFetcher) progressReader(r io.Reader, done, total int64) io.Reader {
return &artifactProgress{
inner: r,
done: done,
total: total,
si: f.si,
last: time.Now(),
}
}
type artifactProgress struct {
inner io.Reader
done, total int64
si *SearchIndex
last time.Time
}
func (p *artifactProgress) Read(b []byte) (int, error) {
n, err := p.inner.Read(b)
p.done += int64(n)
if time.Since(p.last) >= time.Second {
p.last = time.Now()
detail := formatGB(p.done)
if p.total > 0 {
detail = fmt.Sprintf("%.0f%% of %s",
100*float64(p.done)/float64(p.total), formatGB(p.total))
}
// Reported in KiB so a multi-hundred-MB artifact cannot overflow
// the int progress fields on a 32-bit build.
p.si.setTierDetail(
artifactStageNames[artifactStageDownload], "running",
int(p.done>>10), int(p.total>>10), detail,
)
}
if err != nil && !errors.Is(err, io.EOF) {
return n, fmt.Errorf("artifact body read: %w", err)
}
return n, err //nolint:wrapcheck // io.EOF must reach the caller unwrapped.
}
// decompress expands the zstd artifact into the staging directory.
func (f *artifactFetcher) decompress(ctx context.Context) error {
src, err := os.Open(f.compressedPath())
if err != nil {
return fmt.Errorf("%w: open compressed artifact: %w", ErrArtifactUnusable, err)
}
defer func() { _ = src.Close() }()
zr, err := zstd.NewReader(src)
if err != nil {
return fmt.Errorf("%w: zstd reader: %w", ErrArtifactUnusable, err)
}
defer zr.Close()
dst, err := os.Create(f.unpackedPath())
if err != nil {
return fmt.Errorf("%w: create artifact db: %w", ErrArtifactUnusable, err)
}
defer func() { _ = dst.Close() }()
f.si.logIndexJob(jobs.LevelInfo, "Unpacking prebuilt catalog")
if _, err := io.Copy(dst, zr.IOReadCloser()); err != nil {
// A half-written database would be rejected by inspectArtifact,
// but leaving it around means the next run re-reads the same
// wreckage before rejecting it.
_ = os.Remove(f.unpackedPath())
return fmt.Errorf("%w: decompress: %w", ErrArtifactUnusable, err)
}
if err := ctx.Err(); err != nil {
_ = os.Remove(f.unpackedPath())
return err
}
return dst.Close()
}
// fileSHA256 hashes a file. The whole file is hashed after the download
// completes rather than incrementally, because a resumed download never
// sees the bytes it skipped.
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("%w: open for checksum: %w", ErrArtifactUnusable, err)
}
defer func() { _ = file.Close() }()
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return "", fmt.Errorf("%w: checksum read: %w", ErrArtifactUnusable, err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+206
View File
@@ -0,0 +1,206 @@
package explore
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/klauspost/compress/zstd"
"yellowjacket/backend/database"
)
// artifactServer serves a compressed artifact and its checksum the way
// the Gitea generic package registry does.
func artifactServer(t *testing.T, body []byte) *httptest.Server {
t.Helper()
sum := sha256.Sum256(body)
checksum := hex.EncodeToString(sum[:]) + " " + coreArtifactFile + "\n"
mux := http.NewServeMux()
mux.HandleFunc("/"+coreArtifactChecksum,
func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(checksum))
})
// ServeContent gives the stub real Range support, so the resume path
// is exercised against the same semantics as the package registry.
mux.HandleFunc("/"+coreArtifactFile,
func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(
w, r, coreArtifactFile, time.Time{}, bytes.NewReader(body))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// compressArtifact zstd-compresses a file the way CI publishes it.
func compressArtifact(t *testing.T, path string) []byte {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read artifact: %v", err)
}
enc, err := zstd.NewWriter(nil)
if err != nil {
t.Fatalf("zstd writer: %v", err)
}
defer func() { _ = enc.Close() }()
return enc.EncodeAll(raw, nil)
}
// withArtifactEnv points the fetcher at a stub server and gives it an
// isolated data directory.
func withArtifactEnv(t *testing.T, baseURL string) {
t.Helper()
t.Setenv(artifactURLEnv, baseURL)
t.Setenv("YJ_HOME", t.TempDir())
}
func TestFetchAndMergeArtifactEndToEnd(t *testing.T) {
src := writeTestArtifact(t, validMeta(), []artifactRow{
{"artist", artA, "Artist A", "Artist A", artA, 5000},
{"release_group", rgA, "Album A", "Artist A", artA, 3000},
{"recording", recA, "Song A", "Artist A", artA, 2000},
})
srv := artifactServer(t, compressArtifact(t, src))
withArtifactEnv(t, srv.URL+"/")
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, testLogger())
if err := si.tryCoreArtifact(context.Background()); err != nil {
t.Fatalf("tryCoreArtifact: %v", err)
}
var got int
if err := db.QueryRowWriter(
"SELECT COUNT(*) FROM explore_index",
).Scan(&got); err != nil {
t.Fatalf("count rows: %v", err)
}
if got != 3 {
t.Errorf("merged %d rows, want 3", got)
}
if !si.artifactAlreadyMerged() {
t.Error("artifact merge not recorded; a restart would re-download it")
}
// Staging must not keep a few hundred MB around after success.
staging := filepath.Join(os.Getenv("YJ_HOME"), "data", "explore-staging")
for _, name := range []string{coreArtifactFile, "core-index.db"} {
if _, err := os.Stat(filepath.Join(staging, name)); err == nil {
t.Errorf("%s left behind in staging after import", name)
}
}
}
func TestFetchArtifactRejectsBadChecksum(t *testing.T) {
src := writeTestArtifact(t, validMeta(), []artifactRow{
{"artist", artA, "Artist A", "Artist A", artA, 5000},
})
body := compressArtifact(t, src)
// Serve a checksum for different content.
mux := http.NewServeMux()
mux.HandleFunc("/"+coreArtifactChecksum,
func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(strings.Repeat("a", 64) + " " + coreArtifactFile))
})
mux.HandleFunc("/"+coreArtifactFile,
func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
})
srv := httptest.NewServer(mux)
defer srv.Close()
withArtifactEnv(t, srv.URL+"/")
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, testLogger())
err := si.tryCoreArtifact(context.Background())
if err == nil {
t.Fatal("expected checksum rejection")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Errorf("error = %v, want a checksum mismatch", err)
}
// A corrupt download must not leave the index claiming a catalog.
if si.hasMeta(dumpImportDoneKey) || si.artifactAlreadyMerged() {
t.Error("failed download still marked the catalog as imported")
}
}
// A missing artifact is an ordinary outcome — the app has to keep
// working before the first one is ever published.
func TestFetchArtifactMissingIsUnavailable(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
withArtifactEnv(t, srv.URL+"/")
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, testLogger())
err := si.tryCoreArtifact(context.Background())
if !errors.Is(err, ErrArtifactUnavailable) {
t.Errorf("error = %v, want ErrArtifactUnavailable", err)
}
}
// The download resumes rather than restarting, which is what makes a
// large artifact survive a flaky connection.
func TestFetchArtifactResumesPartialDownload(t *testing.T) {
src := writeTestArtifact(t, validMeta(), []artifactRow{
{"artist", artA, "Artist A", "Artist A", artA, 5000},
})
body := compressArtifact(t, src)
srv := artifactServer(t, body)
withArtifactEnv(t, srv.URL+"/")
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, testLogger())
fetcher, err := newArtifactFetcher(si)
if err != nil {
t.Fatalf("newArtifactFetcher: %v", err)
}
// Pre-seed a truncated download.
half := len(body) / 2
if err := os.WriteFile(fetcher.compressedPath(), body[:half], 0o644); err != nil {
t.Fatalf("seed partial download: %v", err)
}
if _, err := fetcher.fetch(context.Background()); err != nil {
t.Fatalf("fetch after partial: %v", err)
}
}

Some files were not shown because too many files have changed in this diff Show More