50 Commits

Author SHA1 Message Date
Jonathan 395944f3e1 fix(worker): scale slskd download backstop to album size (stop abandoning big albums)
The absolute download backstop was a flat _MAX_XFER_POLLS=1200 (~60min). A large
album (e.g. Drake "Scorpion", 521MB / 25 FLACs) from a slow-but-healthy serial
peer (~400KB/s, serving one file at a time) needs ~60-70min, so it was cut off
at 60min and — because an abandoned transfer is cancelled and re-enqueued from
scratch — re-downloaded from zero every attempt, never finishing and eventually
going needs_attention at the attempt cap.

Replace the flat cap with _max_polls(total_bytes): budget the backstop at a
conservative floor throughput (~64KB/s), clamped to [20min, 2h]. Scorpion now
gets the full 2h ceiling instead of 60min. The 90s stall timeout is unchanged,
so a truly dead/queued peer is still abandoned fast.

Follow-up (not in this change): resume across retries (skip files slskd already
completed) so an abandoned large download doesn't restart from zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:27:44 +02:00
Jonathan 10430037fe fix(worker): cap slskd ETA + rollback on progress-write failure (stop txn-abort job churn)
A near-stalled Soulseek peer drives the EWMA speed toward zero, so
_eta_seconds returned int(remaining/speed) values far past int4 max. Writing
that to Job.downloadEtaSeconds (an integer column) raised "integer out of
range", which aborted the SHARED pipeline connection's transaction. The
on_progress except clause logged but never rolled back, so every subsequent
query cascaded "current transaction is aborted" and the whole job failed and
requeued (9 such failures / 24h observed in prod).

- Cap _eta_seconds at ~100h (359999s), well inside int4.
- Roll back the shared conn in on_progress's except so a failed progress
  write can never poison the pipeline txn, honoring the existing docstring
  promise that "a progress write must never fail the download".
- Regression test for the near-stall cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:42:13 +02:00
Jonathan 01657d4e1b fix(web): order Recently Pressed by press time + cap at 10
Recently Pressed was derived from the request list (ordered by Request.createdAt), so
an album requested long ago but imported just now sorted to the bottom — freshly
pressed albums never appeared near the top ("new albums don't get added"). Order by
press time (job.updatedAt ≈ import time) instead, and show only the 10 most recent.
The "Pressed" stat tile and the "N in library" note still reflect the true total; the
per-row date label now shows when it was pressed, not requested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:17:54 +02:00
Jonathan 0ad1fd4cd5 fix(worker): trust the requested artist when it's in the file path (stop MP3-over-FLAC)
Confidence scoring read the artist from the immediate folder as "Artist - Album", so
common FLAC rip layouts — artist in a parent folder ("Rihanna\(2009) Rated R\…") or
buried ("2009 - Rihanna - Rated R") — parsed with no/garbage artist and scored below
the 0.7 gate. Real FLACs got filtered out and a clean-named MP3 (0.88) won, so Lyra
downloaded lossy when lossless was available (Rihanna "Rated R": 8 FLACs all <0.5).

_parse_search_responses now takes the requested artist and, when it appears anywhere
in the candidate's path (via _path_matches), sets it as the candidate artist so
confidence reflects reality. Especially helps blocklisted-artist fallbacks, whose
album-only results come from varied folder structures. 337 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:05:38 +02:00
Jonathan 7ed8ca2848 fix(worker): prefer the original release-group over same-titled reissues
The real root cause of "Hybrid Theory" re-downloading forever: MB lists the 2000
original and a 2023 reissue as SEPARATE same-titled Album release-groups. They tie on
(artist, title, is_album), so _best_release_group kept whichever MB relevance-ordered
first — the 2023 reissue (13 tracks / ~52 min). Its inflated tracklist+runtime then
failed the completeness/duration checks against the standard 12-track album sources
deliver, looping endlessly. Add an earliest-first-release-date tie-break so the
original group wins. (Complements _pick_release, which handles reissue releases
*within* a group.) 335 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:32:25 +02:00
Jonathan 5f59283bf0 fix(worker): resolve MB to the original edition + loosen duration gate
Root cause of albums re-downloading forever despite completing (Linkin Park "Hybrid
Theory"): the MB resolver read the tracklist from an arbitrary releases[0], often a
later reissue/deluxe. HT resolved to a 2023 13-track reissue (~52 min); a complete
2000 12-track download (~38 min) then failed _download_problem's duration gate
(2267s < 2877s×0.85) and fell through peer after peer, never importing.

- _pick_release: choose the ORIGINAL standard edition (Official, earliest date)
  instead of releases[0], so completeness/duration are judged against the edition
  sources actually deliver.
- _DURATION_MIN_RATIO 0.85 -> 0.65: a source legitimately delivering a shorter
  edition than MB's release shouldn't be rejected; only genuinely truncated/preview
  downloads (a fraction of expected runtime) should be.

Together with the est-time peer ranking, this stops the download→reject→re-download
loop. 334 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:27:44 +02:00
Jonathan d2a86932e0 fix(worker): rank Soulseek peers by estimated transfer time, not raw speed
Peer selection ranked (free-slot, advertised-speed, short-queue), ignoring file
size. But Soulseek FLAC rips of one album vary ~2x in size (a 24-bit/bloated rip vs
a standard CD rip) and Lyra scores them the SAME quality class (the slskd search
exposes no bit-depth, so every FLAC is class 2). So a peer advertising high speed
but serving 40MB/track files would out-rank a smaller standard rip that actually
finishes sooner — then blow past the 60-min per-peer backstop, time out, fall
through, and restart at 0% (Linkin Park "Hybrid Theory" looping for hours).

Rank by estimated transfer time instead: album bytes ÷ advertised speed, after the
free-slot check. Prefers whichever peer FINISHES first — fast peers and/or smaller
standard-edition rips — with no quality loss (all class 2). Equal-size case still
reduces to speed order, so existing ranking behaviour is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:17:44 +02:00
Jonathan 73f1ae6e2f feat(web): order the In-queue tab next-up-first with a "Next up" marker
The queue tab showed newest-first (API returns createdAt desc) but the worker claims
the oldest queued job next (claim_next: ORDER BY createdAt ASC) — so the item that ran
next was at the BOTTOM. Sort the queue bucket ascending so it matches processing
order, and badge the first claimable row (oldest, not individually paused) as "Next up".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:56:42 +02:00
Jonathan c49cee9a20 feat: download ETA on the press bar from measured Soulseek throughput
Show a live "~Xm left" countdown on the download progress bar. The slskd download
loop already polls byte transfer every 3s; measure the actual throughput (EWMA-
smoothed bytes/sec), compute seconds-remaining from the album's total bytes, and
surface it. New nullable Job.downloadEtaSeconds (migration; web entrypoint runs
prisma migrate deploy), threaded through the on_progress callback (optional 2nd arg,
so other adapters/callers are unaffected — they report no ETA). API exposes it;
queue.tsx renders etaLabel() after the track count. Null when unknown (no sample yet
or a source that doesn't report bytes). Worker 330 tests, web 214 tests, both green;
verified live-rendered as "42% · 6/14 tracks · ~4m left".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:16:58 +02:00
Jonathan db8b43b0e8 feat(web): tabbed On-the-Press view + kebab menu for press actions
The press page interleaved active downloads, queued items, and errors in one long
list — hard to see what's actually pressing. Split the non-imported rows into three
tabs (Active / In queue / Errors) with live counts; the default tab follows the work
(Active if anything's running, else Queue, else Errors) until the user picks one.
Move the press-level actions (Pause/Resume, Clear queue, Stop now) off the toolbar
into a vertical "⋮" kebab menu on the right of the header — Clear queue disables when
the queue is empty, Stop now only shows while downloading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:41:11 +02:00
Jonathan 434cd2b22c feat(worker): recover blocklisted Soulseek searches via album/artist-only fallback
The Soulseek server silently drops searches containing certain blocklisted terms
(major-label artists / album titles — Adele, Rihanna, Beyoncé, "Lemonade", …),
returning 0 responses ("TimedOut"). The blocked term poisons the whole query even
though the OTHER field usually isn't blocked and the content is on the network
(e.g. searching "Beyoncé …" → 0, but "I Am… Sasha Fierce" alone → 250 with 136
real Beyoncé folders).

When the primary "{artist} {album}" search returns nothing, retry with each field
alone (normalized to fold accents/punctuation) and keep only results whose file
path still matches the dropped field (_keep_matching), so a same-titled album by a
different artist is never grabbed. Recovers blocklisted albums with a distinctive
title; safely yields nothing when the title is too generic (e.g. Adele "21") rather
than mis-grabbing. Fallback only fires on a 0-response primary, so the happy path is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:06:07 +02:00
Jonathan d7ea29a97a fix(worker): requeue jobs that fail on an unhandled pipeline exception
An unhandled exception in run_pipeline (e.g. a MusicBrainz TLS drop surfacing as
SSL: UNEXPECTED_EOF, after the MB retries are exhausted) left the job stranded in
its mid-pipeline 'matching' state: the except handler logged "pipeline failed" and
rolled back, but never reset the job. Since claim_next only picks 'requested', the
job was orphaned — stuck showing "Searching sources…" on the press until the next
worker restart's reclaim_stuck_jobs happened to sweep it up.

Add requeue_or_fail: on an unhandled failure, requeue to 'requested' for a bounded
retry (transient network blips clear on retry), then fall to 'needs_attention' at
the attempt cap (5) so a persistently-broken job stays visible instead of looping.
Mirrors reclaim_stuck_jobs (requeue) and pipeline._fail (needs_attention), and
resets Request.status to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:40:41 +02:00
Jonathan e8483ee22b fix(worker): match library ownership by rgMbid + retire stale failed attempts
Two related monitor bugs surfaced by owned albums that never leave the press:

1. Ownership was matched on (artist, album) strings, but MusicBrainz canonicalizes
   the artist on import ("Kanye West"->"Ye", "Ne-Yo"->"Ne‐Yo", "The Goo Goo Dolls"
   ->"Goo Goo Dolls"). The strings no longer matched, so reconcile/enqueue/backfill
   never recognized the album as owned — it stayed "wanted" and re-pressed every
   retry interval. Match on the release-group MBID when the library row has one
   (falling back to artist+album for scanned rows without an MBID) across
   _in_library_at_cutoff, fulfill_owned_releases, and reconcile.

2. Each failed attempt left a needs_attention Request behind, so a hard-to-match
   album piled up phantom "needs attention" cards on the press even after a later
   attempt imported it (e.g. Maroon 5 "V": 3 stale cards + 1 success). Retire prior
   failed attempts when a release is fulfilled or re-attempted (_retire_failed_attempts,
   monitor-owned requests only — manual requests have no monitoredReleaseId).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:04:31 +02:00
Jonathan ca5bbfa9da fix(worker): retry transient MusicBrainz failures with backoff
The matching stage makes up to three musicbrainzngs calls per job, none of
which retried. A dropped TLS connection (surfacing as NetworkError,
"<urlopen error [SSL: UNEXPECTED_EOF_WHILE_READING]>") or a 503 rate-limit
(ResponseError) failed the entire pipeline for the job, which then re-queued
and re-ran from scratch — ~36% of jobs churned this way over a 6h window.

Wrap the three MB calls in _with_retry (4 attempts, 1s/2s/4s exponential
backoff), catching only the two transient musicbrainzngs error types so real
bugs still propagate. musicbrainzngs stays lazily imported to keep module
import offline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:45:37 +02:00
Jonathan 675d964bbe fix(worker): smarter Soulseek peer selection + stall/progress-aware transfers
Draining the queue via Soulseek was stalling: slow peers (~100KB/s) never finished a
full FLAC album within the fixed 10-min timeout, got cancelled at ~89%, and the
fall-through restarted from scratch on the next peer — while single-song folders named
like the album were also being tried.

- Stall detection: abandon a peer with no byte progress for ~90s (queued/dead) instead
  of waiting the full timeout; a peer that IS progressing keeps its slot up to a 60-min
  backstop, so a slow-but-working transfer can finish.
- Rank search candidates by peer: free upload slot, then speed, then shortest queue.
- Drop candidates with far fewer tracks than the release (single-song folders) up front,
  so no download attempt is wasted on them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:54:14 +02:00
Jonathan 57f97e9838 feat(worker): defer Qobuz upgrades when it's gated instead of re-grabbing same quality
With qualityCutoff=3 (want hi-res), a Soulseek-grabbed CD copy stays below cutoff and
is re-attempted for upgrade. Without a guard, when Qobuz is gated by pacing those
attempts would (a) re-search every owned album each monitor cycle and (b) re-download
a same-quality Soulseek copy that keep-if-better discards — pure churn.

Add an upgrade guard: a >= CD-lossless copy can only be improved by hi-res Qobuz, so
when Qobuz is gated, skip the job WITHOUT searching (pre-search skip); and as a safety
net, skip the download when the best-ranked candidate can't beat the existing copy.
Force jobs always proceed. Extracted the shared keep-existing path into a helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:15:43 +02:00
Jonathan 1fc61a0f5a fix(worker): cancel abandoned slskd transfers + cap download fall-through
Two compounding causes of 'many downloads of one album from different sources' in
slskd: (1) the slskd client raised on timeout/error WITHOUT cancelling the transfer
it enqueued, so as the pipeline fell through to other peers each left a transfer
running; (2) the incomplete-download fall-through was uncapped, grinding through
every candidate of a popular album (~200 Soulseek peers), amplified now that Qobuz
pacing pushes most jobs to Soulseek.

- SlskdClient.download now cancels its enqueued transfers (DELETE) on any abandon.
- The download loop caps at _MAX_DOWNLOAD_ATTEMPTS (6) candidates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:03:05 +02:00
Jonathan 67f374c470 feat: Qobuz pacing budget gate (human-like volume + cadence)
Avoid re-triggering a USER_BLOCKED account by capping Qobuz to a human-looking
volume and cadence. A per-album gate (worker/lyra_worker/qobuz_gate.py) allows
Qobuz only within active hours, under today's cap (a warm-up ramp of a steady
cap), and past a randomized spacing since the last Qobuz download; the gap spreads
the day's budget across active hours with jitter. When the gate is closed the
pipeline drops Qobuz candidates so the job falls through to another source (and
monitored albums upgrade to Qobuz later); a Qobuz-only setup is never gated.
Config keys read live each loop; counters reset at the day boundary via the DB
clock. Tunable in Settings -> Qobuz -> Pacing (new /api/qobuz/pacing route).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:45:56 +02:00
Jonathan d47ed12e8e feat(worker): fall through to the next source on an incomplete download
Completeness/duration verification was applied only to the first candidate that
downloaded successfully; if that copy was incomplete or too short the whole job
failed, even when a later-ranked source had a complete copy (e.g. John Mayer 'The
Search for Everything' — Qobuz reliably fails one hi-res track, so it never fell
back to the deluxe edition or Soulseek).

Extract the checks into a pure _download_problem() and run it INSIDE the download
loop: a candidate that downloads but fails verification now falls through to the
next-ranked source, and the job only fails (needs_attention) when every candidate
is incomplete — reporting the last shortfall reason.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:41:04 +02:00
Jonathan b0eec41823 fix(worker): judge download completeness by the source's edition, not MB's
The completeness + duration guards used MusicBrainz's target.track_count /
total_duration_s, but MB picks an arbitrary releases[0] that is often a
deluxe/expanded edition with more tracks (and runtime) than the standard album a
source legitimately delivers. So complete standard-edition downloads were falsely
rejected — The Script 'No Sound Without Silence' (11 vs MB 12) as 'incomplete
download', Skillet 'Unleashed' (12 vs MB's 20-track deluxe) as 'too short'.

Now completeness is judged against the chosen source's own track count
(winner.track_count): a truncated download (fewer files than the source promised)
still fails, and the duration expectation is scaled to the delivered edition's
size. A separate 'implausibly small vs MB' guard (source has <= half MB's tracks)
still rejects a lone-track 'full album' video. Genuine partials (e.g. John Mayer,
a Qobuz track failing mid-download) still correctly fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:22:01 +02:00
Jonathan 108114ffa3 fix(web): replace stray null byte with space in Recently-Pressed dedupe key
A literal NUL had crept into the `${r.artist} ${r.album}` dedupe key on the
Floor, making git treat queue.tsx as a binary file (unreviewable diffs). Restore
the intended space; behavior is unchanged (the key stays unique per album).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:44:22 +02:00
Jonathan 6451cf3a3c feat(web): Floor press + per-item controls (pause/resume/clear/stop/remove)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:33:44 +02:00
Jonathan 927266d042 fix(mb): rank release groups by credited artist, not title alone
The worker MB resolver picked release groups on (title_ratio, is_album)
and never scored the artist — even though resolve() knows the followed
artist. For a generic title, same-titled release groups by *different*
artists tie on title, and the is_album tiebreak (added in 06e7f91 for
Off the Wall) then actively preferred a foreign Album over the correct
release. Concretely, following Lake Street Dive's "Fun Machine" EP
resolved to an unrelated band "Bastards of Melody"'s same-titled Album,
which then propagated as the downloaded/imported artist.

Make credited-artist similarity the primary sort key, above title and
above is_album. It's a soft signal, never a hard filter, so credited-
name variations (feat., punctuation) still resolve; is_album now only
breaks ties within the same artist+title, preserving the Off the Wall
fix. Verified live: resolve("Lake Street Dive", "Fun Machine") now
returns the 6-track LSD EP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:32:12 +02:00
Jonathan c6945bd89e feat(web): inter-job download delay setting (Settings → Monitor)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:24:42 +02:00
Jonathan 3f624c3395 feat(web): DELETE /api/requests/[id] to remove a queued item
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:20:16 +02:00
Jonathan ecec0b6073 test(pause-api): harden "reject in-flight" test with durability assertion
Strengthen test to prove the job's paused state remains unmodified when
pause/unpause is rejected for a downloading job. Tests now seeds paused=true,
attempts paused=false, verifies 400, and fetches from DB to assert paused is
still true — catching regressions that would mutate before the guard check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:18:03 +02:00
Jonathan b78867435a feat(web): per-item pause route for queued jobs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:14:45 +02:00
Jonathan 3d30e7dc8b feat(web): /api/floor pause/resume/clear/stop controls
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:09:45 +02:00
Jonathan 8c95ff735b feat(worker): pause gate, inter-job delay, and force-stop watcher in main loop
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:06:40 +02:00
Jonathan bff590a00f feat(worker): claim_next skips per-item paused jobs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:01:38 +02:00
Jonathan 20869bdc13 feat(worker): floor pause/delay/kill-switch helpers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:57:34 +02:00
Jonathan 9c2495825f feat(db): add Job.paused column for per-item press pause
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:54:19 +02:00
Jonathan 878eac066e docs: implementation plan for Press controls + inter-job delay
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:50:54 +02:00
Jonathan 603c62ef87 docs: design spec for Press controls + inter-job delay
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:43:09 +02:00
Jonathan 06e7f91897 fix(mb): resolve same-named albums to the Album, not the Single
MusicBrainz search returns release-groups tied on title similarity in an
arbitrary order; `max(groups, key=_ratio)` kept the first, so Michael
Jackson's "Off the Wall" resolved to the 2-track *single* release-group
(Off the Wall / Get on the Floor) instead of the 10-track album. Because
plan_track_names labels files purely by position, that short tracklist got
mapped onto the full album — track 2 became "Get on the Floor" instead of
"Rock with You", and the real "Off the Wall"/"Get on the Floor" files kept
their streamrip names, yielding duplicate titles.

Extract selection into a pure `_best_release_group()` that breaks title-
similarity ties toward primary-type == "Album". Similarity still dominates;
album preference only settles ties. Live-verified: Off the Wall now resolves
to the 10-track album with track 2 = "Rock With You".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:49:26 +02:00
Jonathan 0443f10a3b feat(lastfm): disambiguation picker for same-named MB artists
Last.fm's artist MBID can point at the wrong same-named band (it mapped
"Looking Glass" to an Australian stoner band, not the US pop group), and the
/lastfm rows trusted it verbatim — so opening/following grabbed the wrong
artist and the grabbed album never showed on the artist page. Now open/follow
resolve via MB search: resolveArtistChoice keeps candidates whose name matches
exactly — 1 match is used directly (even over a disagreeing Last.fm MBID), 2+
surface an ArtistPicker showing each MB disambiguation ("70's US pop group,
track 'Brandy'" vs "Australian stoner rock") with MB's top hit flagged
Suggested. Core decision logic unit-tested; no worker/schema change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 03:43:54 +02:00
Jonathan a8ea3441fc feat(floor): show "preparing…" bar during the pre-download window
A Qobuz login/metadata/artwork phase (and a Soulseek peer-queue or YouTube
info-fetch) can sit at 0% for a few minutes before byte progress flows, which
read as a stuck bar. While a downloading job's progress is still 0, render the
indeterminate "preparing…" bar (same style as the searching/finishing phases);
it flips to the determinate % bar on the first reported byte and never back
(the progress aggregator only increases from 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 03:43:54 +02:00
Jonathan ccaaa41fec docs: slim README for self-hosting, move advanced topics to docs/DEPLOYMENT.md
Restructure the docs ahead of sharing the project for others to self-host. The
README is now a focused get-it-running path (overview, architecture, prereqs,
quick start, operational notes, security, backup/restore). Moved the fuller
feature tour, the maintainer pre-built-image release flow, and the Gluetun VPN
guide into a new docs/DEPLOYMENT.md, cross-linked both ways. The pre-built-image
section is now labelled a maintainer process with a LYRA_REGISTRY override so a
self-hoster can point at their own registry (or just build from source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 02:57:45 +02:00
Jonathan b1f6408da6 feat(lastfm): inline Follow / Want buttons on the browse rows
Previously the /lastfm rows only showed a status chip; following an artist or
wanting an album meant opening the modal first. Add an inline Follow button to
each Artists-tab row and an inline Want button to each Albums-tab row, reusing
the same endpoints as the modals (resolve MB id/release-group as needed, then
POST /api/artists or /api/discover/want). Optimistic per-row state flips the
button to the Following/Wanted chip without a reload; the button is disabled
while the request is in flight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 02:39:13 +02:00
Jonathan 55cf88f66e fix(worker): canonicalize bonus-track filenames past the MB tracklist
A track beyond the MusicBrainz tracklist (a Qobuz bonus track) kept streamrip's
raw name (e.g. "13. John Mayer - St. Patrick's Day (Album Version).flac") while
the main tracklist got clean "## Title" names. plan_track_names now canonicalizes
such extras: strip the leading track-number prefix and a leading "<artist> - ",
yielding "13 St. Patrick's Day (Album Version).flac". Requires an explicit
delimiter after the number so titles like "99 Luftballons" are left alone; with
no tracklist resolved at all the on-disk stem is still kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 02:37:08 +02:00
Jonathan 0c5e73eb17 fix(worker): parallelize per-source searches in the match phase
Soulseek's slskd search polls up to ~2 min; searching adapters sequentially
added that on top of Qobuz's instant search. Run the per-adapter searches
concurrently via a ThreadPoolExecutor so total match time is the slowest
single source, not their sum. Only Qobuz drives streamrip's shared asyncio
loop and there's one Qobuz adapter, so no two threads use that loop at once.
Results collected in adapter order (map preserves order) for deterministic
candidate ordering; per-adapter failures still swallowed (down source = no
candidates).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 02:37:08 +02:00
Jonathan 0325467aba fix(worker): smooth Qobuz download progress via streamrip byte hook
streamrip reports progress only per concurrent track, so Job.downloadProgress
came solely from the file-count poller — 0% until every track landed, then a
jump to 100%. Patch streamrip.media.track.get_progress_callback to aggregate
per-track byte deltas over an estimated album total (avg started-track size ×
len(album.tracks)) into one smooth, generally-climbing 0..1 fraction routed
through the pipeline's on_progress. Capped at 0.99; the pipeline sets 1.0 on
completion. No signature changes — track count read from the resolved album.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 02:37:08 +02:00
Jonathan c7bab8e997 docs: handoff plan for 3 post-deploy UX fixes
Qobuz progress smoothing, parallel source searches, bonus-track filename
canonicalization. Lyra is live on the VM; these are worker-only polish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 02:24:56 +02:00
Jonathan 53e7f0b7f2 build: script to publish web/worker images to the Gitea registry
Adds scripts/build-push.sh (build + push lyra-web/lyra-worker :latest and
:<sha> to git.jger.nl) + a README 'Deploying to a server (pre-built images)'
section. The server pulls these via the vm-download ops repo (docker/lyra).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 01:06:46 +02:00
Jonathan f72d193ef7 chore(discover): raise_for_status on Last.fm fetches
A 4xx/5xx (rate limit / auth) now raises instead of parsing an error body;
callers already handle it (discovery per-source try/except; album fetch
returns []). Closes the last small deferred cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:50:35 +02:00
Jonathan 4a768b7122 fix(discover): drop suggestions for non-artists (no core discography)
ListenBrainz surfaces producers/session players/band members (Max Martin,
Dominic Howard) that co-occur in listens but have no releases of their own —
they showed as bare rows with an empty page. _derive_albums now drops a
surfaced artist (deletes their pending suggestion) when their MB discography
has no core release-group (Album/Single/EP, no secondary type), catching both
empty discographies and soundtrack/production-only credits, while keeping real
singles/EP-only artists. On browse failure the suggestion is kept.

Test fake browser gains default_core so scoring tests keep surfacing candidates.
worker 249 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:38:56 +02:00
Jonathan 9915bfbda2 feat(discover): full reset — clear + rebuild suggestions
POST /api/discover/reset sets discover.resetRequested; the worker, at the
start of the next sweep, clears all PENDING suggestions + contributions +
seed throttles and nulls WatchedArtist.lastDiscoveredAt (keeping
followed/wanted/dismissed decisions), then regenerates everything with current
logic. A 'Reset' button on /discover (2-step confirm) triggers it.

worker 247, web 190 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:20:17 +02:00
Jonathan 7e27654b5a chore: remove accidentally-committed screenshot
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:12:36 +02:00
Jonathan 288d55269d fix(discover): hide stale singles + replace album set on re-derive
- Client filters the row's album thumbnails to full albums only (hides
  singles/EPs left over from pre-albums-only sweeps).
- Worker: re-deriving an artist deletes its pending album suggestions that are
  no longer picks (stale singles / now-owned), keeping wanted/dismissed rows,
  so the suggestion set self-cleans instead of accumulating.

worker 246, web 189 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:10:10 +02:00
Jonathan 48d5f287b2 style(discover): compact stacked album items in each row
Each suggested album is now a small horizontal item (30px cover + title/badge
on one line, like Recently Pressed but smaller) stacked vertically, so both
albums fit under each other without enlarging the row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:00:37 +02:00
68 changed files with 5192 additions and 353 deletions
+78 -170
View File
@@ -7,83 +7,54 @@ sources of differing quality: **Qobuz** (lossless/hi-res), **Soulseek** (P2P), a
**ListenBrainz** and **Last.fm** (Spotify's recommendation/related-artists APIs were
retired for new apps, so discovery is MetaBrainz-native and MBID-clean — no Spotify).
## Status
It runs on your own server as a small `docker compose` stack. This README covers getting
it running; see **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** for the full feature tour,
publishing pre-built images, and running behind a VPN.
Built in three slices:
## What it does
1. **Acquisition engine****done**. Resolve a request against MusicBrainz,
match/rank across Qobuz/Soulseek/YouTube, download the best source into an
isolated staging dir, verify completeness, tag (mutagen), and atomically promote
the clean album into `Artist/Album (Year)/## Title.ext`.
2. **Library manager** — ✅ **done**. Watched artists (live MB search + follow),
per-release monitor toggles + auto-monitor-future, a wanted list with retry +
quality-upgrade window, and a worker "monitor" that discovers new releases and
auto-grabs through the acquisition pipeline. Plus a **library scan** that ingests
existing on-disk albums as have + monitored + followed, and a per-artist
studio-only / show-all release-type filter.
3. **Discovery** — ✅ **done**. Recommendation-driven finding of new music via
**ListenBrainz** similar-artists and **Last.fm** `artist.getSimilar` (both
MBID-native), behind a pluggable `SimilaritySource` interface — Last.fm registers
as a second source automatically once its API key is set, and cross-source scores
are summed per candidate. A background sweep (and a "Discover now" button)
aggregates artists similar to the ones you follow into a manual review feed of
suggested **artists** and **albums** at `/discover`; **Follow** and **Want**
reuse the slice-2 follow / wanted flows, **Dismiss** is permanent. Plus a
web-side seed-search box. Every suggestion and seed-search result opens a
read-only **artist preview** (`/discover/artist/[mbid]`) — discography,
per-album tracklists, and "in library" / "monitored" badges — where you can
Follow or Want before committing. No Spotify (its recommendation APIs are dead
for new apps).
- **Acquisition** — resolve a request against MusicBrainz, match/rank across
Qobuz/Soulseek/YouTube, download the best source, verify completeness, tag, and organize
into `Artist/Album (Year)/## Title.ext`.
- **Library manager** — follow artists (live MB search), per-release monitor toggles, a
wanted list with retry + quality-upgrade, a background monitor that auto-grabs new
releases, and a scan that ingests albums already on disk.
- **Discovery** (`/discover`) — recommendation-driven suggestions from ListenBrainz +
Last.fm similar-artists, with Follow / Want / Dismiss and a read-only artist preview.
- **Browse & watch** — Last.fm top artists/albums (`/lastfm`, with inline Follow/Want), a
cover-art **Library** grid (`/library`), and **The Floor** (home): a live queue of
in-flight acquisitions with three-phase progress and a Retry button.
Alongside the three slices:
- **Last.fm browse** (`/lastfm`) — your Last.fm top artists and albums (by period),
with a "hide items already in my library" toggle; each artist opens a modal of your
most-played songs and albums, and every album is one click to **Want**.
- **Library** (`/library`) — a cover-art grid of everything on disk, filterable, each
album opening a tracklist modal.
- **The Floor** (home) — a live queue of in-flight acquisitions with three-phase
progress (**Searching → Downloading → Finishing**), a live download percentage, a
per-job "pressing ticket" (source · format · tracks · elapsed), and a **Retry**
button on anything that needs attention.
The whole UI runs a hand-rolled "Pressing Plant" design system (warm-paper theme, self-hosted
Fraunces serif for prose vs. system mono for machine data, light/dark aware).
**Operational notes:** the monitor and the discovery sweep are both **off by
default** — set `monitor.enabled=true` (or Settings → Monitor, + optional `monitor.*`
tuning) to activate auto-grab/upgrade, and `discover.enabled=true` (or
Settings → Discovery, + optional `discover.*` tuning) to activate the scheduled
discovery sweep. The `/discover` "Discover now" button and seed-search work with the
sweep off. Credentials (Qobuz, Soulseek, Last.fm) are entered in Settings and persist
across `docker compose up -d --build` rebuilds (don't use `down -v`; secrets are
encrypted at rest with `LYRA_SECRET_KEY`). Tests run against a separate `lyra_test`
database (a guard blocks the live DB). Deferred hardening + the full roadmap live in
the implementation-plan "Notes" sections under
[`docs/superpowers/plans/`](docs/superpowers/plans/).
Full detail on each of these is in [docs/DEPLOYMENT.md → Features in depth](docs/DEPLOYMENT.md#features-in-depth).
## Architecture
The `docker compose` stack is three services on a home server:
The `docker compose` stack is a few services on one host:
- **db** — Postgres, the shared source of truth.
- **db** — Postgres, the shared source of truth (all durable state lives here).
- **web** — the Next.js app: UI + API (search, request, queue, progress, settings).
- **worker** — the Python worker: all source integration (streamrip, yt-dlp, slskd
client, mutagen; MusicBrainz + ListenBrainz + Last.fm for metadata and discovery),
running the six-stage acquisition pipeline plus the background monitor and discovery
sweeps.
- **worker** — the Python worker: all source integration (streamrip, yt-dlp, slskd client,
mutagen; MusicBrainz + ListenBrainz + Last.fm for metadata and discovery), running the
acquisition pipeline plus the background monitor and discovery sweeps.
- **db-backup** — a sidecar that `pg_dump`s the database on a schedule (see [Backup &
restore](#backup--restore)).
Soulseek support talks to an **external slskd** daemon (an off-the-shelf headless
Soulseek client) — the worker reaches it via the `slskd.url` + `slskd.api_key`
credentials set in Settings, so run slskd separately rather than as part of this stack.
Soulseek support talks to an **external slskd** daemon (an off-the-shelf headless Soulseek
client) — the worker reaches it via the `slskd.url` + `slskd.api_key` credentials set in
Settings, so run slskd separately rather than as part of this stack.
A shared `/music` volume is the library, with a separate `/staging` volume for
in-progress downloads (point `STAGING_DIR` at a fast local disk when `MUSIC_DIR` is a
network share).
A shared `/music` volume is the library, with a separate `/staging` volume for in-progress
downloads (point `STAGING_DIR` at a fast local disk when `MUSIC_DIR` is a network share).
See [`docs/superpowers/specs/`](docs/superpowers/specs/) for the full design.
## Prerequisites
## Running it
- **Docker** + **Docker Compose**.
- A directory for the music library (set `MUSIC_DIR`).
- Optional, entered in **Settings** after first run: a **Qobuz** account (lossless/hi-res),
a running **slskd** daemon (Soulseek), and a **Last.fm** API key (browse + discovery).
None are required to start — Lyra runs with whatever you configure.
## Quick start
```sh
cp .env.example .env # then edit: set LYRA_SECRET_KEY (openssl rand -base64 32) and MUSIC_DIR
@@ -97,10 +68,26 @@ the placeholder. Once set, keep it stable: changing it makes already-stored cred
undecryptable (you'd re-enter them in Settings). Optionally set `LYRA_PASSWORD` too (see
[Security](#security)).
The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema changes
reach the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in
**Settings** (they persist across rebuilds), then follow an artist or request an album
to feed The Floor.
The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema changes reach
the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in **Settings** (they
persist across rebuilds), then follow an artist or request an album to feed The Floor.
`.env.example` documents every setting (`MUSIC_DIR`, `STAGING_DIR`, `SLSKD_DOWNLOADS_DIR`,
backup knobs). Note in particular: point `SLSKD_DOWNLOADS_DIR` at slskd's own downloads
directory (Lyra must run on the same host as slskd for Soulseek delivery to work), and point
`STAGING_DIR` at a fast local disk if `MUSIC_DIR` is a network share.
## Operational notes
- The **monitor** and the **discovery sweep** are both **off by default** — set
`monitor.enabled=true` (or Settings → Monitor, + optional `monitor.*` tuning) to activate
auto-grab/upgrade, and `discover.enabled=true` (or Settings → Discovery, + optional
`discover.*` tuning) to activate the scheduled discovery sweep. The `/discover` "Discover
now" button and seed-search work with the sweep off.
- **Credentials persist** across `docker compose up -d --build` rebuilds — don't use
`down -v` (it wipes the Postgres volume); secrets are encrypted at rest with
`LYRA_SECRET_KEY`.
- **Tests** run against a separate `lyra_test` database (a guard blocks the live DB).
## Managing the library
@@ -110,6 +97,24 @@ it — re-Want it from the artist page if you change your mind). This is why the
also bind-mounts `MUSIC_DIR` at `/music` (read-write) — deletion is guarded to only ever
remove folders strictly inside the library root.
## Security
Lyra has **no per-user accounts**. Two layers protect it:
- **Shared-password gate.** Set `LYRA_PASSWORD` in `.env` to require a single shared
password for the entire UI and API. A request without a valid session cookie is
redirected to `/login` (or gets `401` for `/api/*`). The cookie is httpOnly and carries
an HMAC of the password keyed by `LYRA_SECRET_KEY`, so it can't be forged. **If
`LYRA_PASSWORD` is unset, the app is completely open** — anyone who can reach the host
can browse the library, trigger downloads, and read/overwrite the stored credentials in
Settings. Set it for any deployment that isn't on a fully trusted, isolated network.
- **Network.** The shared password is deliberately minimal (single password, bearer
cookie, plain HTTP on the LAN). For anything internet-facing, also put Lyra behind a
reverse proxy with TLS and/or a VPN or auth gateway — don't rely on the shared password
alone. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#running-behind-a-vpn-gluetun) for a
Gluetun VPN setup.
## Backup & restore
**All** durable state — library metadata, monitored/wanted lists, discovery data,
@@ -140,105 +145,8 @@ docker compose start web worker
is safe. To restore onto a brand-new host, bring up just `db` first
(`docker compose up -d db`), restore, then `up -d --build` the rest.
## Security
## More
Lyra has **no per-user accounts**. Two layers protect it:
- **Shared-password gate.** Set `LYRA_PASSWORD` in `.env` to require a single shared
password for the entire UI and API. A request without a valid session cookie is
redirected to `/login` (or gets `401` for `/api/*`). The cookie is httpOnly and carries
an HMAC of the password keyed by `LYRA_SECRET_KEY`, so it can't be forged. **If
`LYRA_PASSWORD` is unset, the app is completely open** — anyone who can reach the host
can browse the library, trigger downloads, and read/overwrite the stored credentials in
Settings. Set it for any deployment that isn't on a fully trusted, isolated network.
- **Network.** The shared password is deliberately minimal (single password, bearer
cookie, plain HTTP on the LAN). For anything internet-facing, also put Lyra behind a
reverse proxy with TLS and/or a VPN or auth gateway — don't rely on the shared password
alone.
## Running behind a VPN (Gluetun)
Lyra runs fine with its traffic routed through a [Gluetun](https://github.com/qdm12/gluetun)
VPN container — useful for hiding the download traffic (Soulseek / Qobuz / YouTube). Nothing
in Lyra is VPN-incompatible; the only thing that changes is container networking, because a
service using `network_mode: "service:gluetun"` gives up its own network stack and shares
Gluetun's. Two consequences:
- Containers that share Gluetun's namespace reach each other over **`localhost`**, not by
Docker service name — so `DATABASE_URL` changes from `@db:5432` to **`@localhost:5432`**.
- **Published ports move to the `gluetun` container** (the individual services can no longer
publish their own).
The simplest, most consistent setup is to put the **whole stack** behind Gluetun. Sketch
(merge into your existing Gluetun compose; adjust the VPN provider block to yours):
```yaml
services:
gluetun:
image: qmcgaw/gluetun
cap_add: [NET_ADMIN]
environment:
# ... your VPN provider / credentials ...
FIREWALL_INPUT_PORTS: "3000" # let your browser reach the web UI
FIREWALL_OUTBOUND_SUBNETS: "192.168.0.0/16" # your LAN — so the worker can reach slskd
ports:
- "8770:3000" # Lyra web (published on gluetun, not on `web`)
# - "5432:5432" # only if you want DB access from the LAN
web:
build: ./web
network_mode: "service:gluetun" # no `ports:` / `networks:` here
environment:
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra # `db` -> `localhost`
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
HOSTNAME: 0.0.0.0
LYRA_LIBRARY_ROOT: /music
volumes:
- ${MUSIC_DIR:-./music}:/music
depends_on:
gluetun: { condition: service_healthy }
db: { condition: service_healthy }
worker:
build: ./worker
network_mode: "service:gluetun"
environment:
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
STAGING_ROOT: /staging
SLSKD_DOWNLOADS_ROOT: /slskd-downloads
volumes:
- ${MUSIC_DIR:-./music}:/music
- ${STAGING_DIR:-${MUSIC_DIR:-./music}/.staging}:/staging
- ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads
depends_on:
gluetun: { condition: service_healthy }
db: { condition: service_healthy }
db:
image: postgres:17
network_mode: "service:gluetun" # shares the namespace so `localhost:5432` resolves
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
```
(Give `db-backup` the same `network_mode` and `PGHOST: localhost`.) The `HOSTNAME=0.0.0.0`
and the built-in healthchecks keep working, since everything is in one namespace.
Caveats worth knowing:
- **Qobuz is geo-sensitive** — its catalog/availability varies by country, so pick a VPN
exit in a region where your Qobuz account works, or some downloads will fail to match.
- The worker's `net.ipv6.conf.all.disable_ipv6` sysctl **can't apply behind Gluetun** (a
shared netns can't set sysctls) — harmless, because Gluetun blocks IPv6 anyway, which is
exactly what that sysctl worked around (Qobuz's CDN resolving to an unroutable IPv6).
- MusicBrainz rate-limits per IP; a shared VPN exit is marginally more likely to hit the
1 req/s ceiling, but the built-in response cache makes that a non-issue in normal use.
- Bind mounts (`/music`, `/staging`, `/slskd-downloads`), the Postgres volume, and backups
are filesystem, not network — **completely unaffected** by the VPN.
- **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** — full feature tour, publishing pre-built
images to a registry, and running the whole stack behind a Gluetun VPN.
- **[docs/superpowers/specs/](docs/superpowers/specs/)** — the full design specs.
+177
View File
@@ -0,0 +1,177 @@
# Lyra — deployment & advanced guide
Topics beyond the [README](../README.md) quick-start: the full feature tour, publishing
pre-built images to a registry, and running the whole stack behind a VPN. For a basic
self-hosted setup you only need the README — start there.
- [Features in depth](#features-in-depth)
- [Publishing pre-built images](#publishing-pre-built-images)
- [Running behind a VPN (Gluetun)](#running-behind-a-vpn-gluetun)
---
## Features in depth
Lyra was built in three slices, all complete:
1. **Acquisition engine.** Resolve a request against MusicBrainz, match/rank across
Qobuz/Soulseek/YouTube, download the best source into an isolated staging dir, verify
completeness, tag (mutagen), and atomically promote the clean album into
`Artist/Album (Year)/## Title.ext`.
2. **Library manager.** Watched artists (live MB search + follow), per-release monitor
toggles + auto-monitor-future, a wanted list with retry + quality-upgrade window, and a
worker "monitor" that discovers new releases and auto-grabs through the acquisition
pipeline. Plus a **library scan** that ingests existing on-disk albums as
have + monitored + followed, and a per-artist studio-only / show-all release-type filter.
3. **Discovery.** Recommendation-driven finding of new music via **ListenBrainz**
similar-artists and **Last.fm** `artist.getSimilar` (both MBID-native), behind a
pluggable `SimilaritySource` interface — Last.fm registers as a second source
automatically once its API key is set, and cross-source scores are summed per candidate.
A background sweep (and a "Discover now" button) aggregates artists similar to the ones
you follow into a manual review feed of suggested **artists** and **albums** at
`/discover`; **Follow** and **Want** reuse the slice-2 follow / wanted flows, **Dismiss**
is permanent. Plus a web-side seed-search box. Every suggestion and seed-search result
opens a read-only **artist preview** (`/discover/artist/[mbid]`) — discography, per-album
tracklists, and "in library" / "monitored" badges — where you can Follow or Want before
committing. No Spotify (its recommendation APIs are dead for new apps).
Alongside the three slices:
- **Last.fm browse** (`/lastfm`) — your Last.fm top artists and albums (by period), with a
"hide items already in my library" toggle and inline **Follow** / **Want** buttons on each
row; each artist also opens a modal of your most-played songs and albums.
- **Library** (`/library`) — a cover-art grid of everything on disk, filterable, each album
opening a tracklist modal.
- **The Floor** (home) — a live queue of in-flight acquisitions with three-phase progress
(**Searching → Downloading → Finishing**), a live download percentage, a per-job "pressing
ticket" (source · format · tracks · elapsed), and a **Retry** button on anything that needs
attention.
The whole UI runs a hand-rolled "Pressing Plant" design system (warm-paper theme, self-hosted
Fraunces serif for prose vs. system mono for machine data, light/dark aware).
Deferred hardening and the full roadmap live in the implementation-plan "Notes" sections under
[`docs/superpowers/plans/`](superpowers/plans/); the full design specs are under
[`docs/superpowers/specs/`](superpowers/specs/).
---
## Publishing pre-built images
> **Maintainer release process** — this is how the project maintainer publishes images to a
> private registry. If you're self-hosting your own copy, ignore this and just build from
> source with `docker compose up -d --build` (see the README). You'd only need this if you
> run your own registry and want servers to **pull** rather than build.
The `web` and `worker` images can be published to a container registry and consumed by a
compose file that pulls instead of builds. The maintainer publishes to a Gitea container
registry consumed by a compose file in the
[`vm-download`](https://git.jger.nl/Jonathan/vm-download) ops repo (`docker/lyra/`).
Publish a new release from this repo (amd64):
```sh
docker login <your-registry> # once — registry user + a token with package:write
./scripts/build-push.sh # builds + pushes lyra-web / lyra-worker (:latest and :<sha>)
```
`scripts/build-push.sh` defaults the registry namespace to `git.jger.nl/jonathan`; override it
with `LYRA_REGISTRY=<your-registry>/<namespace>`.
Then on the server:
```sh
cd /opt/git/vm-download && git pull
cd docker/lyra && docker compose pull && docker compose up -d
```
`db` (Postgres) and `db-backup` use the stock `postgres:17` image, so only the two custom
images are built here.
---
## Running behind a VPN (Gluetun)
Lyra runs fine with its traffic routed through a [Gluetun](https://github.com/qdm12/gluetun)
VPN container — useful for hiding the download traffic (Soulseek / Qobuz / YouTube). Nothing
in Lyra is VPN-incompatible; the only thing that changes is container networking, because a
service using `network_mode: "service:gluetun"` gives up its own network stack and shares
Gluetun's. Two consequences:
- Containers that share Gluetun's namespace reach each other over **`localhost`**, not by
Docker service name — so `DATABASE_URL` changes from `@db:5432` to **`@localhost:5432`**.
- **Published ports move to the `gluetun` container** (the individual services can no longer
publish their own).
The simplest, most consistent setup is to put the **whole stack** behind Gluetun. Sketch
(merge into your existing Gluetun compose; adjust the VPN provider block to yours):
```yaml
services:
gluetun:
image: qmcgaw/gluetun
cap_add: [NET_ADMIN]
environment:
# ... your VPN provider / credentials ...
FIREWALL_INPUT_PORTS: "3000" # let your browser reach the web UI
FIREWALL_OUTBOUND_SUBNETS: "192.168.0.0/16" # your LAN — so the worker can reach slskd
ports:
- "8770:3000" # Lyra web (published on gluetun, not on `web`)
# - "5432:5432" # only if you want DB access from the LAN
web:
build: ./web
network_mode: "service:gluetun" # no `ports:` / `networks:` here
environment:
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra # `db` -> `localhost`
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
HOSTNAME: 0.0.0.0
LYRA_LIBRARY_ROOT: /music
volumes:
- ${MUSIC_DIR:-./music}:/music
depends_on:
gluetun: { condition: service_healthy }
db: { condition: service_healthy }
worker:
build: ./worker
network_mode: "service:gluetun"
environment:
DATABASE_URL: postgresql://lyra:lyra@localhost:5432/lyra
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
STAGING_ROOT: /staging
SLSKD_DOWNLOADS_ROOT: /slskd-downloads
volumes:
- ${MUSIC_DIR:-./music}:/music
- ${STAGING_DIR:-${MUSIC_DIR:-./music}/.staging}:/staging
- ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads
depends_on:
gluetun: { condition: service_healthy }
db: { condition: service_healthy }
db:
image: postgres:17
network_mode: "service:gluetun" # shares the namespace so `localhost:5432` resolves
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
```
(Give `db-backup` the same `network_mode` and `PGHOST: localhost`.) The `HOSTNAME=0.0.0.0`
and the built-in healthchecks keep working, since everything is in one namespace.
Caveats worth knowing:
- **Qobuz is geo-sensitive** — its catalog/availability varies by country, so pick a VPN
exit in a region where your Qobuz account works, or some downloads will fail to match.
- The worker's `net.ipv6.conf.all.disable_ipv6` sysctl **can't apply behind Gluetun** (a
shared netns can't set sysctls) — harmless, because Gluetun blocks IPv6 anyway, which is
exactly what that sysctl worked around (Qobuz's CDN resolving to an unroutable IPv6).
- MusicBrainz rate-limits per IP; a shared VPN exit is marginally more likely to hit the
1 req/s ceiling, but the built-in response cache makes that a non-issue in normal use.
- Bind mounts (`/music`, `/staging`, `/slskd-downloads`), the Postgres volume, and backups
are filesystem, not network — **completely unaffected** by the VPN.
@@ -0,0 +1,96 @@
# Post-deploy UX fixes (3 items) — handoff plan
> **Context:** Lyra is DEPLOYED + LIVE on the media VM (ssh alias `download` = jonathan@10.10.0.103,
> hostname `servarr`, x86_64). Pre-built images from `git.jger.nl/jonathan/lyra-{web,worker}`, whole
> stack behind its own gluetun VPN, deploy repo `Jonathan/vm-download` → `docker/lyra/`. Library was
> wiped to a blank slate; user rebuilds via Last.fm. Two test presses succeeded end-to-end (John Mayer
> Continuum q3 hi-res, Room for Squares q2) — pipeline is solid. These 3 are UX polish the user hit
> in real use. See MEMORY.md → project-state.md "VM DEPLOYMENT" for full deploy details.
**Workflow for EACH fix:** implement in `worker/` → run worker tests (`cd worker && source .venv/bin/activate
&& python -m pytest`) → **on the DEV box** `./scripts/build-push.sh` (needs `docker login git.jger.nl`
already done this session; a fresh session may need re-login — a **real TTY**, not the `!`-prefix) →
on VM `ssh download 'cd /opt/git/vm-download/docker/lyra && docker compose pull && docker compose up -d worker'`
→ verify with a test press (queue via SQL, see below) and watch `Job.downloadProgress`/on-disk result.
Commit per fix; branch `feat/post-deploy-ux`, ff-merge to main, push.
**Queue a test press on the VM** (mimics the Wanted "Search now"): pick a `MonitoredRelease` id from
`SELECT id,"artistName",album FROM "MonitoredRelease" WHERE monitored AND NOT ignored AND state<>'fulfilled'`,
then insert a Request+Job (use a `/tmp/*.sql` file + `docker cp` + `psql -f` to avoid ssh quoting hell):
`INSERT INTO "Request"(id,artist,album,"monitoredReleaseId",status,"createdAt") SELECT gen_random_uuid()::text,"artistName",album,id,'pending',now() FROM "MonitoredRelease" WHERE id='<mrid>' RETURNING id` → capture into a Job insert `(id,"requestId",state,"currentStage",attempts,"downloadProgress","createdAt","updatedAt") VALUES (gen_random_uuid()::text,rid,'requested','intake',0,0,now(),now())`.
---
## Fix 1 — Smooth Qobuz download progress (byte-level)
**Problem:** the Floor progress bar sits at 0% for the whole Qobuz download then jumps to 100%.
Qobuz/streamrip's per-track byte callback is NOT hooked, so `Job.downloadProgress` comes only from the
file-count poller (`pipeline._poll_download_progress`) — and streamrip downloads tracks concurrently, so
0 files are "complete" until they all land at once. yt-dlp + slskd already report bytes via `on_progress`
(smooth); only Qobuz doesn't.
**Files:** `worker/lyra_worker/adapters/_streamrip.py` (the Qobuz download), `worker/lyra_worker/pipeline.py`
(threads `on_progress``_make_on_progress``Job.downloadProgress`; poller is the fallback when the
per-attempt `reports` Event is set).
**Approach:** hook streamrip's progress. streamrip drives progress via a callback/console; investigate its
API (it uses a `rip.utils`/`Downloadable` byte-progress hook or a `tqdm` callback). Aggregate downloaded
bytes / total bytes summed across the album's concurrent tracks → a single fraction → call the pipeline's
`on_progress(frac)` (which sets `reports` and writes throttled `Job.downloadProgress`). Keep the file-count
poller as the fallback (adapters that don't report). Match the throttle already in `_make_on_progress`
(≥1% move or every 0.5s).
**Test:** hard to unit-test streamrip internals — add a unit test that a fake byte-callback aggregates to a
monotonic 0→1 fraction, plus **live-verify** with a test press: `Job.downloadProgress` climbs mid-download
instead of 0→100. (Known caveat if streamrip exposes no usable byte hook: fall back to a smoother
file-count cadence, or accept the jump — document the finding.)
## Fix 2 — Parallelize the source searches (Soulseek adds ~2 min)
**Problem:** every acquisition spends up to ~2 min in the Soulseek search. `SlskdClient.search_album` polls
`_SEARCH_POLLS=40 × _POLL_SECONDS=3s = 120s` (early-exits on `isComplete`, but slskd searches genuinely run
long). The pipeline searches adapters SEQUENTIALLY (`for adapter in adapters: adapter.search(target)`), so
Soulseek's 2 min is added on top of Qobuz's ~instant search.
**Files:** `worker/lyra_worker/pipeline.py` (the match phase, `for adapter in adapters` loop);
optionally `worker/lyra_worker/adapters/_slskd.py` (`_SEARCH_POLLS`).
**Approach:** run the per-adapter searches **concurrently** (e.g. `ThreadPoolExecutor`, one thread per
adapter), collect all candidates when the last returns → total match wall-clock = slowest single search,
not the sum. So Qobuz returns instantly and only Soulseek's ~2 min gates. Keep the existing per-adapter
try/except (a failing source contributes nothing). **Thread-safety caveat:** streamrip uses a module-level
persistent asyncio loop (`_streamrip._run`) — verify concurrent Qobuz search from a worker thread is safe
(it runs coroutines on that shared loop; may need a lock or to keep Qobuz on the calling thread while
Soulseek/YouTube go to threads). Optionally also drop `_SEARCH_POLLS` to ~20 (60s) with the early-exit.
**Test:** a pipeline test asserting all adapters' candidates are collected (order-independent) when searched
concurrently; existing match/ranking tests stay green. Live-verify a press: match phase finishes in ~the
Soulseek time, not Soulseek+Qobuz.
## Fix 3 — Canonicalize bonus-track filenames on import
**Problem:** tracks BEYOND the MusicBrainz tracklist keep streamrip's raw name. Real example: Room for
Squares imported 12 clean `## Title.flac` + one `13. John Mayer - St. Patrick's Day (Album Version).flac`
(MB lists 12; Qobuz had a 13th bonus). The main tracklist gets `## Title` but extras don't.
**Files:** `worker/lyra_worker/library.py` (`import_album` — how it names/moves audio into
`Artist/Album (Year)/## Title.ext`), and/or the tagger `worker/lyra_worker/_mutagen.py`.
**Approach:** find where files are renamed to `## Title.ext`. For a track with no MB position/title (an
extra), derive a clean name from its own embedded tags (track number + title) or by stripping the
`^\d+\.?\s*(Artist\s*-\s*)?` prefix from the streamrip filename → `## Title.ext`. Ensure the position keeps
counting past the MB list (13, 14, …) and the on-disk `trackNames` capture stays consistent.
**Test:** unit-test the filename-canonicalizer on inputs like `13. John Mayer - St. Patrick's Day (Album
Version).flac``13 St. Patrick's Day (Album Version).flac`; live-verify by re-pressing an album with a
bonus track (or `docker compose exec worker` a rename dry-run). NOTE: the already-imported Room for Squares
track 13 can be fixed by re-pressing (force upgrade) or a one-off rename — mention to the user.
---
## Self-review / notes
- All three are worker-only → only the `worker` image rebuilds/redeploys (`docker compose up -d worker`).
- Don't disrupt the live stack: the VM is in use; deploy the worker, verify one press, done.
- If context/time is tight, do them in priority order: **2 (search speed)** and **3 (filenames)** are
self-contained and low-risk; **1 (streamrip progress)** is the deepest (streamrip internals) — timebox it
and fall back to documenting if streamrip has no clean byte hook.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,179 @@
# Press controls + inter-job delay — design
Date: 2026-07-15
## Problem
The monitor can enqueue a large backlog of acquisition jobs at once. The worker drains
them serially with **no pacing** (only a 2s idle poll when the queue is empty) and offers
**no runtime control** over the queue — the only per-job action on the Floor is Retry (on
`needs_attention`). The user wants:
1. A **configurable delay between downloads** to spread sustained Qobuz load over time.
2. **Per-item controls** on the Floor ("the Press"): pause and remove a queued item.
3. **Whole-press controls**: pause/resume, clear the queue, and a force-stop of the
currently-downloading album.
## Constraints (from the existing code)
- The worker is a **single, strictly-serial process** (`worker/lyra_worker/main.py:256-298`).
One job runs to completion in a blocking `run_pipeline` call before the loop does anything
else.
- **Config is read fresh every loop iteration** (`main.py:262`, `get_config`), so any Config
change takes effect within ~2s with no restart.
- An **in-flight download cannot be cleanly interrupted**`run_pipeline` and the adapter
download are synchronous/blocking with only an `on_progress` callback, no cancel token. The
only interruption is process death, after which `reclaim_stuck_jobs` (`claim.py:8-24`)
requeues the in-flight job at startup and `clear_staging_root` (`main.py:233`) wipes the
partial download.
- `claim_next` (`claim.py:27-57`) claims the oldest `state='requested'` job.
- Deleting a `Request` cascades to its `Job` and `Candidate` rows (`schema.prisma:51,66`); the
linked `MonitoredRelease` is `SetNull`, not cascaded.
- `enqueue_due` (`monitor.py:115-147`) re-enqueues a monitored release once it has no live
(non-terminal) job and its `lastSearchedAt` is older than `retryIntervalHours` (default 6).
So a removed/cleared monitored item reappears later on the normal cadence unless `ignored`.
- The web container runs `prisma migrate deploy` on startup (`web/docker-entrypoint.sh`), so a
new migration auto-applies on deploy.
## Decisions (confirmed with the user)
- **Active item:** add a global **force-kill** ("Stop now") that aborts the in-flight download
via worker process-restart. Per-item controls act only on queued items.
- **Remove / Clear semantics:** **cancel-for-now**. Manual one-off requests are gone for good;
monitored/wanted releases stay wanted and may reappear on the monitor's normal cadence.
Permanent skip remains the existing per-release **Ignore**.
- **Pause scope:** **downloads only**. Pausing halts claiming/downloading; the monitor keeps
finding and enqueueing releases (they pile up in the queue) and Resume drains them.
## Data model
- **New column** `Job.paused Boolean @default(false)` — a Prisma migration
(`add_job_paused`). Auto-applies on deploy.
- **Config keys** (key/value `Config` rows, no migration):
- `floor.paused``"true"`/`"false"`; global press pause.
- `floor.jobDelaySeconds` — integer string, default `"0"`; delay between downloads.
- `floor.killRequested``"true"`/`"false"`; one-shot force-kill signal, cleared by the
worker on consumption.
## Worker changes
`worker/lyra_worker/main.py` and `worker/lyra_worker/claim.py`.
1. **Inter-job delay.** After `finish_job` (`main.py:297`), read `floor.jobDelaySeconds` from
the already-fresh `config` and sleep that many seconds before the next loop iteration.
Sleep in small (~2s) chunks so the loop stays responsive; the force-kill watcher runs on a
separate thread and works regardless. Parse with the existing `_int`-style guard (invalid →
0). A value of 0 preserves today's back-to-back behavior.
2. **Global pause.** Immediately before `claim_next` (`main.py:286`), if `floor.paused` is
truthy (`_TRUE` idiom), skip claiming: `time.sleep(IDLE_SLEEP); continue`. Because the loop
is serial, any in-flight job has already finished before this check — pause stops the
*next* download. Monitor sweep / scan / discovery (above the claim in the loop body)
continue to run, so the queue keeps filling while paused.
3. **Per-item pause.** `claim_next` `WHERE` gains `AND NOT "paused"`. A paused queued job is
skipped by the claim but remains `state='requested'`, so it is not "lost" and resumes
claiming as soon as `paused` is cleared. `reclaim_stuck_jobs` is unaffected (it only touches
in-flight states).
4. **Force-kill watcher.** A daemon thread started in `run_forever` (modeled on
`_liveness_heartbeat`, `main.py:216-226`) polls `floor.killRequested` on its own DB
connection every ~1.5s. When set, it clears the flag (commit) and calls `os._exit(0)`. The
container restart policy brings the worker back; startup `clear_staging_root` +
`reclaim_stuck_jobs` wipe the partial download and requeue the aborted job. The "Stop now"
action also sets `floor.paused=true`, so the worker returns **idle** rather than instantly
re-downloading the aborted album; the user resumes when ready.
## Infrastructure (ops repo)
The prod worker's Docker restart policy is currently **`no`** (`docker inspect`
`RestartPolicy=no`), so process death — from force-kill *or any crash* — leaves it down. The
force-kill design requires `restart: unless-stopped` on the `worker` service in the ops repo
(`/opt/git/vm-download/docker/lyra/compose.yaml`). The in-repo `docker-compose.yml` already has
this (`docker-compose.yml:53`); this change brings prod in line and is good hygiene
independent of this feature. This is a one-line change in a separate repo and must be applied
before the force-kill feature is usable in prod.
## API (web) — follows existing `retry` / `library DELETE` patterns
- **`GET /api/floor`** → `{ paused: boolean, jobDelaySeconds: number }` (reads the Config
rows). Used by the Floor to render the paused banner and control states.
- **`POST /api/floor`** with `{ action }`:
- `"pause"` / `"resume"` → upsert `floor.paused`.
- `"clear"` → delete every `Request` whose `Job.state = 'requested'` (queued, not yet
claimed). Cascade removes the Jobs and Candidates. The in-flight and terminal jobs are
untouched. Returns the number cleared.
- `"stop"` → upsert `floor.paused='true'` and `floor.killRequested='true'`.
- **`POST /api/requests/[id]/pause`** with `{ paused: boolean }` → set `Job.paused`. Guard:
the job must be `state='requested'` (else `400`, matching the existing retry route), since
pausing an in-flight job has no effect.
- **`DELETE /api/requests/[id]`** → delete the `Request` (cascade Job + Candidates). Guard:
only when `Job.state = 'requested'`, so the active download is never touched (that is the
job of `Stop now`). Mirrors `DELETE /api/library/[id]`.
## Web UI
`web/src/app/queue.tsx` and `web/src/app/_ui/job-row.tsx`; styling via existing `.btn`
variants in `design.css`.
- **Press header** (above the queue list):
- `Pause the press` / `Resume` toggle (from `GET /api/floor`).
- `Clear queue` — confirm dialog showing the queued count; calls `POST /api/floor
{action:"clear"}`.
- `Stop now ⚠` — rendered only when a job is actively downloading (derivable from the
existing `/api/requests` data: any job in `matching`/`matched`/`downloading`/`tagging`).
Confirm dialog ("aborts the current download and pauses the press"); calls `POST /api/floor
{action:"stop"}`.
- A "Press paused — N queued" banner/chip when `paused`.
- **Per-item controls** (queued rows only — `job.state === 'requested'`), placed in the row
`note` slot as `.btn sm ghost`:
- `Pause` / `Resume` → `POST /api/requests/[id]/pause`.
- `Remove` → `DELETE /api/requests/[id]`.
- A per-item paused row shows a subtle "paused" chip and the `Resume` button.
- Active-download and `needs_attention` rows keep their current rendering (Retry unchanged).
- Polling cadence stays 2s; `GET /api/floor` is fetched alongside the existing three calls in
`refresh()`.
## Settings
Add a numeric field **"Delay between downloads (seconds)"** (default 0, min 0) to the settings
form (`web/src/app/settings/settings-form.tsx`), persisted to `floor.jobDelaySeconds` via the
existing settings config-write path (the same mechanism used for the other numeric/monitor
settings). Include short helptext explaining it paces sustained Qobuz load.
## Testing
- **Worker** (pytest, `worker/tests/`):
- `claim_next` skips `paused=true` queued jobs and still claims the next unpaused one.
- Global pause: the loop skips claiming when `floor.paused` is set (unit-test the claim-gate
helper).
- Delay: the delay value is read and applied (unit-test the parse/gate helper; keep the
sleep injectable/mockable).
- Kill watcher: when `floor.killRequested` is set, the watcher clears the flag and triggers
exit (mock `os._exit`, assert flag cleared + exit called).
- **Web** (route tests, mirroring `requests/[id]/retry/route.test.ts` and
`library/bulk/route.test.ts`):
- `POST /api/floor` for each action; `clear` deletes only `requested` jobs; `stop` sets both
flags.
- `POST /api/requests/[id]/pause` toggles `Job.paused`; rejects non-`requested`.
- `DELETE /api/requests/[id]` removes a queued Request; refuses an in-flight/terminal job.
- `GET /api/floor` returns the Config-derived state.
## Edge cases
- **Remove/Clear of monitored items** reappear later by design (cancel-for-now). No extra
handling; the per-release Ignore is the permanent path.
- **Kill with nothing downloading** still restarts the worker (brief blip). Minimized by only
showing `Stop now` while a download is active.
- **Delay during pause:** the delay sleeps only *between* jobs; pause is checked before each
claim, so a paused press never starts a delayed job.
- **Race:** clicking per-item Pause/Remove on a job that was just claimed — the guards
(`state='requested'`) reject it; the UI only renders those buttons on queued rows, and the
2s refresh reconciles.
## Out of scope
- Resuming a partially-downloaded album (no download-resume support; force-killed jobs
re-download from scratch).
- Reordering the queue / per-item priority.
- Any change to the per-release Ignore or the wanted list UI.
@@ -0,0 +1,83 @@
# Qobuz pacing ("budget gate") — design
Date: 2026-07-15
## Problem
Bulk hi-res downloading got the Qobuz account blocked (`403 USER_BLOCKED`). We need to keep
using Qobuz for hi-res but at a volume and cadence that looks like a human listener, so the
account isn't flagged again — while a large wanted-list still drains promptly via other sources.
## Approach: a per-job Qobuz eligibility gate
One gate decides, **per album**, whether Qobuz is an eligible source *right now*. If the gate is
closed, Qobuz candidates are dropped before ranking, so the worker falls through to the best
non-Qobuz source (Soulseek/YouTube) and keeps moving — no idling. Monitored albums grabbed that
way are upgraded to Qobuz hi-res later, automatically, via the existing quality-upgrade window,
once Qobuz has budget again.
**Qobuz is eligible for a job only if ALL hold:**
1. **Active hours** — current hour ∈ [activeStart, activeEnd) (a daytime window; start < end).
2. **Under today's cap** — Qobuz downloads counted today < today's cap.
3. **Spacing elapsed** — now ≥ the randomized "next allowed" time set after the last Qobuz
download.
After each successful Qobuz download the worker bumps today's counter and sets the next-allowed
time. The counter resets at the day boundary (tracked by a stored date).
**Today's cap = warm-up ramp of the steady cap**, by account age (days since `warmupStartDate`):
| Account age (days) | Cap |
|---|---|
| 02 | min(5, steady) |
| 36 | min(10, steady) |
| 713 | min(20, steady) |
| 14+ | steady |
If `warmupStartDate` is unset, no ramp (steady cap applies).
**Spacing / jitter (combined):** the gap to the next Qobuz download is randomized around
`activeWindowSeconds / todayCap` (so the day's budget spreads evenly across active hours), ±30%,
with a hard floor (≥ 60 s). This *is* the jitter — never a fixed metronome — and it prevents
front-loading the whole daily cap into the first hour.
## Configuration (Config keys; editable in Settings → Qobuz → Pacing)
Settings:
- `qobuz.pacing.enabled` (default `true`)
- `qobuz.pacing.activeStartHour` (default `8`)
- `qobuz.pacing.activeEndHour` (default `23`)
- `qobuz.pacing.dailyCap` (steady cap, default `40`)
- `qobuz.pacing.warmupStartDate` (ISO date; set to the new account's start day)
Worker-managed state (not user-edited):
- `qobuz.pacing.countDay` (date the counter is for)
- `qobuz.pacing.countToday` (int)
- `qobuz.pacing.nextAllowedAt` (epoch seconds)
Current hour / day come from the **DB clock** (`now()`), avoiding container-timezone ambiguity.
Config is read fresh each worker loop, so changes take effect with no restart.
## Worker integration
- New module `worker/lyra_worker/qobuz_gate.py`: a `QobuzPacing` config dataclass
(`from_config`), a pure `today_cap(...)`, a pure `is_allowed(...)` decision, and thin
DB helpers `gate_open(conn, now)` (reads state + decides) and `record_download(conn, pacing,
now)` (bumps counter + sets next-allowed).
- `pipeline.py`: after searching sources, if `gate_open` is false AND at least one non-Qobuz
candidate exists, drop Qobuz candidates before ranking (fall-through). If Qobuz is the *only*
source available, keep it (a Qobuz-only setup isn't gated — holding would strand the album).
- After a successful download where `winner.source == "qobuz"`, call `record_download`.
## Testing
Pure-logic unit tests for `is_allowed` (each gate condition), `today_cap` (ramp boundaries), and
the spacing computation; pipeline tests that a gated Qobuz candidate is dropped in favour of a
non-Qobuz source, that an open gate keeps Qobuz, and that a successful Qobuz download records the
counter/next-allowed.
## Out of scope
- Holding/parking an album for later Qobuz when Qobuz is the only source (fails/last-source as
today). Multi-source setups fall through, which is the common case.
- Timezone-aware active-hours windows that wrap past midnight (start < end only).
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Build the Lyra web + worker images and push them to the Gitea container registry, so the VM
# can pull pre-built images instead of building from source.
#
# Usage (from the Lyra repo root):
# docker login git.jger.nl # once — Gitea username + a token with package:write
# ./scripts/build-push.sh # builds + pushes :latest and :<short-sha>
#
# Override the registry namespace with LYRA_REGISTRY (default git.jger.nl/jonathan).
set -euo pipefail
REGISTRY="${LYRA_REGISTRY:-git.jger.nl/jonathan}"
here="$(cd "$(dirname "$0")/.." && pwd)"
sha="$(git -C "$here" rev-parse --short HEAD)"
for svc in web worker; do
img="$REGISTRY/lyra-$svc"
echo "==> building $img ($sha)"
docker build -t "$img:latest" -t "$img:$sha" "$here/$svc"
done
for svc in web worker; do
img="$REGISTRY/lyra-$svc"
echo "==> pushing $img:latest and $img:$sha"
docker push "$img:latest"
docker push "$img:$sha"
done
echo "Done. Pulled on the VM with: docker compose pull && docker compose up -d"
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Job" ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- Estimated seconds remaining for the active download (measured throughput). Nullable.
ALTER TABLE "Job" ADD COLUMN "downloadEtaSeconds" INTEGER;
+4
View File
@@ -58,6 +58,10 @@ model Job {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
downloadProgress Float @default(0)
// Estimated seconds until the current download finishes, from measured throughput (slskd byte
// transfer rate). Null when unknown (no speed sample yet, or a source that doesn't report bytes).
downloadEtaSeconds Int?
paused Boolean @default(false)
candidates Candidate[]
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { Modal } from "./modal";
import type { ArtistHit } from "@/lib/artist-resolve";
/** Disambiguation picker: several MusicBrainz artists share a name (e.g. two bands both called
* "Looking Glass"), so let the user choose the right one by its MB disambiguation text instead
* of silently trusting Last.fm's — sometimes wrong — MBID. Candidates arrive in MB relevance
* order, so the first is flagged "Suggested". */
export function ArtistPicker({
open,
name,
candidates,
onPick,
onClose,
}: {
open: boolean;
name: string;
candidates: ArtistHit[];
onPick: (mbid: string, name: string) => void;
onClose: () => void;
}) {
return (
<Modal open={open} onClose={onClose} title="Which artist?">
<p className="muted-note">
More than one artist is named {name}. Pick the right one.
</p>
<ul className="list">
{candidates.map((c, i) => (
<li key={c.mbid} className="list-row">
<div className="main">
<div className="rtitle">
<button className="linkish" onClick={() => onPick(c.mbid, c.name)}>
{c.name}
</button>
</div>
<div className="rmeta">
<span>{c.disambiguation || "no description on MusicBrainz"}</span>
</div>
</div>
<div className="actions">
{i === 0 ? <span className="chip working">Suggested</span> : null}
</div>
</li>
))}
</ul>
</Modal>
);
}
+3
View File
@@ -14,6 +14,7 @@ export function JobRow({
album,
kind,
label,
marker,
meta,
note,
bar,
@@ -22,6 +23,7 @@ export function JobRow({
album: string;
kind: Kind;
label: string;
marker?: string;
meta?: ReactNode;
note?: ReactNode;
bar?: ReactNode;
@@ -31,6 +33,7 @@ export function JobRow({
<div className="stripe" />
<div>
<h3 className="title">
{marker ? <span className="nextup">{marker}</span> : null}
{album} <span className="artist">· {artist}</span>
</h3>
{meta ? <div className="meta">{meta}</div> : null}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
/** A vertical "⋮" trigger that opens a small popup menu. Closes on outside click, Escape, or any
* click inside the popup (so menu items don't each need to manage it). Children are the items —
* use <button className="menu-item">…</button>. */
export function KebabMenu({ label = "Actions", children }: { label?: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div className="menu" ref={ref}>
<button
type="button"
className="menu-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
onClick={() => setOpen((o) => !o)}
>
</button>
{open ? (
<div className="menu-pop" role="menu" onClick={() => setOpen(false)}>
{children}
</div>
) : null}
</div>
);
}
+16 -1
View File
@@ -1,5 +1,20 @@
import { describe, it, expect } from "vitest";
import { describeJob, timeAgo } from "./status";
import { describeJob, etaLabel, timeAgo } from "./status";
describe("etaLabel", () => {
it("formats seconds, minutes, and hours", () => {
expect(etaLabel(45)).toBe("~45s left");
expect(etaLabel(240)).toBe("~4m left");
expect(etaLabel(3900)).toBe("~1h 5m left");
});
it("returns null for missing or non-positive input", () => {
expect(etaLabel(null)).toBeNull();
expect(etaLabel(undefined)).toBeNull();
expect(etaLabel(0)).toBeNull();
expect(etaLabel(-5)).toBeNull();
});
});
describe("describeJob", () => {
it("maps pipeline states to literal labels + severity kind", () => {
+10
View File
@@ -27,6 +27,16 @@ export function timeAgo(value: string | Date | null | undefined, now: number): s
return `${Math.floor(h / 24)}d`;
}
/** Compact download ETA like "~4m left", "~45s left", "~1h 5m left". Null/≤0 → no label. */
export function etaLabel(seconds: number | null | undefined): string | null {
if (seconds == null || seconds <= 0) return null;
if (seconds < 60) return `~${seconds}s left`;
const m = Math.round(seconds / 60);
if (m < 60) return `~${m}m left`;
const h = Math.floor(m / 60);
return `~${h}h ${m % 60}m left`;
}
export function describeJob(state: string, stage: string): { label: string; kind: Kind } {
void stage; // stage no longer drives the label; kept for call-site compatibility
return (
@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { POST } from "./route";
describe("POST /api/discover/reset", () => {
it("sets the reset + requested flags so the worker rebuilds on the next sweep", async () => {
const res = await POST();
expect(res.status).toBe(202);
expect((await res.json()).reset).toBe(true);
const cfg = Object.fromEntries(
(await prisma.config.findMany({ where: { key: { in: ["discover.resetRequested", "discover.requested"] } } }))
.map((c) => [c.key, c.value]),
);
expect(cfg["discover.resetRequested"]).toBe("true");
expect(cfg["discover.requested"]).toBe("true");
});
});
+17
View File
@@ -0,0 +1,17 @@
import { prisma } from "@/lib/db";
// POST /api/discover/reset — request a full rebuild: the worker clears the pending
// suggestions, contributions, and seed throttles at the start of the next sweep, then
// regenerates everything with the current logic. Kicks a sweep immediately.
export async function POST() {
await Promise.all(
["discover.resetRequested", "discover.requested"].map((key) =>
prisma.config.upsert({
where: { key },
create: { key, value: "true", secret: false },
update: { value: "true" },
}),
),
);
return Response.json({ reset: true }, { status: 202 });
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";
import { prisma } from "@/lib/db";
function post(action: string) {
return new Request("http://localhost/api/floor", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action }),
});
}
async function flag(key: string) {
return (await prisma.config.findUnique({ where: { key } }))?.value ?? null;
}
async function queuedRequest(album: string, state = "requested") {
return prisma.request.create({
data: { artist: "A", album, status: "pending", job: { create: { state: state as never } } },
include: { job: true },
});
}
describe("floor API", () => {
it("GET reports paused=false by default", async () => {
const res = await GET();
expect(await res.json()).toEqual({ paused: false });
});
it("pause and resume toggle floor.paused", async () => {
await POST(post("pause"));
expect(await flag("floor.paused")).toBe("true");
const paused = await (await GET()).json();
expect(paused).toEqual({ paused: true });
await POST(post("resume"));
expect(await flag("floor.paused")).toBe("false");
});
it("stop sets both paused and killRequested", async () => {
const res = await POST(post("stop"));
expect(res.status).toBe(200);
expect(await flag("floor.paused")).toBe("true");
expect(await flag("floor.killRequested")).toBe("true");
});
it("clear deletes only queued (requested) requests", async () => {
const queued = await queuedRequest("Queued");
const active = await queuedRequest("Downloading", "downloading");
const res = await POST(post("clear"));
const body = await res.json();
expect(body.cleared).toBe(1);
expect(await prisma.request.findUnique({ where: { id: queued.id } })).toBeNull();
expect(await prisma.request.findUnique({ where: { id: active.id } })).not.toBeNull();
});
it("rejects an unknown action", async () => {
const res = await POST(post("nope"));
expect(res.status).toBe(400);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { prisma } from "@/lib/db";
async function getFlag(key: string): Promise<boolean> {
const row = await prisma.config.findUnique({ where: { key } });
return row?.value === "true";
}
async function setFlag(key: string, value: boolean): Promise<void> {
const v = value ? "true" : "false";
await prisma.config.upsert({
where: { key },
create: { key, value: v, secret: false },
update: { value: v },
});
}
export async function GET() {
return Response.json({ paused: await getFlag("floor.paused") });
}
export async function POST(request: Request) {
let body: { action?: string };
try {
body = (await request.json()) as { action?: string };
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
switch (body.action) {
case "pause":
await setFlag("floor.paused", true);
return Response.json({ ok: true, paused: true });
case "resume":
await setFlag("floor.paused", false);
return Response.json({ ok: true, paused: false });
case "stop":
await setFlag("floor.paused", true);
await setFlag("floor.killRequested", true);
return Response.json({ ok: true, paused: true, stopped: true });
case "clear": {
const { count } = await prisma.request.deleteMany({
where: { job: { is: { state: "requested" } } },
});
return Response.json({ ok: true, cleared: count });
}
default:
return Response.json({ error: "unknown action" }, { status: 400 });
}
}
@@ -34,3 +34,23 @@ describe("monitor config API", () => {
expect(await prisma.config.count()).toBe(before);
});
});
describe("monitor config — floor.jobDelaySeconds", () => {
it("defaults jobDelaySeconds to 0 and round-trips a new value", async () => {
const before = await (await GET()).json();
expect(before["floor.jobDelaySeconds"]).toBe("0");
await PATCH(
new Request("http://localhost/api/monitor/config", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ "floor.jobDelaySeconds": "45" }),
}),
);
const stored = await prisma.config.findUnique({ where: { key: "floor.jobDelaySeconds" } });
expect(stored?.value).toBe("45");
const after = await (await GET()).json();
expect(after["floor.jobDelaySeconds"]).toBe("45");
});
});
+1
View File
@@ -6,6 +6,7 @@ const DEFAULTS: Record<string, string> = {
"monitor.retryIntervalHours": "6",
"monitor.qualityCutoff": "2",
"monitor.upgradeWindowDays": "14",
"floor.jobDelaySeconds": "0",
};
export async function GET() {
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { GET, PATCH } from "./route";
import { prisma } from "@/lib/db";
function patch(body: Record<string, string>) {
return new Request("http://localhost/api/qobuz/pacing", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("qobuz pacing config API", () => {
it("returns defaults when nothing is stored", async () => {
const cfg = await (await GET()).json();
expect(cfg["qobuz.pacing.enabled"]).toBe("true");
expect(cfg["qobuz.pacing.activeStartHour"]).toBe("8");
expect(cfg["qobuz.pacing.activeEndHour"]).toBe("23");
expect(cfg["qobuz.pacing.dailyCap"]).toBe("40");
expect(cfg["qobuz.pacing.warmupStartDate"]).toBe("");
});
it("round-trips allow-listed settings and persists them", async () => {
await PATCH(patch({ "qobuz.pacing.dailyCap": "25", "qobuz.pacing.activeStartHour": "9" }));
const stored = await prisma.config.findUnique({ where: { key: "qobuz.pacing.dailyCap" } });
expect(stored?.value).toBe("25");
const cfg = await (await GET()).json();
expect(cfg["qobuz.pacing.dailyCap"]).toBe("25");
expect(cfg["qobuz.pacing.activeStartHour"]).toBe("9");
});
it("ignores keys not in the allow-list (e.g. worker-managed counters)", async () => {
await PATCH(patch({ "qobuz.pacing.countToday": "999", "qobuz.pacing.dailyCap": "30" }));
expect(await prisma.config.findUnique({ where: { key: "qobuz.pacing.countToday" } })).toBeNull();
expect((await prisma.config.findUnique({ where: { key: "qobuz.pacing.dailyCap" } }))?.value).toBe("30");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { prisma } from "@/lib/db";
// Qobuz pacing ("budget gate") settings. The worker reads these Config keys live; state keys
// (countDay/countToday/nextAllowedAt) are worker-managed and intentionally NOT editable here.
const DEFAULTS: Record<string, string> = {
"qobuz.pacing.enabled": "true",
"qobuz.pacing.activeStartHour": "8",
"qobuz.pacing.activeEndHour": "23",
"qobuz.pacing.dailyCap": "40",
"qobuz.pacing.warmupStartDate": "",
};
export async function GET() {
const rows = await prisma.config.findMany({ where: { key: { in: Object.keys(DEFAULTS) } } });
const stored = Object.fromEntries(rows.map((r) => [r.key, r.value]));
return Response.json(
Object.fromEntries(Object.entries(DEFAULTS).map(([k, d]) => [k, stored[k] ?? d])),
);
}
export async function PATCH(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
const entries = Object.entries((body ?? {}) as Record<string, unknown>).filter(([k]) =>
Object.prototype.hasOwnProperty.call(DEFAULTS, k),
);
for (const [key, value] of entries) {
await prisma.config.upsert({
where: { key },
create: { key, value: String(value), secret: false },
update: { value: String(value) },
});
}
return Response.json({ updated: entries.length });
}
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { POST } from "./route";
import { prisma } from "@/lib/db";
import type { Prisma } from "@prisma/client";
function req(id: string, paused: boolean) {
return new Request(`http://localhost/api/requests/${id}/pause`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ paused }),
});
}
async function seed(jobData: Prisma.JobCreateWithoutRequestInput) {
return prisma.request.create({
data: { artist: "A", album: "B", status: "pending", job: { create: jobData } },
include: { job: true },
});
}
describe("per-item pause API", () => {
it("pauses a queued job", async () => {
const created = await seed({ state: "requested" });
const res = await POST(req(created.id, true), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(200);
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
expect(job?.paused).toBe(true);
});
it("resumes a paused job", async () => {
const created = await seed({ state: "requested", paused: true });
await POST(req(created.id, false), { params: Promise.resolve({ id: created.id }) });
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
expect(job?.paused).toBe(false);
});
it("rejects pausing an in-flight job", async () => {
const created = await seed({ state: "downloading", paused: true });
const res = await POST(req(created.id, false), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(400);
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
expect(job?.paused).toBe(true);
});
it("404s for a missing request", async () => {
const res = await POST(req("nope", true), { params: Promise.resolve({ id: "nope" }) });
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,26 @@
import { prisma } from "@/lib/db";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let body: { paused?: unknown };
try {
body = (await request.json()) as { paused?: unknown };
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
if (typeof body.paused !== "boolean") {
return Response.json({ error: "paused must be a boolean" }, { status: 400 });
}
const req = await prisma.request.findUnique({ where: { id }, include: { job: true } });
if (!req || !req.job) {
return Response.json({ error: "not found" }, { status: 404 });
}
if (req.job.state !== "requested") {
return Response.json({ error: "only queued jobs can be paused" }, { status: 400 });
}
await prisma.job.update({ where: { id: req.job.id }, data: { paused: body.paused } });
return Response.json({ ok: true, paused: body.paused });
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { DELETE } from "./route";
import { prisma } from "@/lib/db";
import type { Prisma } from "@prisma/client";
function del(id: string) {
return new Request(`http://localhost/api/requests/${id}`, { method: "DELETE" });
}
async function seed(jobData: Prisma.JobCreateWithoutRequestInput) {
return prisma.request.create({
data: { artist: "A", album: "B", status: "pending", job: { create: jobData } },
include: { job: true },
});
}
describe("remove request API", () => {
it("deletes a queued request and cascades its job", async () => {
const created = await seed({ state: "requested" });
const res = await DELETE(del(created.id), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(200);
expect(await prisma.request.findUnique({ where: { id: created.id } })).toBeNull();
expect(await prisma.job.findUnique({ where: { id: created.job!.id } })).toBeNull();
});
it("refuses to remove an in-flight job", async () => {
const created = await seed({ state: "downloading" });
const res = await DELETE(del(created.id), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(400);
expect(await prisma.request.findUnique({ where: { id: created.id } })).not.toBeNull();
});
it("404s for a missing request", async () => {
const res = await DELETE(del("nope"), { params: Promise.resolve({ id: "nope" }) });
expect(res.status).toBe(404);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { prisma } from "@/lib/db";
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const req = await prisma.request.findUnique({ where: { id }, include: { job: true } });
if (!req) {
return Response.json({ error: "not found" }, { status: 404 });
}
if (req.job && req.job.state !== "requested") {
return Response.json({ error: "only queued items can be removed" }, { status: 400 });
}
await prisma.request.delete({ where: { id } }); // cascades Job + Candidates
return Response.json({ ok: true });
}
+2
View File
@@ -64,6 +64,8 @@ export async function GET() {
claimedAt: r.job.claimedAt,
updatedAt: r.job.updatedAt,
downloadProgress: r.job.downloadProgress,
downloadEtaSeconds: r.job.downloadEtaSeconds,
paused: r.job.paused,
candidateCount: cands.length,
chosen: chosen ? { source: chosen.source, format: chosen.format, trackCount: chosen.trackCount } : null,
};
+49 -10
View File
@@ -245,6 +245,9 @@ nav.contents .sep { flex: 1; }
.btn.ghost { border-color: var(--rule-2); color: var(--graphite); }
.btn.ghost:hover { background: transparent; border-color: var(--ink); color: var(--ink); }
.press-controls { display: flex; gap: 0.5rem; align-items: center; margin: 0 0 1rem; flex-wrap: wrap; }
.item-controls { display: inline-flex; gap: 0.5rem; align-items: center; }
.toggle {
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--graphite); display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
@@ -287,16 +290,19 @@ nav.contents .sep { flex: 1; }
.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.rmeta.why { font-style: italic; color: var(--graphite); margin-top: 2px; }
/* Discover unified row: artist block | inline album thumbnails | actions */
.disco-row { align-items: flex-start; flex-wrap: wrap; gap: 14px; }
.disco-row .main { flex: 1 1 180px; }
.disco-albums { display: flex; gap: 12px; flex-wrap: wrap; }
.disco-album { display: flex; flex-direction: column; gap: 4px; width: 92px; }
.disco-thumb { display: flex; flex-direction: column; gap: 4px; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
.disco-thumb .thumb { width: 92px; height: 92px; }
.disco-album .da-title { font-size: 12px; line-height: 1.2; color: var(--ink); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.disco-album .badge { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--graphite); }
.disco-album .btn.sm { padding: 2px 8px; }
/* Discover unified row: artist block | compact album list (stacked) | actions.
Each album is a small horizontal item (cover + title/badge on one line) so two stack
vertically without making the row taller than the artist block. */
.disco-row { align-items: center; flex-wrap: wrap; gap: 14px 20px; }
.disco-row .main { flex: 1 1 200px; }
.disco-albums { display: flex; flex-direction: column; gap: 6px; flex: 0 1 300px; min-width: 220px; }
.disco-album { display: flex; align-items: center; gap: 8px; }
.disco-thumb { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
.disco-thumb .da-cover { width: 30px; height: 30px; flex-shrink: 0; }
.da-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.15; }
.disco-album .da-title { font-size: 12px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.disco-album .badge { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--graphite); }
.disco-album .btn.sm.want { padding: 1px 8px; flex-shrink: 0; }
.sel-tools { display: inline-flex; align-items: center; gap: 10px; }
.bulk-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 12px; padding: 8px 12px; border: 1px solid var(--rule); background: var(--paper-2); }
.album-card { position: relative; }
@@ -380,6 +386,39 @@ nav.contents .sep { flex: 1; }
.tab:hover { color: var(--ink); }
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
.tab .n { color: var(--rule-2); margin-left: 6px; }
.tab.has-errors .n { color: var(--alert); }
/* "Next up" badge on the first queued row (the item the worker claims next) */
.nextup {
display: inline-block; vertical-align: middle; margin-right: 9px; transform: translateY(-2px);
font-family: var(--mono); font-size: 0.56rem; letter-spacing: 0.13em; text-transform: uppercase;
color: var(--accent-ink); background: var(--accent); padding: 2px 7px; border-radius: 4px;
}
/* ── Kebab menu (⋮ popup for press actions) ───────────── */
.dept.has-actions { align-items: center; }
.dept.has-actions .fill { transform: none; }
.menu { position: relative; display: inline-flex; }
.menu-trigger {
font-size: 1.15rem; line-height: 1; color: var(--graphite); background: transparent;
border: 1px solid var(--rule); border-radius: 6px; width: 32px; height: 30px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.menu-trigger:hover { color: var(--ink); border-color: var(--rule-2); }
.menu-pop {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; min-width: 190px;
background: var(--paper-2); border: 1px solid var(--rule-2); border-radius: 9px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22); padding: 5px;
display: flex; flex-direction: column; gap: 1px;
}
.menu-item {
font-family: var(--mono); font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase;
text-align: left; background: transparent; border: 0; color: var(--ink);
padding: 10px 11px; border-radius: 6px; cursor: pointer; white-space: nowrap;
}
.menu-item:hover { background: var(--rule); }
.menu-item:disabled { color: var(--rule-2); cursor: default; background: transparent; }
.menu-item.danger { color: var(--alert); }
/* ── Discography row (cover thumb + click-to-modal) ───── */
.disco-open {
+52 -15
View File
@@ -36,6 +36,12 @@ function similarTo(seeds: string[]): string | null {
return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`;
}
/** Full-length albums only — hide singles/EPs and secondary-type releases (live/comp) that
* pre-date the albums-only derivation and still linger in the DB. */
function isFullAlbum(a: Album): boolean {
return (a.primaryType ?? "Album") === "Album" && (a.secondaryTypes?.length ?? 0) === 0;
}
/** Human badge for why an album is surfaced. */
function badgeFor(reason: string | null): string | null {
if (reason === "newest") return "Newest";
@@ -63,6 +69,7 @@ export function DiscoverClient() {
const [query, setQuery] = useState("");
const [search, setSearch] = useState<SearchResult | null>(null);
const [busy, setBusy] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
const [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null);
@@ -100,6 +107,18 @@ export function DiscoverClient() {
setRunning(true);
}
async function resetDiscover() {
setConfirmReset(false);
const res = await fetch("/api/discover/reset", { method: "POST" }).catch(() => null);
if (res && (res.ok || res.status === 202)) {
setRows([]); // clears immediately; the rebuild repopulates
setRunning(true);
toast("Rebuilding suggestions from scratch…");
} else {
toast("Couldn't reset");
}
}
async function runSearch(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
@@ -163,13 +182,26 @@ export function DiscoverClient() {
<button className="btn accent" onClick={discoverNow} disabled={running}>
{running ? "Discovering…" : "Discover now"}
</button>
<span className="rmeta">
{running
? "Sweeping your followed artists for new suggestions…"
: lastRunAt
? `Last discovered ${agoLabel(lastRunAt)}${result ? ` · ${result}` : ""}`
: "Not yet run — press Discover now to sweep your followed artists"}
</span>
{confirmReset ? (
<>
<span className="rmeta">Clear all suggestions and rebuild from scratch?</span>
<button className="btn sm accent" onClick={resetDiscover}>Reset</button>
<button className="btn sm ghost" onClick={() => setConfirmReset(false)}>Cancel</button>
</>
) : (
<>
<button className="btn sm ghost" onClick={() => setConfirmReset(true)} disabled={running}>
Reset
</button>
<span className="rmeta">
{running
? "Sweeping your followed artists for new suggestions…"
: lastRunAt
? `Last discovered ${agoLabel(lastRunAt)}${result ? ` · ${result}` : ""}`
: "Not yet run — press Discover now to sweep your followed artists"}
</span>
</>
)}
</div>
<SectionHeader title="Find similar" />
@@ -214,7 +246,9 @@ export function DiscoverClient() {
<p className="muted-note">No suggestions yet run Discover, or follow a few artists to seed it.</p>
) : (
<ul className="list">
{rows.map((row) => (
{rows.map((row) => {
const albums = row.albums.filter(isFullAlbum);
return (
<li key={row.artistMbid} className="list-row disco-row">
<div className="main">
<div className="rtitle">
@@ -230,20 +264,22 @@ export function DiscoverClient() {
{similarTo(row.seeds) ? <div className="rmeta why">{similarTo(row.seeds)}</div> : null}
</div>
{row.albums.length > 0 ? (
{albums.length > 0 ? (
<div className="disco-albums">
{row.albums.map((a) => (
{albums.map((a) => (
<div key={a.id} className="disco-album">
<button
className="disco-thumb"
onClick={() => setAlbumModal({ a, artistName: row.artistName, artistMbid: row.artistMbid })}
aria-label={`Open ${a.album ?? ""}`}
>
<CoverArt rgMbid={a.rgMbid} alt={a.album ?? ""} className="thumb" />
<span className="da-title">{a.album}</span>
{badgeFor(a.albumReason) ? <span className="badge">{badgeFor(a.albumReason)}</span> : null}
<CoverArt rgMbid={a.rgMbid} alt={a.album ?? ""} className="da-cover" />
<span className="da-text">
<span className="da-title">{a.album}</span>
{badgeFor(a.albumReason) ? <span className="badge">{badgeFor(a.albumReason)}</span> : null}
</span>
</button>
<button className="btn sm" onClick={() => act(a.id, "want")}>
<button className="btn sm ghost want" onClick={() => act(a.id, "want")}>
Want
</button>
</div>
@@ -262,7 +298,8 @@ export function DiscoverClient() {
) : null}
</div>
</li>
))}
);
})}
</ul>
)}
Binary file not shown.
Binary file not shown.
+74
View File
@@ -31,6 +31,7 @@ const MONITOR_FIELDS: [string, string][] = [
["monitor.retryIntervalHours", "Retry interval (hours)"],
["monitor.qualityCutoff", "Quality cutoff"],
["monitor.upgradeWindowDays", "Upgrade window (days)"],
["floor.jobDelaySeconds", "Delay between downloads (seconds)"],
];
export function SettingsForm() {
@@ -53,6 +54,8 @@ export function SettingsForm() {
const [discoverSaved, setDiscoverSaved] = useState(false);
const [monitorCfg, setMonitorCfg] = useState<Record<string, string>>({});
const [monitorSaved, setMonitorSaved] = useState(false);
const [pacingCfg, setPacingCfg] = useState<Record<string, string>>({});
const [pacingSaved, setPacingSaved] = useState(false);
useEffect(() => {
fetch("/api/scan")
@@ -105,6 +108,12 @@ export function SettingsForm() {
.then((c: Record<string, string>) => setMonitorCfg(c));
}, []);
useEffect(() => {
fetch("/api/qobuz/pacing")
.then((r) => r.json())
.then((c: Record<string, string>) => setPacingCfg(c));
}, []);
async function saveCredentials(e: React.FormEvent) {
e.preventDefault();
await fetch("/api/config", {
@@ -159,6 +168,21 @@ export function SettingsForm() {
setTimeout(() => setMonitorSaved(false), 1500);
}
function setPacing(key: string, value: string) {
setPacingCfg((c) => ({ ...c, [key]: value }));
}
async function savePacing(e: React.FormEvent) {
e.preventDefault();
await fetch("/api/qobuz/pacing", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(pacingCfg),
});
setPacingSaved(true);
setTimeout(() => setPacingSaved(false), 1500);
}
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
async function clearField(field: "qobuzToken" | "slskdApiKey" | "lastfmApiKey") {
@@ -192,6 +216,7 @@ export function SettingsForm() {
</div>
{tab === "Qobuz" ? (
<>
<form className="settings-section" onSubmit={saveCredentials}>
<div className="subhead">
Auth token
@@ -234,6 +259,55 @@ export function SettingsForm() {
{saved ? <span className="saved-note">Saved</span> : null}
</div>
</form>
<form className="settings-section" onSubmit={savePacing}>
<div className="subhead">
Pacing
<HelpButton title="Qobuz pacing">
Throttles Qobuz to a human-looking volume and cadence so the account isn&apos;t flagged
for bulk downloading. Within active hours it downloads up to the daily cap, spaced out
with randomized gaps; when throttled, albums fall through to Soulseek/YouTube and
upgrade to Qobuz hi-res later. The daily cap ramps up over the first two weeks from the
new-account start date.
</HelpButton>
</div>
<label className="field-inline">
<input type="checkbox" aria-label="pace qobuz downloads"
checked={pacingCfg["qobuz.pacing.enabled"] !== "false"}
onChange={(e) => setPacing("qobuz.pacing.enabled", e.target.checked ? "true" : "false")} />
<span>Pace Qobuz downloads</span>
</label>
<div className="field-grid">
<label className="field">
<span>Active hours start (024)</span>
<input type="number" min={0} max={24} aria-label="qobuz active start hour"
value={pacingCfg["qobuz.pacing.activeStartHour"] ?? ""}
onChange={(e) => setPacing("qobuz.pacing.activeStartHour", e.target.value)} />
</label>
<label className="field">
<span>Active hours end (024)</span>
<input type="number" min={0} max={24} aria-label="qobuz active end hour"
value={pacingCfg["qobuz.pacing.activeEndHour"] ?? ""}
onChange={(e) => setPacing("qobuz.pacing.activeEndHour", e.target.value)} />
</label>
<label className="field">
<span>Daily cap (steady)</span>
<input type="number" min={0} aria-label="qobuz daily cap"
value={pacingCfg["qobuz.pacing.dailyCap"] ?? ""}
onChange={(e) => setPacing("qobuz.pacing.dailyCap", e.target.value)} />
</label>
<label className="field">
<span>New-account start (warm-up)</span>
<input type="date" aria-label="qobuz warmup start date"
value={pacingCfg["qobuz.pacing.warmupStartDate"] ?? ""}
onChange={(e) => setPacing("qobuz.pacing.warmupStartDate", e.target.value)} />
</label>
</div>
<div className="save-row">
<button type="submit" className="btn accent">Save</button>
{pacingSaved ? <span className="saved-note">Saved</span> : null}
</div>
</form>
</>
) : null}
{tab === "Soulseek" ? (
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { resolveArtistChoice, type ArtistHit } from "./artist-resolve";
const US: ArtistHit = { mbid: "us", name: "Looking Glass", disambiguation: "70's US pop group" };
const AU: ArtistHit = { mbid: "au", name: "Looking Glass", disambiguation: "Australian stoner rock" };
describe("resolveArtistChoice", () => {
it("is ambiguous when 2+ candidates share the exact name", () => {
expect(resolveArtistChoice("Looking Glass", [US, AU], "au")).toEqual({
kind: "ambiguous",
candidates: [US, AU],
});
});
it("uses the single exact-name match even over a disagreeing fallback mbid", () => {
const hit: ArtistHit = { mbid: "r", name: "Radiohead", disambiguation: "" };
expect(resolveArtistChoice("Radiohead", [hit], "wrong-lastfm-mbid")).toEqual({
kind: "single",
mbid: "r",
});
});
it("matches exact name case- and whitespace-insensitively", () => {
expect(resolveArtistChoice(" looking GLASS ", [US, AU])).toEqual({
kind: "ambiguous",
candidates: [US, AU],
});
});
it("falls back to the provided mbid when no candidate name matches exactly", () => {
const fuzzy: ArtistHit = { mbid: "x", name: "The Beatles", disambiguation: "" };
expect(resolveArtistChoice("Beatles", [fuzzy], "lastfm-mbid")).toEqual({
kind: "single",
mbid: "lastfm-mbid",
});
});
it("falls back to MB's top hit when there is no exact match and no fallback", () => {
const fuzzy: ArtistHit = { mbid: "top", name: "The Beatles", disambiguation: "" };
expect(resolveArtistChoice("Beatles", [fuzzy])).toEqual({ kind: "single", mbid: "top" });
});
it("is none when there are no candidates and no fallback", () => {
expect(resolveArtistChoice("Nobody Here", [])).toEqual({ kind: "none" });
});
});
+34
View File
@@ -0,0 +1,34 @@
export type ArtistHit = { mbid: string; name: string; disambiguation: string };
export type Resolution =
| { kind: "single"; mbid: string }
| { kind: "ambiguous"; candidates: ArtistHit[] }
| { kind: "none" };
const norm = (s: string) => s.trim().toLowerCase();
/**
* Decide which MusicBrainz artist a Last.fm row refers to, given MB's search candidates.
*
* The bug this guards against: Last.fm's own MBID for an ambiguous name can point at the
* wrong same-named band (it mapped "Looking Glass" to an Australian stoner band, not the US
* pop group). So we prefer MB's own resolution:
* - 2+ candidates whose name EXACTLY matches (normalized) → ambiguous; let the user pick.
* - exactly 1 exact-name match → use it, even if it disagrees with `fallbackMbid`.
* - no exact-name match → keep prior behavior: the Last.fm `fallbackMbid`, else MB's top hit.
*
* `candidates` is assumed to arrive in MB relevance order (best first), so `candidates[0]`
* is the suggested default and exact-name matches keep that ordering.
*/
export function resolveArtistChoice(
name: string,
candidates: ArtistHit[],
fallbackMbid?: string | null,
): Resolution {
const exact = candidates.filter((a) => norm(a.name) === norm(name));
if (exact.length >= 2) return { kind: "ambiguous", candidates: exact };
if (exact.length === 1) return { kind: "single", mbid: exact[0].mbid };
if (fallbackMbid) return { kind: "single", mbid: fallbackMbid };
if (candidates.length > 0) return { kind: "single", mbid: candidates[0].mbid };
return { kind: "none" };
}
+103 -9
View File
@@ -1,3 +1,4 @@
import time
from difflib import SequenceMatcher
from lyra_worker.types import MBTarget
@@ -5,10 +6,104 @@ from lyra_worker.types import MBTarget
_MIN_TITLE_RATIO = 0.6
def _with_retry(fn, *, attempts: int = 4, base_delay: float = 1.0, sleep=time.sleep):
"""Call `fn`, retrying transient MusicBrainz failures with exponential backoff.
A dropped TLS connection to the MusicBrainz webservice surfaces as
musicbrainzngs.NetworkError (`<urlopen error [SSL: UNEXPECTED_EOF_WHILE_READING]>`),
and MusicBrainz's own 503 rate-limit surfaces as ResponseError; both are
transient, so we retry rather than failing the whole pipeline for the job.
Any other exception (bug, non-transient) propagates immediately.
musicbrainzngs is imported lazily so importing this module stays offline.
"""
import musicbrainzngs
transient = (musicbrainzngs.NetworkError, musicbrainzngs.ResponseError)
for i in range(attempts):
try:
return fn()
except transient:
if i == attempts - 1:
raise
sleep(base_delay * (2**i))
def _ratio(a: str, b: str) -> float:
return SequenceMatcher(None, a.strip().casefold(), b.strip().casefold()).ratio()
def _is_album(g: dict) -> bool:
return (g.get("primary-type") or "").casefold() == "album"
def _rg_year(g: dict) -> int:
"""First-release year of a release-group, or 9999 when unknown (so undated groups lose ties)."""
frd = (g.get("first-release-date") or "")[:4]
return int(frd) if frd.isdigit() else 9999
def _credited_artist(g: dict) -> str:
"""Primary credited artist name of a release-group search result, or '' if absent."""
credit = g.get("artist-credit")
if isinstance(credit, list) and credit and isinstance(credit[0], dict):
return (credit[0].get("artist") or {}).get("name", "") or ""
return ""
def _best_release_group(artist: str, album: str, groups: list) -> dict | None:
"""Choose the best release-group match for `artist`/`album`.
Ranked, highest first, on `(artist_ratio, title_ratio, is_album)`:
* artist match dominates — MusicBrainz relevance ties same-titled release groups by
*different* artists (e.g. "Fun Machine" exists as a Lake Street Dive EP and an
unrelated band's Album), and the artist we followed is trustworthy, so a credited
artist that matches the request outranks everything else;
* title similarity is next;
* an Album primary-type only breaks ties *within* the same artist+title, so a famous
album (Michael Jackson's "Off the Wall") still isn't resolved to its same-named
single whose short tracklist would map positionally onto the album's files;
* finally, the EARLIEST release-group wins — MusicBrainz lists the original album and its
later reissues/anniversary editions as separate same-titled groups (Linkin Park "Hybrid
Theory": a 2000 original and a 2023 13-track reissue), which tie on everything above, so
MB's arbitrary relevance order used to pick a reissue whose longer tracklist/runtime then
failed completeness checks against the standard album a source delivers. Prefer the original.
Artist is a ranking signal, never a hard filter — a low match sinks a candidate but
never drops the release, so credited-name variations (feat., punctuation) still resolve.
Returns None below the title threshold. Pure — no network I/O."""
if not groups:
return None
best = max(
groups,
key=lambda g: (
_ratio(artist, _credited_artist(g)),
_ratio(album, g.get("title", "")),
_is_album(g),
-_rg_year(g), # tie-break: the original (earliest) group over later reissues
),
)
if _ratio(album, best.get("title", "")) < _MIN_TITLE_RATIO:
return None
return best
def _pick_release(releases: list) -> dict | None:
"""Choose which release of a release-group to read the tracklist from. MB returns them in an
arbitrary order and ``releases[0]`` is often a later reissue/deluxe/anniversary edition with
more tracks and a longer runtime than the standard album a source actually delivers — which
then makes a *complete* download fail the pipeline's track-count/duration completeness checks
and re-download forever (Linkin Park "Hybrid Theory": MB's arbitrary pick was a 2023 13-track
reissue at ~52 min vs the 2000 original's 12 tracks / ~38 min). Prefer the ORIGINAL standard
edition: an Official release, then the earliest date, then a dated one. Pure — unit-tested."""
if not releases:
return None
def _key(r: dict) -> tuple:
official = 0 if (r.get("status") == "Official") else 1 # official editions first
date = r.get("date") or ""
return (official, 0 if date else 1, date) # then dated, then earliest date
return min(releases, key=_key)
class MusicBrainzResolver:
"""Real MbResolver using the MusicBrainz webservice via musicbrainzngs.
@@ -26,12 +121,10 @@ class MusicBrainzResolver:
musicbrainzngs.set_useragent(self._app, self._version, self._contact)
res = musicbrainzngs.search_release_groups(query=album, artist=artist, limit=5)
res = _with_retry(lambda: musicbrainzngs.search_release_groups(query=album, artist=artist, limit=5))
groups = res.get("release-group-list", [])
if not groups:
return None
rg = max(groups, key=lambda g: _ratio(album, g.get("title", "")))
if _ratio(album, rg.get("title", "")) < _MIN_TITLE_RATIO:
rg = _best_release_group(artist, album, groups)
if rg is None:
return None
canonical_album = rg.get("title", album)
@@ -53,11 +146,12 @@ class MusicBrainzResolver:
titles: tuple[str, ...] = ()
rgid = rg.get("id")
if rgid:
rgfull = musicbrainzngs.get_release_group_by_id(rgid, includes=["releases"])
rgfull = _with_retry(lambda: musicbrainzngs.get_release_group_by_id(rgid, includes=["releases"]))
releases = rgfull.get("release-group", {}).get("release-list", [])
if releases:
rel = musicbrainzngs.get_release_by_id(
releases[0]["id"], includes=["recordings"]
chosen = _pick_release(releases)
if chosen:
rel = _with_retry(
lambda: musicbrainzngs.get_release_by_id(chosen["id"], includes=["recordings"])
).get("release", {})
tracks = []
for medium in rel.get("medium-list", []):
+32 -5
View File
@@ -1,21 +1,48 @@
import os
import re
from lyra_worker.library import safe_name
_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"}
# Leading "13.", "13 -", "13)" style track-number prefix streamrip bakes into a bonus-track
# filename. Requires an explicit delimiter so a real title that merely starts with a number
# (e.g. "99 Luftballons") is left alone.
_NUM_PREFIX = re.compile(r"^\s*\d+\s*[.):\-]\s*")
def plan_track_names(names: list[str], tracklist: tuple[str, ...]) -> list[tuple[str, str, str, int]]:
def _clean_extra_title(stem: str, artist: str = "") -> str:
"""Derive a display title for a track BEYOND the MusicBrainz tracklist (a bonus track that
streamrip named itself, e.g. `13. John Mayer - St. Patrick's Day (Album Version)`). Strip a
leading track-number prefix, then a leading `<artist> - ` if present, so the example yields
`St. Patrick's Day (Album Version)`. Falls back to the stem if nothing strips."""
s = _NUM_PREFIX.sub("", stem).strip()
if artist:
s = re.sub(r"^" + re.escape(artist) + r"\s*-\s*", "", s, flags=re.IGNORECASE).strip()
return s or stem
def plan_track_names(
names: list[str], tracklist: tuple[str, ...], artist: str = ""
) -> list[tuple[str, str, str, int]]:
"""Map audio filenames -> (old_name, new_name, title, track_number).
Files are sorted; titles come from `tracklist` by position when available,
else the filename stem. Pure — no filesystem or tag I/O.
Files are sorted; titles come from `tracklist` by position when available. A file BEYOND
the tracklist (a bonus track streamrip named itself) is canonicalized via `_clean_extra_title`
so it gets a clean `## Title.ext` name too; with no tracklist at all the on-disk stem is kept.
Pure — no filesystem or tag I/O.
"""
plan: list[tuple[str, str, str, int]] = []
audio = sorted(n for n in names if os.path.splitext(n)[1].lower() in _AUDIO_EXT)
for i, name in enumerate(audio):
ext = os.path.splitext(name)[1].lower()
title = tracklist[i] if i < len(tracklist) else os.path.splitext(name)[0]
stem = os.path.splitext(name)[0]
if i < len(tracklist):
title = tracklist[i]
elif tracklist: # a genuine bonus track past a known tracklist -> canonicalize its name
title = _clean_extra_title(stem, artist)
else: # no tracklist resolved at all -> keep the on-disk stem (renumbered)
title = stem
new_name = f"{i + 1:02d} {safe_name(title)}{ext}"
plan.append((name, new_name, title, i + 1))
return plan
@@ -29,7 +56,7 @@ class MutagenTagger:
import mutagen
names = os.listdir(album_dir)
for old_name, new_name, title, number in plan_track_names(names, target.tracklist):
for old_name, new_name, title, number in plan_track_names(names, target.tracklist, target.artist):
try:
path = os.path.join(album_dir, old_name)
audio = mutagen.File(path, easy=True)
+202 -42
View File
@@ -2,6 +2,7 @@ import json
import os
import shutil
import time
import unicodedata
from typing import Callable
import requests
@@ -10,9 +11,26 @@ _DEFAULT_DOWNLOADS_ROOT = "/slskd-downloads"
_AUDIO_EXT = {"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac"}
_LOSSLESS_EXT = {"flac", "wav"}
_SEARCH_POLLS = 40 # * 3s ≈ 2 min
_XFER_POLLS = 200 # * 3s ≈ 10 min
_SEARCH_POLLS = 40 # * 3s ≈ 2 min
_STALL_POLLS = 30 # * 3s ≈ 90s of no byte progress → abandon a queued/dead peer and fall through
_POLL_SECONDS = 3
# Absolute backstop for a slow-but-progressing peer. Scaled to the album's total bytes (a flat
# cap starved big albums: a 500MB set from a ~400KB/s serial peer needs ~60-70min and was cut at
# a flat 60min, then re-downloaded from scratch — never finishing). Budget at a conservative floor
# throughput, clamped. The 90s stall timeout still abandons a truly dead/queued peer quickly.
_MIN_XFER_POLLS = 400 # * 3s ≈ 20 min floor (small albums)
_MAX_XFER_POLLS = 2400 # * 3s ≈ 2 h ceiling (bounds worst-case slot hold)
_BACKSTOP_FLOOR_BPS = 64_000 # budget the backstop assuming >= ~64 KB/s sustained
def _max_polls(total_bytes: int) -> int:
"""Poll budget (each _POLL_SECONDS) for the absolute download backstop, scaled to album size at
a conservative floor throughput so a large slow-but-healthy transfer isn't abandoned mid-flight.
Clamped to [_MIN_XFER_POLLS, _MAX_XFER_POLLS]. Pure — unit-tested."""
if total_bytes <= 0:
return _MIN_XFER_POLLS
budget = int(total_bytes / (_BACKSTOP_FLOOR_BPS * _POLL_SECONDS))
return max(_MIN_XFER_POLLS, min(budget, _MAX_XFER_POLLS))
def _basename(path: str) -> str:
@@ -27,6 +45,114 @@ def _dirname(path: str) -> str:
return ""
_ETA_CAP_SECONDS = 359_999 # ~100h; keeps the value well inside Job.downloadEtaSeconds (int4)
def _eta_seconds(total_bytes: int, bytes_now: int, speed_bps: float) -> int | None:
"""Seconds until the transfer finishes at the current measured rate, or None when it can't be
estimated (no throughput sample yet, unknown total, or already complete). Pure — unit-tested.
A near-stalled peer drives speed_bps toward zero, so the estimate is capped: an uncapped value
can exceed int4 and overflow the downloadEtaSeconds column write (poisoning the pipeline txn)."""
if speed_bps <= 0 or total_bytes <= 0 or bytes_now >= total_bytes:
return None
return min(int((total_bytes - bytes_now) / speed_bps), _ETA_CAP_SECONDS)
def _norm(s: str) -> str:
"""Lowercase, strip diacritics, and reduce to alphanumeric tokens, so 'Beyoncé' matches a
peer's 'Beyonce' folder and punctuation ('I Am… Sasha Fierce') doesn't defeat the match."""
s = unicodedata.normalize("NFKD", s or "")
s = "".join(ch for ch in s if not unicodedata.combining(ch))
s = "".join(ch if ch.isalnum() else " " for ch in s.lower())
return " ".join(s.split())
def _path_matches(needle: str, path: str) -> bool:
"""True if `needle` (an artist or album name) appears in a file path, tolerant of accents and
punctuation. Every significant token (≥3 chars) must be present; for a very short needle
(e.g. 'U2') the whole normalized string must appear."""
npath = _norm(path)
tokens = [t for t in _norm(needle).split() if len(t) >= 3]
if not tokens:
whole = _norm(needle)
return bool(whole) and whole in npath
return all(t in npath for t in tokens)
def _keep_matching(responses: list, needle: str) -> list:
"""Drop files — then now-empty responses — whose path doesn't match `needle`. Makes an album-
or artist-only fallback search precise so we never grab a same-titled release by a different
artist. Pure — unit-tested offline."""
out = []
for resp in responses:
files = [f for f in (resp.get("files") or []) if _path_matches(needle, f.get("filename", ""))]
if files:
out.append({**resp, "files": files})
return out
def _parse_search_responses(responses: list, artist: str = "") -> list[dict]:
"""Turn slskd search responses into album candidates (one per peer+directory), ordered so the
peers that will FINISH first come first: a free upload slot, then the shortest estimated
transfer time (album bytes ÷ the peer's advertised speed), then a shorter queue. Ranking on
estimated time — not raw speed — matters because Soulseek FLAC rips of the same album vary
~2x in size (a 24-bit/bloated rip vs a standard CD rip), and Lyra scores them the SAME quality
class (the search exposes no bit-depth), so a peer advertising high speed but serving huge
files can lose a race — and blow past the download backstop — against a peer with a smaller
standard rip. The pipeline tries candidates in this order, so this is what makes the Soulseek
fall-through converge on a copy that actually completes. Pure — no I/O — unit-tested offline."""
scored: list[tuple] = []
for resp in responses:
username = resp.get("username", "")
has_slot = 0 if resp.get("hasFreeUploadSlot") else 1 # 0 sorts first (free slot = starts now)
speed = int(resp.get("uploadSpeed") or 0)
queue = int(resp.get("queueLength") or 0)
by_dir: dict[str, list] = {}
for f in resp.get("files", []) or []:
name = f.get("filename", "")
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
if ext not in _AUDIO_EXT:
continue
by_dir.setdefault(_dirname(name), []).append(
{"filename": name, "size": f.get("size", 0), "ext": ext}
)
for directory, dfiles in by_dir.items():
if not dfiles:
continue
# Estimated seconds to pull this album from this peer at its advertised rate. Unknown/
# zero speed → a low nominal (1 B/s) so total size still orders those peers last.
total_bytes = sum(int(f["size"] or 0) for f in dfiles)
est_seconds = total_bytes / (speed if speed > 0 else 1)
lossless = all(f["ext"] in _LOSSLESS_EXT for f in dfiles)
dirname = _basename(directory)
# Trust the REQUESTED artist when it appears anywhere in the path — many rips put the
# artist in a parent folder ("Rihanna\(2009) Rated R\…") or bury it ("2009 - Rihanna -
# Rated R"), which the "Artist - Album" folder parse gets wrong; that tanked the
# confidence score and lost real FLACs to clean-named MP3s. album stays the folder name
# (fuzzy-matched downstream); artist is what the parse gets wrong.
if artist and _path_matches(artist, dfiles[0]["filename"]):
guess_artist, guess_album = artist, dirname
elif " - " in dirname:
guess_artist, guess_album = (p.strip() for p in dirname.split(" - ", 1))
else:
guess_artist, guess_album = "", dirname
candidate = {
"source_ref": json.dumps({
"username": username,
"files": [{"filename": f["filename"], "size": f["size"]} for f in dfiles],
}),
"title": guess_album,
"artist": guess_artist,
"track_count": len(dfiles),
"format": "FLAC" if lossless else "MP3",
"bitrate": None,
}
scored.append((has_slot, est_seconds, queue, candidate))
# ascending: free slot first, then shortest estimated transfer, then shortest queue
scored.sort(key=lambda t: (t[0], t[1], t[2]))
return [c for *_rest, c in scored]
class SlskdClient:
"""Real Soulseek client talking to an slskd daemon's REST API.
@@ -58,11 +184,12 @@ class SlskdClient:
r.raise_for_status()
return r.json()
def search_album(self, artist: str, album: str) -> list[dict]:
def _run_search(self, text: str) -> list:
"""POST a search, wait for it to complete, and return the raw responses (a list)."""
r = requests.post(
f"{self._url}/api/v0/searches",
headers=self._headers(),
json={"searchText": f"{artist} {album}"},
json={"searchText": text},
timeout=30,
)
r.raise_for_status()
@@ -75,43 +202,27 @@ class SlskdClient:
break
responses = self._get(f"/api/v0/searches/{search_id}/responses")
candidates: list[dict] = []
for resp in responses:
username = resp.get("username", "")
by_dir: dict[str, list] = {}
for f in resp.get("files", []) or []:
name = f.get("filename", "")
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
if ext not in _AUDIO_EXT:
return responses if isinstance(responses, list) else []
def search_album(self, artist: str, album: str) -> list[dict]:
responses = self._run_search(f"{artist} {album}")
if not responses:
# The Soulseek server silently drops searches containing certain blocklisted terms
# (major-label artists / album titles like Adele, Rihanna, Beyoncé, "Lemonade") →
# 0 responses. The blocked term poisons the whole query, but the OTHER field usually
# isn't blocked, so retry with each field alone (normalized) and keep only results
# whose path still matches the field we dropped. That recovers e.g. Beyoncé's
# "I Am… Sasha Fierce" via the album title, without ever grabbing a same-titled album
# by someone else.
for query, needle in ((album, artist), (artist, album)):
q = _norm(query)
if not q:
continue
by_dir.setdefault(_dirname(name), []).append(
{"filename": name, "size": f.get("size", 0), "ext": ext}
)
for directory, dfiles in by_dir.items():
if not dfiles:
continue
lossless = all(f["ext"] in _LOSSLESS_EXT for f in dfiles)
dirname = _basename(directory)
if " - " in dirname:
guess_artist, guess_album = (p.strip() for p in dirname.split(" - ", 1))
else:
guess_artist, guess_album = "", dirname
candidates.append(
{
"source_ref": json.dumps(
{
"username": username,
"files": [{"filename": f["filename"], "size": f["size"]} for f in dfiles],
}
),
"title": guess_album,
"artist": guess_artist,
"track_count": len(dfiles),
"format": "FLAC" if lossless else "MP3",
"bitrate": None,
}
)
return candidates
alt = _keep_matching(self._run_search(q), needle)
if alt:
responses = alt
break
return _parse_search_responses(responses, artist)
def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict:
ref = json.loads(source_ref)
@@ -119,7 +230,17 @@ class SlskdClient:
files = ref["files"]
wanted = {f["filename"] for f in files}
os.makedirs(dest, exist_ok=True)
xfer_ids: set[str] = set() # slskd transfer ids we started, for cancel-on-abandon
try:
return self._download(username, files, wanted, dest, on_progress, xfer_ids)
except Exception:
# Abandoning this peer (timeout / error / the pipeline moving on): cancel the transfers
# we enqueued so they don't keep running and pile up in slskd as the job falls through
# to other peers/sources for the same album.
self._cancel_transfers(username, xfer_ids)
raise
def _download(self, username, files, wanted, dest, on_progress, xfer_ids: set) -> dict:
r = requests.post(
f"{self._url}/api/v0/transfers/downloads/{username}",
headers=self._headers(),
@@ -130,23 +251,49 @@ class SlskdClient:
on_progress(0.0)
total = len(files)
total_bytes = sum(int(f.get("size") or 0) for f in files)
completed = False
for _ in range(_XFER_POLLS):
last_bytes = -1
stall = 0
speed = 0.0 # bytes/sec, EWMA-smoothed so the ETA doesn't jitter poll to poll
prev_bytes = 0
prev_t = time.monotonic()
for _ in range(_max_polls(total_bytes)):
time.sleep(_POLL_SECONDS)
data = self._get(f"/api/v0/transfers/downloads/{username}")
states: list[str] = []
bytes_now = 0
for directory in data.get("directories", []) if isinstance(data, dict) else []:
for f in directory.get("files", []):
if f.get("filename") in wanted: # only the files we enqueued
states.append(str(f.get("state", "")))
bytes_now += int(f.get("bytesTransferred") or 0)
if f.get("id"): # remember the transfer so we can cancel it if abandoned
xfer_ids.add(str(f["id"]))
if states:
if any(bad in s for s in states for bad in ("Errored", "Rejected", "Cancelled")):
raise RuntimeError("soulseek transfer failed")
now = time.monotonic()
dt = now - prev_t
if dt > 0 and bytes_now >= prev_bytes:
inst = (bytes_now - prev_bytes) / dt
speed = inst if speed == 0.0 else 0.6 * speed + 0.4 * inst
prev_bytes, prev_t = bytes_now, now
done = sum(1 for s in states if "Completed" in s)
on_progress(min(1.0, done / max(total, 1)))
on_progress(min(1.0, done / max(total, 1)), _eta_seconds(total_bytes, bytes_now, speed))
if done >= total:
completed = True
break
# Stall detection: a peer making byte progress keeps its slot (up to the absolute
# backstop above) so a slow-but-working transfer can finish; one that sends no bytes
# for _STALL_POLLS (~90s) is queued/dead — abandon it fast and fall through.
if bytes_now > last_bytes:
last_bytes = bytes_now
stall = 0
else:
stall += 1
if stall >= _STALL_POLLS:
raise TimeoutError(f"soulseek transfer stalled for {username}")
if not completed:
raise TimeoutError(f"soulseek download did not complete for {username}")
@@ -162,6 +309,19 @@ class SlskdClient:
)
return {"track_count": moved, "path": dest}
def _cancel_transfers(self, username: str, xfer_ids: set) -> None:
"""Cancel the given in-flight slskd transfers for a peer. Best-effort: a cleanup failure
must never mask the original download failure."""
for tid in xfer_ids:
try:
requests.delete(
f"{self._url}/api/v0/transfers/downloads/{username}/{tid}",
headers=self._headers(),
timeout=15,
)
except Exception:
pass
def _retrieve(self, files: list[dict], dest: str) -> int:
"""Move the just-finished slskd files out of its downloads dir into `dest`.
+63 -1
View File
@@ -34,6 +34,53 @@ def _run(coro):
return _loop.run_until_complete(coro)
class _ProgressAggregator:
"""Turn streamrip's per-track byte callbacks into one album-level 0..1 fraction.
streamrip downloads an album's tracks concurrently and reports progress only per track
(a byte delta per chunk), so the file-count poller sees 0 complete files until they all
land at once — the 0%→100% jump. Summing downloaded bytes over an *estimated* album total
(average started-track size × track count) gives a smooth, generally-climbing fraction.
Track sizes are similar, so the estimate is stable once a track or two has started; capped
at 0.99 because the pipeline sets 1.0 once the whole download returns. All streamrip
coroutines run on one event loop on the calling thread, so no locking is needed."""
def __init__(self, track_count: int, on_progress: Callable[[float], None]):
self._n = track_count or 0
self._on_progress = on_progress
self._seen_total = 0 # summed byte-size of tracks that have started downloading
self._seen_count = 0 # number of tracks that have started
self._done_bytes = 0 # summed bytes downloaded across all started tracks
def start_track(self, total) -> None:
self._seen_total += max(int(total or 0), 0)
self._seen_count += 1
def advance(self, delta) -> None:
self._done_bytes += max(int(delta or 0), 0)
if self._seen_count == 0:
return
avg = self._seen_total / self._seen_count
n = self._n if self._n > 0 else self._seen_count
est_total = avg * n
if est_total > 0:
self._on_progress(min(self._done_bytes / est_total, 0.99))
class _ProgressHandle:
"""streamrip's `Track.download` uses `with get_progress_callback(...) as cb:`; `cb` must be
the per-chunk byte callback. This stand-in yields that callback and no-ops on exit."""
def __init__(self, update: Callable[[int], None]):
self.update = update
def __enter__(self):
return self.update
def __exit__(self, *_):
return False
def _flatten_audio(dest: str) -> int:
"""streamrip writes into a nested `Artist - Album [...]/` subfolder. Move any audio
files up into `dest`, remove the now-empty subfolders, and return the count of audio
@@ -136,6 +183,7 @@ class StreamripClient:
async def _download(self, album_id: str, dest: str, on_progress: Callable[[float], None]) -> None:
os.makedirs(dest, exist_ok=True)
import streamrip.media.track as _track_mod
from streamrip.client import QobuzClient
from streamrip.db import Database, Dummy
from streamrip.media import PendingAlbum
@@ -153,7 +201,21 @@ class StreamripClient:
album = await pending.resolve()
if album is None:
raise RuntimeError(f"could not resolve Qobuz album {album_id}")
await album.rip()
# Hook streamrip's per-track byte progress into a single album-level fraction so the
# Floor bar climbs smoothly instead of jumping 0→100. `Track.download` binds
# get_progress_callback at import, so patch it on the track module (not progress.py).
agg = _ProgressAggregator(len(getattr(album, "tracks", []) or []), on_progress)
_orig_cb = _track_mod.get_progress_callback
def _hook(_enabled, total, _desc):
agg.start_track(total)
return _ProgressHandle(agg.advance)
_track_mod.get_progress_callback = _hook
try:
await album.rip()
finally:
_track_mod.get_progress_callback = _orig_cb
on_progress(1.0)
finally:
await client.session.close()
+10 -3
View File
@@ -72,17 +72,24 @@ class FailingAdapter(_BaseFake):
class FakeMbBrowser:
"""In-memory MbBrowser for tests. `releases` maps artist mbid -> [ReleaseGroupInfo]."""
"""In-memory MbBrowser for tests. `releases` maps artist mbid -> [ReleaseGroupInfo].
`default_core=True` returns a synthetic core Album for any artist not in `releases`, so
scoring tests don't trip the "no core discography → drop the suggestion" filter."""
def __init__(self, artists=None, releases=None):
def __init__(self, artists=None, releases=None, default_core=False):
self._artists = list(artists or [])
self._releases = dict(releases or {})
self._default_core = default_core
def search_artist(self, name: str) -> list[ArtistHit]:
return list(self._artists)
def browse_release_groups(self, artist_mbid: str) -> list[ReleaseGroupInfo]:
return list(self._releases.get(artist_mbid, []))
if artist_mbid in self._releases:
return list(self._releases[artist_mbid])
if self._default_core:
return [ReleaseGroupInfo(f"rg-{artist_mbid}", "Album", "Album", (), "2000-01-01")]
return []
class FakeAudioProbe:
+38 -1
View File
@@ -24,13 +24,50 @@ def reclaim_stuck_jobs(conn: psycopg.Connection) -> int:
return n
_MAX_ATTEMPTS = 5
def requeue_or_fail(
conn: psycopg.Connection, job_id: str, error: str, max_attempts: int = _MAX_ATTEMPTS
) -> None:
"""Recover a job whose pipeline raised an *unhandled* exception (e.g. a dropped MusicBrainz
TLS connection surfacing as an SSL EOF). Without this the job is left in its mid-pipeline
'matching' state; since claim_next only picks 'requested' it is orphaned until the next
restart's reclaim_stuck_jobs — showing "Searching sources…" on the press indefinitely.
Such errors are usually transient, so requeue to 'requested' until the attempt cap, then fall
to 'needs_attention' so a persistently-failing job stays visible instead of looping forever.
`attempts` is bumped by claim_next on every claim, so it already counts tries."""
with conn.cursor() as cur:
cur.execute('SELECT attempts, "requestId" FROM "Job" WHERE id = %s', (job_id,))
row = cur.fetchone()
if row is None:
return
attempts, request_id = row
if attempts >= max_attempts:
cur.execute(
'UPDATE "Job" SET state = \'needs_attention\', error = %s, "claimedAt" = NULL, '
'"updatedAt" = now() WHERE id = %s',
(error, job_id),
)
cur.execute('UPDATE "Request" SET status = \'needs_attention\' WHERE id = %s', (request_id,))
else:
cur.execute(
'UPDATE "Job" SET state = \'requested\', "currentStage" = \'intake\', error = NULL, '
'"claimedAt" = NULL, "downloadProgress" = 0, "updatedAt" = now() WHERE id = %s',
(job_id,),
)
cur.execute('UPDATE "Request" SET status = \'pending\' WHERE id = %s', (request_id,))
conn.commit()
def claim_next(conn: psycopg.Connection) -> str | None:
"""Atomically claim the oldest queued job. Returns its id or None."""
with conn.cursor() as cur:
cur.execute(
"""
SELECT id FROM "Job"
WHERE state = 'requested'
WHERE state = 'requested' AND NOT paused
ORDER BY "createdAt" ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
+32
View File
@@ -204,6 +204,16 @@ def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[
except Exception as e: # one artist's browse failure must not abort the sweep
print(f"worker: discovery album browse failed for {artist['mbid']}: {e}", flush=True)
continue
# Drop suggestions for "artists" with no real discography of their own — producers,
# session players, band members, soundtrack-only credits. ListenBrainz surfaces them
# (they co-occur in listens) but they have no core release-group to follow or want, and
# their page is empty. Require at least one core release (Album/Single/EP, no secondary
# type); on browse failure we keep the suggestion rather than drop a real artist.
if not any(is_core_release(g.primary_type, g.secondary_types) for g in groups):
with conn.cursor() as cur:
cur.execute("DELETE FROM \"DiscoverySuggestion\" WHERE status = 'pending' "
'AND "artistMbid" = %s', (artist["mbid"],))
continue
core = [g for g in groups
if _album_kind_ok(g, cfg.albums_only) and g.rg_mbid not in have]
core.sort(key=lambda g: g.first_release_date or "", reverse=True)
@@ -217,6 +227,16 @@ def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[
if popular is not None:
reasons[popular] = "newest,popular" if reasons.get(popular) == "newest" else "popular"
# Re-deriving replaces this artist's album set: drop any pending album suggestion of
# theirs that is no longer a pick (stale singles/EPs from older sweeps, or an album now
# owned), keeping wanted/dismissed rows untouched. Then upsert the current picks.
with conn.cursor() as cur:
cur.execute(
"DELETE FROM \"DiscoverySuggestion\" WHERE kind = 'album' AND status = 'pending' "
'AND "artistMbid" = %s AND NOT ("rgMbid" = ANY(%s))',
(artist["mbid"], list(reasons.keys())),
)
for rg in core: # preserve newest-first order among the chosen
reason = reasons.get(rg.rg_mbid)
if reason is not None:
@@ -305,6 +325,18 @@ def _recompute_suggestions(conn: psycopg.Connection, affected: set[str],
return surfaced
def reset_pending(conn: psycopg.Connection) -> None:
"""Clear discovery state so the next sweep rebuilds from scratch with current logic. Drops
all PENDING suggestions (followed/wanted/dismissed user decisions are kept), every seed
contribution + library-seed throttle, and marks each watched artist due again."""
with conn.cursor() as cur:
cur.execute("DELETE FROM \"DiscoverySuggestion\" WHERE status = 'pending'")
cur.execute('DELETE FROM "DiscoverySeedContribution"')
cur.execute('DELETE FROM "DiscoverySeed"')
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = NULL')
conn.commit()
def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource],
browser: MbBrowser, cfg: DiscoveryConfig, popularity=None) -> DiscoveryResult:
"""Record each seed's similar-artist contributions, then recompute affected
+42
View File
@@ -0,0 +1,42 @@
import os
from lyra_worker.config import get_config
_TRUE = {"1", "true", "yes", "on"}
def press_paused(config: dict[str, str]) -> bool:
"""True when the whole press is paused — the worker stops claiming new jobs."""
return str(config.get("floor.paused", "")).strip().lower() in _TRUE
def job_delay_seconds(config: dict[str, str]) -> float:
"""Seconds to wait between finishing one download and claiming the next. Invalid or
absent -> 0.0 (back-to-back). Negatives are clamped to 0."""
try:
return max(0.0, float(int(config.get("floor.jobDelaySeconds", "") or 0)))
except (TypeError, ValueError):
return 0.0
def kill_requested(config: dict[str, str]) -> bool:
"""True when a force-stop of the in-flight download has been requested."""
return str(config.get("floor.killRequested", "")).strip().lower() in _TRUE
def consume_kill_switch(conn, exit_fn=os._exit) -> bool:
"""If a force-stop is pending, clear the flag and terminate the process (default
os._exit) so the in-flight download is aborted. The container restart policy brings the
worker back; startup clears staging and requeues the aborted job. Returns whether it
fired. exit_fn is injectable for tests."""
config = get_config(conn)
if not kill_requested(config):
return False
with conn.cursor() as cur:
cur.execute(
'UPDATE "Config" SET value = \'false\', "updatedAt" = now() WHERE key = %s',
("floor.killRequested",),
)
conn.commit()
exit_fn(0)
return True
+39 -2
View File
@@ -6,11 +6,12 @@ import uuid
import psycopg
from lyra_worker.claim import claim_next, reclaim_stuck_jobs
from lyra_worker.claim import claim_next, reclaim_stuck_jobs, requeue_or_fail
from lyra_worker.config import get_config
from lyra_worker.crypto import secret_key_problem
from lyra_worker.db import wait_for_db
from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, run_discovery
from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, reset_pending, run_discovery
from lyra_worker.floor import press_paused, job_delay_seconds, consume_kill_switch
from lyra_worker.library import clear_staging_root
from lyra_worker.monitor import MonitorConfig, fulfill_owned_releases, reconcile, sweep
from lyra_worker.pipeline import run_pipeline
@@ -147,6 +148,10 @@ def maybe_run_discovery(conn, sources, browser, config, dcfg, now, last_discover
starting = not in_progress
if starting: # begin a fresh sweep
if str(config.get("discover.resetRequested", "")).strip().lower() in _TRUE:
reset_pending(conn) # wipe stale pending suggestions before rebuilding
_set_config(conn, "discover.resetRequested", "false")
print("worker: discovery reset — cleared pending suggestions, rebuilding", flush=True)
_set_config(conn, "discover.inProgress", "true")
_set_config(conn, "discover.requested", "false")
@@ -222,9 +227,30 @@ def _liveness_heartbeat() -> None:
time.sleep(HEARTBEAT_SECONDS)
KILL_POLL_SECONDS = 1.5
def _kill_switch_watch() -> None:
"""Poll floor.killRequested on a background daemon thread (its own DB connection) so a
force-stop aborts the process even while the main loop is blocked in a download. On any
DB hiccup, reconnect and keep watching."""
conn = wait_for_db()
while True:
try:
consume_kill_switch(conn)
except Exception:
try:
conn.close()
except Exception:
pass
conn = wait_for_db()
time.sleep(KILL_POLL_SECONDS)
def run_forever() -> None:
_require_secret_key()
threading.Thread(target=_liveness_heartbeat, daemon=True).start()
threading.Thread(target=_kill_switch_watch, daemon=True).start()
conn = wait_for_db()
clear_staging_root(STAGING_ROOT) # sweep any staging dirs orphaned by a prior crash
reclaimed = reclaim_stuck_jobs(conn) # requeue jobs a prior crash left mid-pipeline
@@ -248,6 +274,7 @@ def run_forever() -> None:
last_discover_tick = 0.0
last_heartbeat = 0.0
last_prune = 0.0
next_job_allowed = 0.0 # monotonic time before which no new job is claimed (inter-job delay)
try:
while True:
# A transient DB connection loss (e.g. AdminShutdown when Postgres restarts) raises
@@ -279,6 +306,12 @@ def run_forever() -> None:
maybe_run_scan(conn, resolver, probe, browser, config)
# Press paused (downloads only) or the inter-job delay hasn't elapsed: keep the
# loop alive (monitor/scan/discovery above still run) but claim nothing.
if press_paused(config) or now < next_job_allowed:
time.sleep(IDLE_SLEEP)
continue
job_id = claim_next(conn)
if job_id is None:
time.sleep(IDLE_SLEEP)
@@ -290,8 +323,12 @@ def run_forever() -> None:
except Exception as e: # one bad job must not take down the worker loop
print(f"worker: pipeline failed for job {job_id}: {e}", flush=True)
conn.rollback()
# Don't orphan the job in its mid-pipeline 'matching' state (claim_next only
# picks 'requested'): requeue for a bounded retry, then needs_attention.
requeue_or_fail(conn, job_id, str(e))
finish_job(conn, job_id, mcfg)
print(f"worker: finished job {job_id}", flush=True)
next_job_allowed = time.monotonic() + job_delay_seconds(config)
except psycopg.OperationalError as e:
print(f"worker: db connection lost ({e}); reconnecting...", flush=True)
try:
+59 -17
View File
@@ -86,16 +86,40 @@ def _set_state(conn: psycopg.Connection, rel_id: str, state: str) -> None:
cur.execute('UPDATE "MonitoredRelease" SET state = %s WHERE id = %s', (state, rel_id))
def _in_library_at_cutoff(conn: psycopg.Connection, artist: str, album: str, cutoff: int) -> bool:
def _in_library_at_cutoff(
conn: psycopg.Connection, artist: str, album: str, rg_mbid: str, cutoff: int
) -> bool:
"""True if the album is owned at/above `cutoff`. Matches on the release-group MBID when the
library row has one — robust against MusicBrainz artist-name canonicalization (e.g. the
followed 'Kanye West' imports as 'Ye') — and falls back to exact artist+album for scanned
rows that predate MBID capture."""
with conn.cursor() as cur:
cur.execute(
'SELECT 1 FROM "LibraryItem" WHERE artist = %s AND album = %s '
'AND "qualityClass" >= %s LIMIT 1',
(artist, album, cutoff),
'SELECT 1 FROM "LibraryItem" WHERE "qualityClass" >= %s AND ('
' ("rgMbid" IS NOT NULL AND "rgMbid" = %s) '
' OR ("rgMbid" IS NULL AND artist = %s AND album = %s)) LIMIT 1',
(cutoff, rg_mbid, artist, album),
)
return cur.fetchone() is not None
def _retire_failed_attempts(conn: psycopg.Connection, rel_id: str) -> None:
"""Delete prior failed (needs_attention) Request rows for this release (Job/Candidate cascade).
A hard-to-match release is re-pressed every retry interval; each failed attempt used to leave
a needs_attention Request behind, piling up phantom "needs attention" cards on the press even
after a later attempt imported the album. Retiring them when the release is fulfilled or
re-attempted keeps the press showing one live attempt at a time. Manual (non-monitor) requests
have no monitoredReleaseId and are never touched."""
with conn.cursor() as cur:
cur.execute(
'DELETE FROM "Request" r USING "Job" j '
'WHERE j."requestId" = r.id AND r."monitoredReleaseId" = %s '
"AND j.state = 'needs_attention'",
(rel_id,),
)
def _enqueue_one(conn: psycopg.Connection, rel_id: str, artist: str, album: str) -> None:
with conn.cursor() as cur:
cur.execute(
@@ -118,7 +142,8 @@ def enqueue_due(conn: psycopg.Connection, cfg: MonitorConfig) -> int:
cur.execute(
'SELECT mr.id, mr."artistName", mr.album, mr.state, mr."currentQualityClass", '
' (mr."firstGrabbedAt" IS NOT NULL '
' AND mr."firstGrabbedAt" < now() - make_interval(days => %s)) AS window_expired '
' AND mr."firstGrabbedAt" < now() - make_interval(days => %s)) AS window_expired, '
' mr."rgMbid" '
'FROM "MonitoredRelease" mr '
'WHERE mr.monitored = true '
' AND NOT mr.ignored ' # user-ignored releases are never enqueued
@@ -134,8 +159,11 @@ def enqueue_due(conn: psycopg.Connection, cfg: MonitorConfig) -> int:
rows = cur.fetchall()
enqueued = 0
for rel_id, artist, album, state, quality, window_expired in rows:
if _in_library_at_cutoff(conn, artist, album, cfg.quality_cutoff):
for rel_id, artist, album, state, quality, window_expired, rg_mbid in rows:
# A due release is about to be fulfilled or re-attempted; either way any earlier failed
# attempt is superseded, so retire it before deciding.
_retire_failed_attempts(conn, rel_id)
if _in_library_at_cutoff(conn, artist, album, rg_mbid, cfg.quality_cutoff):
_set_state(conn, rel_id, "fulfilled")
continue
if state == "grabbed" and (window_expired or (quality is not None and quality >= cfg.quality_cutoff)):
@@ -154,12 +182,19 @@ def fulfill_owned_releases(conn: psycopg.Connection, cfg: MonitorConfig) -> int:
with conn.cursor() as cur:
cur.execute(
'UPDATE "MonitoredRelease" mr SET state = \'fulfilled\', '
' "currentQualityClass" = li.q, '
' "currentQualityClass" = owned.q, '
' "firstGrabbedAt" = COALESCE(mr."firstGrabbedAt", now()) '
'FROM (SELECT artist, album, max("qualityClass") AS q FROM "LibraryItem" '
' GROUP BY artist, album) li '
'WHERE li.artist = mr."artistName" AND li.album = mr.album '
" AND mr.monitored = true AND mr.state <> 'fulfilled' AND li.q >= %s",
'FROM ('
' SELECT mr2.id AS mid, max(li."qualityClass") AS q '
' FROM "MonitoredRelease" mr2 JOIN "LibraryItem" li ON ('
# match on rgMbid where the library row has one (survives artist-name canonicalization),
# else fall back to exact artist+album for scanned rows lacking an MBID
' (li."rgMbid" IS NOT NULL AND li."rgMbid" = mr2."rgMbid") '
' OR (li."rgMbid" IS NULL AND li.artist = mr2."artistName" AND li.album = mr2.album)) '
" WHERE mr2.monitored = true AND mr2.state <> 'fulfilled' "
' GROUP BY mr2.id HAVING max(li."qualityClass") >= %s'
') owned '
'WHERE mr.id = owned.mid',
(cfg.quality_cutoff,),
)
fixed = cur.rowcount
@@ -171,21 +206,28 @@ def reconcile(conn: psycopg.Connection, job_id: str, cfg: MonitorConfig) -> None
"""Feed a finished job's outcome back into its MonitoredRelease (if any)."""
with conn.cursor() as cur:
cur.execute(
'SELECT j.state, r."monitoredReleaseId", r.artist, r.album '
'FROM "Job" j JOIN "Request" r ON r.id = j."requestId" WHERE j.id = %s',
'SELECT j.state, r."monitoredReleaseId", r.artist, r.album, mr."rgMbid" '
'FROM "Job" j JOIN "Request" r ON r.id = j."requestId" '
'LEFT JOIN "MonitoredRelease" mr ON mr.id = r."monitoredReleaseId" '
'WHERE j.id = %s',
(job_id,),
)
row = cur.fetchone()
if row is None:
return
state, rel_id, artist, album = row
state, rel_id, artist, album, rg_mbid = row
if rel_id is None or state != "imported":
return # needs_attention (or in-flight) leaves the release wanted
# A successful import supersedes any earlier failed attempts for this release.
_retire_failed_attempts(conn, rel_id)
with conn.cursor() as cur:
cur.execute(
'SELECT "qualityClass" FROM "LibraryItem" WHERE artist = %s AND album = %s',
(artist, album),
'SELECT max("qualityClass") FROM "LibraryItem" WHERE '
' ("rgMbid" IS NOT NULL AND "rgMbid" = %s) '
' OR ("rgMbid" IS NULL AND artist = %s AND album = %s)',
(rg_mbid, artist, album),
)
lib = cur.fetchone()
quality = lib[0] if lib else None
+161 -50
View File
@@ -1,7 +1,9 @@
import os
import random
import shutil
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from typing import Callable, Sequence
@@ -10,6 +12,7 @@ import psycopg
from lyra_worker.adapters.base import SourceAdapter
from lyra_worker.confidence import score_confidence
from lyra_worker.library import album_dir, import_album, list_audio_files, staging_dir
from lyra_worker.qobuz_gate import gate_open, record_download
from lyra_worker.quality import quality_class
from lyra_worker.ranker import rank_candidates
from lyra_worker.types import Candidate, MBTarget
@@ -19,8 +22,19 @@ _AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"}
# Reject a download whose total playtime falls below this fraction of MusicBrainz's expected
# total — catches truncated files / preview clips substituted for real tracks that keep the
# track COUNT right. Generous, so edition/encoding differences don't false-positive; bonus
# tracks (over-long) are always fine.
_DURATION_MIN_RATIO = 0.85
# tracks (over-long) are always fine. Kept well below 1.0 because a source legitimately delivers a
# different edition than MB's resolved release (shorter radio edits, no bonus/live tracks), and only
# a genuinely truncated/preview-filled download (playtime a fraction of expected) should be caught.
_DURATION_MIN_RATIO = 0.65
# Cap how many ranked candidates a single job will actually download+verify before giving up.
# Without this, the incomplete-download fall-through can grind through dozens of sources (a popular
# album returns ~200 Soulseek candidates), each a slow attempt that also leaves an abandoned slskd
# transfer. The best candidates rank first, so 6 attempts is plenty.
_MAX_DOWNLOAD_ATTEMPTS = 6
# The best quality any non-Qobuz source offers (Soulseek FLAC = CD-lossless). A library copy at or
# above this can only be improved by hi-res Qobuz, so when Qobuz is gated by pacing an upgrade of
# such a copy is deferred without even searching.
_CD_LOSSLESS_CLASS = 2
def _measure_staged_duration_s(staging: str) -> float | None:
@@ -69,6 +83,40 @@ def _count_staged_audio(staging: str) -> int:
return n
def _download_problem(result, staging: str, target, winner, measure_duration) -> str | None:
"""Why a completed download is unusable — ``"incomplete download"`` / ``"download too short
(...)"`` — or None if it's good. Completeness is judged against the CHOSEN SOURCE's own track
count (``winner.track_count``), NOT MusicBrainz's canonical release: MB picks an arbitrary
releases[0] that is often a deluxe/expanded edition with more tracks (and runtime) than the
standard album a source legitimately delivers, so gating on ``target.track_count`` falsely
rejects complete standard-edition downloads (The Script "No Sound Without Silence" 11 vs MB 12,
Skillet "Unleashed" 12 vs MB's 20-track deluxe). Pure — no DB I/O — so the download loop can
call it per candidate and fall through to the next source on a problem."""
expected = winner.track_count
# (a) Truncated: fewer files than the source promised (a track failed mid-download). Count the
# NON-EMPTY audio on disk too: a silently-skipped track leaves a 0-byte placeholder the adapter
# still counts. (`staged and ...` keeps fake adapters that stage nothing on the count path.)
staged = _count_staged_audio(staging)
truncated = result.track_count < expected or bool(staged and staged < expected)
# (b) Implausibly small vs MB's album — a lone-track "full album" video / wrong match, as
# distinct from a legitimately smaller edition. A real edition keeps more than half MB's tracks.
too_small = target.track_count is not None and expected * 2 <= target.track_count
if truncated or too_small:
return "incomplete download"
# Duration: a truncated file or short preview substituted for a real track shows up as playtime
# well under the expected total. Scale MB's total to the DELIVERED edition's size so a smaller
# edition isn't judged against a larger one's runtime. Only when the total is known (measured is
# None for the offline fakes).
if target.total_duration_s:
expected_total = target.total_duration_s
if target.track_count:
expected_total *= winner.track_count / target.track_count
measured = measure_duration(staging)
if measured is not None and measured < expected_total * _DURATION_MIN_RATIO:
return f"download too short ({measured / 60:.0f} of ~{expected_total / 60:.0f} min)"
return None
def _poll_download_progress(job_id: str, staging: str, expected: int, stop: "threading.Event",
reports: "threading.Event") -> None:
"""Until `stop`, periodically write Job.downloadProgress = files-in-staging / expected
@@ -94,6 +142,16 @@ def _poll_download_progress(job_id: str, staging: str, expected: int, stop: "thr
conn.close()
def _search_adapter(adapter: SourceAdapter, target: MBTarget) -> list[Candidate]:
"""Search one adapter, swallowing failures — a down source contributes no candidates rather
than crashing the job. Runs on a worker thread (searches are parallelized across sources)."""
try:
return list(adapter.search(target))
except Exception as e:
print(f"pipeline: adapter {adapter.name} search failed: {e}", flush=True)
return []
def _set_state(conn: psycopg.Connection, job_id: str, state: str, stage: str) -> None:
with conn.cursor() as cur:
cur.execute(
@@ -103,30 +161,42 @@ def _set_state(conn: psycopg.Connection, job_id: str, state: str, stage: str) ->
conn.commit()
def _set_download_progress(conn: psycopg.Connection, job_id: str, frac: float) -> None:
def _set_download_progress(
conn: psycopg.Connection, job_id: str, frac: float, eta_seconds: int | None = None
) -> None:
with conn.cursor() as cur:
cur.execute('UPDATE "Job" SET "downloadProgress" = %s WHERE id = %s', (frac, job_id))
cur.execute(
'UPDATE "Job" SET "downloadProgress" = %s, "downloadEtaSeconds" = %s WHERE id = %s',
(frac, eta_seconds, job_id),
)
conn.commit()
def _make_on_progress(conn: psycopg.Connection, job_id: str, reports: "threading.Event"):
"""Build the on_progress(pct) callback threaded into adapter.download. It marks the
adapter as reporting real byte-level progress (so the file-count poller yields) and
throttle-writes Job.downloadProgress. Called synchronously from adapter.download on the
pipeline's own thread, so it safely reuses `conn`."""
"""Build the on_progress(pct, eta_seconds=None) callback threaded into adapter.download. It
marks the adapter as reporting real byte-level progress (so the file-count poller yields) and
throttle-writes Job.downloadProgress (+ the download ETA when the adapter measures one — slskd
does; others pass None). Called synchronously from adapter.download on the pipeline's own
thread, so it safely reuses `conn`."""
state = {"frac": -1.0, "t": 0.0}
def on_progress(pct: float) -> None:
def on_progress(pct: float, eta_seconds: int | None = None) -> None:
reports.set()
frac = 0.0 if pct < 0 else 1.0 if pct > 1 else float(pct)
now = time.monotonic()
# throttle DB writes: on a >=1% move or every 0.5s, not once per received byte
if frac - state["frac"] >= 0.01 or (now - state["t"]) >= 0.5:
# throttle DB writes: on a >=1% move, a new ETA, or every 0.5s not once per received byte
if frac - state["frac"] >= 0.01 or eta_seconds is not None or (now - state["t"]) >= 0.5:
state["frac"] = frac
state["t"] = now
try:
_set_download_progress(conn, job_id, frac)
_set_download_progress(conn, job_id, frac, eta_seconds)
except Exception as e: # a progress write must never fail the download
# Roll back so a failed write can't leave the shared pipeline conn in an aborted
# txn (which would cascade "current transaction is aborted" into every later query).
try:
conn.rollback()
except Exception:
pass
print(f"pipeline: on_progress write failed: {e}", flush=True)
return on_progress
@@ -214,6 +284,17 @@ def _library_quality(conn: psycopg.Connection, target: MBTarget) -> int | None:
return row[0] if row else None
def _keep_existing_and_complete(conn: psycopg.Connection, job_id: str) -> None:
"""Finish a job without importing — keep the existing library copy — and mark its request
completed. Used when a (deferred) upgrade has nothing better to offer right now; the release
stays below the cutoff and is re-attempted later."""
_set_state(conn, job_id, "imported", "import")
with conn.cursor() as cur:
cur.execute("UPDATE \"Request\" SET status = 'completed' WHERE id = %s",
(_request_id(conn, job_id),))
conn.commit()
def _persist_candidates(conn: psycopg.Connection, job_id: str, candidates: list[Candidate]) -> None:
with conn.cursor() as cur:
for c in candidates:
@@ -306,19 +387,43 @@ def run_pipeline(
conn.commit()
return
# 2. match — search all adapters; score + persist EVERY candidate found
# Pre-search upgrade skip: a >= CD-lossless copy can only be improved by hi-res Qobuz. If Qobuz
# is gated by pacing, don't even search (no other source can beat it) — defer to when Qobuz
# frees up. This avoids re-searching every already-owned album (~2 min Soulseek search each) on
# every monitor cycle. A user-forced re-acquire always proceeds.
_existing_q = _library_quality(conn, target)
if _existing_q is not None and _existing_q >= _CD_LOSSLESS_CLASS \
and not _is_force_job(conn, job_id) and not gate_open(conn):
_keep_existing_and_complete(conn, job_id)
return
# 2. match — search all adapters CONCURRENTLY (Soulseek's search polls up to ~2 min; run it
# alongside Qobuz's instant search so total match time = the slowest source, not their sum).
# Only Qobuz touches streamrip's shared asyncio loop and there's a single Qobuz adapter, so no
# two threads drive that loop at once. Results are collected in adapter order (map preserves
# input order) so candidate ordering stays deterministic.
_set_state(conn, job_id, "matching", "match")
found: list[Candidate] = []
for adapter in adapters:
try:
results = adapter.search(target)
except Exception as e: # a down source contributes no candidates, never crashes the job
print(f"pipeline: adapter {adapter.name} search failed: {e}", flush=True)
continue
with ThreadPoolExecutor(max_workers=max(1, len(adapters))) as pool:
per_adapter = list(pool.map(lambda a: (a, _search_adapter(a, target)), adapters))
for adapter, results in per_adapter:
for c in results:
found.append(replace(c, source_tier=adapter.tier, confidence=score_confidence(target, c)))
# Drop candidates that can't be the full album — a result with far fewer tracks than the release
# has (e.g. a single-song Soulseek folder named like the album). Same threshold as the
# completeness 'too small' guard, applied up front so we never waste a download attempt on it.
# Unknown counts (0) and legitimately smaller editions (e.g. 11 vs 12) are kept.
if target.track_count:
found = [c for c in found if c.track_count == 0 or c.track_count * 2 > target.track_count]
_persist_candidates(conn, job_id, found)
# Qobuz pacing: when the budget gate is closed (off-hours / daily cap / spacing), drop Qobuz
# candidates so the job falls through to another source and Qobuz stays under the radar — UNLESS
# Qobuz is the only option (nothing to fall through to, so don't strand the album).
if any(c.source == "qobuz" for c in found) and any(c.source != "qobuz" for c in found):
if not gate_open(conn):
found = [c for c in found if c.source != "qobuz"]
# 3. rank
_set_state(conn, job_id, "matched", "rank")
ranked = rank_candidates(target, found, min_confidence)
@@ -326,12 +431,23 @@ def run_pipeline(
_fail(conn, job_id, "no candidate above confidence threshold")
return
# Upgrade guard: for an album already in the library, skip the download when no currently-
# available source beats the copy we have (ranked[0] is the highest-quality candidate). This
# happens when Qobuz is gated by pacing and only a same-quality Soulseek copy is left — the
# download would be discarded by keep-if-better, so defer it. The release stays below the cutoff
# and is re-attempted when a better source frees up. A user-forced re-acquire always proceeds.
existing_q = _library_quality(conn, target)
if existing_q is not None and not _is_force_job(conn, job_id) \
and quality_class(ranked[0].quality) <= existing_q:
_keep_existing_and_complete(conn, job_id) # keep the existing copy; nothing better available
return
# 4. download (fall-through) into an isolated per-job staging dir
_set_state(conn, job_id, "downloading", "download")
by_source = {a.name: a for a in adapters}
staging = staging_dir(staging_root or f"{dest_root}/.staging", job_id)
winner = None
result = None
last_problem = None # why the most recent downloaded-but-rejected candidate was unusable
_set_download_progress(conn, job_id, 0.0) # reset for this run
expected = target.track_count or (ranked[0].track_count if ranked else 0)
_stop = threading.Event()
@@ -342,46 +458,46 @@ def run_pipeline(
_poller.start()
try:
try:
attempts = 0
for candidate in ranked:
adapter = by_source.get(candidate.source)
if adapter is None:
continue
if attempts >= _MAX_DOWNLOAD_ATTEMPTS:
break # don't grind through every peer/source of a popular album
attempts += 1
_mark_chosen(conn, job_id, candidate.source_ref) # reflect the source now in flight
shutil.rmtree(staging, ignore_errors=True) # clean slate per attempt
_reports.clear() # this attempt hasn't reported yet → poller estimates until it does
result = adapter.download(candidate, staging, _make_on_progress(conn, job_id, _reports))
if result.ok:
if not result.ok:
continue
# Verify completeness on the staging copy. A download that lands incomplete or too
# short falls through to the NEXT-ranked source (e.g. Qobuz repeatedly fails one
# track → try the deluxe edition or Soulseek) rather than failing the whole job on
# the first source's shortfall.
last_problem = _download_problem(result, staging, target, candidate, measure_duration)
if last_problem is None:
winner = candidate
break
finally:
_stop.set()
_poller.join(timeout=3)
if winner is None or result is None or not result.ok:
_fail(conn, job_id, "all downloads failed")
if winner is None:
# No source produced a complete album: surface why the last one was rejected (incomplete
# / too short), or "all downloads failed" if none even downloaded.
_fail(conn, job_id, last_problem or "all downloads failed")
return
_set_download_progress(conn, job_id, 1.0) # download done — UI shows 100% into Finishing
if winner.source == "qobuz":
# Count this Qobuz download against today's budget and set the next randomized spacing.
try:
record_download(conn, random.random())
except Exception as e: # pacing bookkeeping must never fail a good download
print(f"pipeline: qobuz pacing record failed for job {job_id}: {e}", flush=True)
# 5. verify completeness on the staging copy, then promote + tag
# 5. promote + tag the verified winner
_set_state(conn, job_id, "tagging", "tag")
expected = target.track_count if target.track_count is not None else winner.track_count
# Also count the NON-EMPTY audio actually on disk: a silently-skipped track leaves a
# 0-byte placeholder that the adapter still counts, so trust the files when any are
# staged. (`staged and ...` keeps fake adapters that stage nothing on the count path.)
staged = _count_staged_audio(staging)
if result.track_count < expected or (staged and staged < expected):
_fail(conn, job_id, "incomplete download")
return
# Duration check: even with the right track count, a truncated file or a short preview
# substituted for a real track shows up as a total playtime well under MB's expected
# total. Only applies when both are known (measured is None for the offline fakes).
if target.total_duration_s:
measured = measure_duration(staging)
if measured is not None and measured < target.total_duration_s * _DURATION_MIN_RATIO:
_fail(conn, job_id,
f"download too short ({measured / 60:.0f} of ~{target.total_duration_s / 60:.0f} min)")
return
final = album_dir(dest_root, target)
existing_q = _library_quality(conn, target)
new_q = quality_class(winner.quality)
@@ -395,12 +511,7 @@ def run_pipeline(
print(f"pipeline: tagging failed for job {job_id}: {e}", flush=True)
_import(conn, job_id, target, winner, final)
else:
# an existing copy is already at >= this quality — keep it untouched, just complete the request
with conn.cursor() as cur:
cur.execute(
"UPDATE \"Request\" SET status = 'completed' WHERE id = %s",
(_request_id(conn, job_id),),
)
conn.commit()
# an existing copy is already at >= this quality — keep it untouched, just complete
_keep_existing_and_complete(conn, job_id)
finally:
shutil.rmtree(staging, ignore_errors=True) # a partial/failed download never persists
+159
View File
@@ -0,0 +1,159 @@
"""Qobuz pacing ("budget gate").
Keep using Qobuz for hi-res, but at a human-looking volume and cadence so the account isn't
flagged for bulk downloading (which got it 403 USER_BLOCKED). One gate decides, per album,
whether Qobuz is an eligible source right now: within active hours, under today's cap (a warm-up
ramp of a steady cap), and past a randomized spacing since the last Qobuz download. When the gate
is closed the pipeline drops Qobuz candidates and falls through to the next source; monitored
albums upgrade to Qobuz later once budget frees up.
Pure decision logic (`today_cap`, `is_allowed`, `next_gap_seconds`) is separated from the two DB
helpers (`gate_open`, `record_download`) so the policy is unit-tested without a clock or DB.
"""
from dataclasses import dataclass
from datetime import date
_TRUE = {"1", "true", "yes", "on"}
_JITTER = 0.3 # ± fraction around the spread interval
_GAP_FLOOR_S = 60.0
@dataclass(frozen=True)
class QobuzPacing:
enabled: bool = True
active_start: int = 8
active_end: int = 23
daily_cap: int = 40
warmup_start_date: str = "" # ISO "YYYY-MM-DD", or "" for no warm-up ramp
@classmethod
def from_config(cls, config: dict) -> "QobuzPacing":
def _int(key, default):
try:
return int(config[key])
except (KeyError, TypeError, ValueError):
return default
return cls(
enabled=str(config.get("qobuz.pacing.enabled", "true")).strip().lower() in _TRUE,
active_start=_int("qobuz.pacing.activeStartHour", 8),
active_end=_int("qobuz.pacing.activeEndHour", 23),
daily_cap=_int("qobuz.pacing.dailyCap", 40),
warmup_start_date=str(config.get("qobuz.pacing.warmupStartDate", "") or "").strip(),
)
def today_cap(pacing: QobuzPacing, today: date) -> int:
"""Today's Qobuz download cap: the steady cap, ramped up by account age (days since
``warmup_start_date``). No warm-up date -> steady cap. A small steady cap is never exceeded."""
steady = max(0, pacing.daily_cap)
if not pacing.warmup_start_date:
return steady
try:
start = date.fromisoformat(pacing.warmup_start_date)
except ValueError:
return steady
days = max(0, (today - start).days)
if days < 3:
return min(5, steady)
if days < 7:
return min(10, steady)
if days < 14:
return min(20, steady)
return steady
def is_allowed(pacing: QobuzPacing, now_hour: int, count_today: int, cap: int,
now_epoch: float, next_allowed_epoch: float) -> bool:
"""Pure gate decision. Qobuz is eligible only within active hours, under today's cap, and past
the randomized spacing since the last download. Pacing disabled -> always allowed."""
if not pacing.enabled:
return True
if not (pacing.active_start <= now_hour < pacing.active_end):
return False
if count_today >= cap:
return False
if now_epoch < next_allowed_epoch:
return False
return True
def next_gap_seconds(pacing: QobuzPacing, cap: int, rand01: float) -> float:
"""Seconds until the next Qobuz download is allowed: the day's budget spread across the active
window (``activeWindow / cap``), ± _JITTER, floored at _GAP_FLOOR_S. ``rand01`` in [0,1] is the
injected randomness (so tests are deterministic; callers pass random.random())."""
window_s = max(1, pacing.active_end - pacing.active_start) * 3600
base = window_s / max(1, cap)
lo, hi = base * (1 - _JITTER), base * (1 + _JITTER)
gap = lo + (hi - lo) * rand01
return max(_GAP_FLOOR_S, gap)
def _now_parts(conn):
"""(hour:int, today:date, epoch:float) from the DB clock — avoids container-TZ ambiguity."""
with conn.cursor() as cur:
cur.execute("SELECT extract(hour from now())::int, current_date, extract(epoch from now())")
hour, today, epoch = cur.fetchone()
return int(hour), today, float(epoch)
def _read(conn, keys):
with conn.cursor() as cur:
cur.execute('SELECT key, value FROM "Config" WHERE key = ANY(%s)', (list(keys),))
return dict(cur.fetchall())
def gate_open(conn) -> bool:
"""Whether Qobuz may be used for a download right now (reads pacing config + counter/spacing
state from Config and the DB clock). A counter from a previous day is treated as 0."""
from lyra_worker.config import get_config
pacing = QobuzPacing.from_config(get_config(conn))
hour, today, epoch = _now_parts(conn)
state = _read(conn, ("qobuz.pacing.countDay", "qobuz.pacing.countToday", "qobuz.pacing.nextAllowedAt"))
count = 0
if state.get("qobuz.pacing.countDay") == today.isoformat():
try:
count = int(state.get("qobuz.pacing.countToday") or 0)
except (TypeError, ValueError):
count = 0
try:
next_allowed = float(state.get("qobuz.pacing.nextAllowedAt") or 0)
except (TypeError, ValueError):
next_allowed = 0.0
return is_allowed(pacing, hour, count, today_cap(pacing, today), epoch, next_allowed)
def record_download(conn, rand01: float) -> None:
"""Record a successful Qobuz download: bump today's counter (resetting it if the stored day is
stale) and set the randomized next-allowed time. ``rand01`` is injected randomness."""
from lyra_worker.config import get_config
pacing = QobuzPacing.from_config(get_config(conn))
_hour, today, epoch = _now_parts(conn)
state = _read(conn, ("qobuz.pacing.countDay", "qobuz.pacing.countToday"))
if state.get("qobuz.pacing.countDay") == today.isoformat():
try:
count = int(state.get("qobuz.pacing.countToday") or 0)
except (TypeError, ValueError):
count = 0
else:
count = 0
count += 1
next_allowed = epoch + next_gap_seconds(pacing, today_cap(pacing, today), rand01)
_write(conn, {
"qobuz.pacing.countDay": today.isoformat(),
"qobuz.pacing.countToday": str(count),
"qobuz.pacing.nextAllowedAt": repr(next_allowed),
})
def _write(conn, kv: dict) -> None:
with conn.cursor() as cur:
for key, value in kv.items():
cur.execute(
'INSERT INTO "Config"(key,value,secret,"updatedAt") VALUES (%s,%s,false,now()) '
'ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, "updatedAt"=now()',
(key, value),
)
conn.commit()
+1
View File
@@ -31,6 +31,7 @@ class LastfmSource:
params={"method": method, "api_key": self._key, "format": "json", **params},
timeout=self._timeout,
)
res.raise_for_status() # a 4xx/5xx (rate limit, auth) → RequestException, handled by callers
return res.json()
def health(self) -> bool:
@@ -41,6 +41,7 @@ class LastfmAlbumPopularity:
"api_key": self._key, "format": "json", "limit": str(self._limit),
"autocorrect": "1",
}, timeout=self._timeout)
res.raise_for_status()
return _parse_top_albums(res.json())
except Exception: # a Last.fm outage must never abort the sweep
return []
+57
View File
@@ -0,0 +1,57 @@
"""A search result with far fewer tracks than the release (e.g. a single-song Soulseek folder
named like the album) can't be the full album — it must be dropped before we waste a download."""
import os
from lyra_worker.claim import claim_next
from lyra_worker.pipeline import run_pipeline
from lyra_worker.types import Candidate, DownloadResult, MBTarget, Quality
from tests.conftest import insert_request
_Q = Quality(fmt="FLAC", lossless=True, bit_depth=24, sample_rate=96000)
class _TwoCandQobuz:
name = "qobuz"
tier = 0
def __init__(self):
self.downloaded = []
def health(self):
return True
def search(self, target):
return [
Candidate(source="qobuz", source_ref="single", matched_artist=target.artist,
matched_album=target.album, quality=_Q, track_count=1, source_tier=0),
Candidate(source="qobuz", source_ref="full", matched_artist=target.artist,
matched_album=target.album, quality=_Q, track_count=12, source_tier=0),
]
def download(self, candidate, dest, on_progress):
self.downloaded.append(candidate.source_ref)
os.makedirs(dest, exist_ok=True)
for i in range(candidate.track_count):
with open(os.path.join(dest, f"{i + 1:02d}.flac"), "wb") as fh:
fh.write(b"a")
return DownloadResult(ok=True, path=dest, track_count=candidate.track_count)
class _Resolver:
def __init__(self, n):
self.n = n
def resolve(self, artist, album):
return MBTarget(artist=artist, album=album, track_count=self.n)
def test_single_song_candidate_is_dropped(conn):
job_id = insert_request(conn, artist="A", album="B")
claim_next(conn)
adapter = _TwoCandQobuz()
run_pipeline(conn, job_id, [adapter], resolver=_Resolver(12), dest_root="/tmp/lib-cf")
assert adapter.downloaded == ["full"] # only the full album was ever attempted
with conn.cursor() as cur:
cur.execute('SELECT "trackCount" FROM "Candidate" WHERE "jobId" = %s', (job_id,))
assert [r[0] for r in cur.fetchall()] == [12] # the single-song folder wasn't even persisted
+14
View File
@@ -24,3 +24,17 @@ def test_claim_does_not_repick_claimed_job(conn):
insert_request(conn)
assert claim_next(conn) is not None
assert claim_next(conn) is None
def test_claim_skips_paused_jobs(conn):
paused_job = insert_request(conn, album="Paused Album")
with conn.cursor() as cur:
cur.execute('UPDATE "Job" SET paused = true WHERE id = %s', (paused_job,))
conn.commit()
unpaused_job = insert_request(conn, album="Live Album")
claimed = claim_next(conn)
assert claimed == unpaused_job # the paused one is skipped
# With only the paused job left, nothing is claimable.
assert claim_next(conn) is None
+50 -21
View File
@@ -26,7 +26,7 @@ def test_seeds_discovery_from_owned_album_artists(conn):
# An artist you own an album by but don't follow becomes a discovery seed.
_insert_library_item(conn, "Owned Artist", "lib-seed")
src = FakeSimilaritySource(similar={"lib-seed": [SimilarArtist("c1", "New Find", 0.9)]})
result = run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
result = run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert result.seeds == 1
assert [r[0] for r in _artist_rows(conn)] == ["c1"] # surfaced from the library seed
with conn.cursor() as cur: # library seed throttled via DiscoverySeed, not WatchedArtist
@@ -37,7 +37,7 @@ def test_seeds_discovery_from_owned_album_artists(conn):
def test_library_seeding_can_be_disabled(conn):
_insert_library_item(conn, "Owned Artist", "lib-seed")
src = FakeSimilaritySource(similar={"lib-seed": [SimilarArtist("c1", "New Find", 0.9)]})
result = run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig(seed_from_library=False))
result = run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig(seed_from_library=False))
assert result.seeds == 0 and _artist_rows(conn) == [] # library not seeded
@@ -49,7 +49,7 @@ def test_owned_artist_is_not_suggested_as_a_candidate(conn):
"s1": [SimilarArtist("c1", "Owned", 0.9), SimilarArtist("c2", "New", 0.8)],
"c1": [], # the library seed itself finds nothing new here
})
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert [r[0] for r in _artist_rows(conn)] == ["c2"] # c1 excluded (already owned)
@@ -60,7 +60,7 @@ def test_aggregates_scores_across_seeds(conn):
"s1": [SimilarArtist("c1", "Shared", 0.6), SimilarArtist("c2", "Only1", 0.4)],
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
result = run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
result = run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert result.artists == 2
rows = _artist_rows(conn)
# c1 recommended by both seeds; each seed max-normalizes its top score to 1.0, so
@@ -81,7 +81,7 @@ def test_normalizes_across_sources_of_different_scales(conn):
lfm = FakeSimilaritySource(similar={"s1": [
SimilarArtist("c_lfm", "LFM Only", 0.9), SimilarArtist("c_shared", "Shared", 0.5)]})
lfm.name = "lastfm"
run_discovery(conn, [lb, lfm], FakeMbBrowser(), DiscoveryConfig())
run_discovery(conn, [lb, lfm], FakeMbBrowser(default_core=True), DiscoveryConfig())
lb_only = _artist_score(conn, "c_lb")[0] # 5000/5000 = 1.0
lfm_only = _artist_score(conn, "c_lfm")[0] # 0.9/0.9 = 1.0
@@ -95,7 +95,7 @@ def test_skips_already_followed_artists(conn):
insert_watched_artist(conn, mbid="c1", name="Already Followed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9),
SimilarArtist("c2", "New", 0.8)]})
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert [r[0] for r in _artist_rows(conn)] == ["c2"] # c1 filtered (followed)
@@ -103,7 +103,7 @@ def test_min_score_filters_weak_candidates(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Strong", 0.9),
SimilarArtist("c2", "Weak", 0.1)]})
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig(min_score=0.5))
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig(min_score=0.5))
assert [r[0] for r in _artist_rows(conn)] == ["c1"]
@@ -111,7 +111,7 @@ def test_dismissed_suggestion_is_never_revived(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c1", status="dismissed", score=0.0)
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]})
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
with conn.cursor() as cur:
cur.execute('SELECT status::text, score FROM "DiscoverySuggestion" WHERE "artistMbid" = %s', ("c1",))
assert cur.fetchone() == ("dismissed", 0.0) # untouched — WHERE status='pending' blocked the update
@@ -120,18 +120,18 @@ def test_dismissed_suggestion_is_never_revived(conn):
def test_stamps_last_discovered_and_throttles(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]})
assert run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()).artists == 1
assert run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig()).artists == 1
with conn.cursor() as cur:
cur.execute('SELECT "lastDiscoveredAt" IS NOT NULL FROM "WatchedArtist" WHERE mbid = %s', ("s1",))
assert cur.fetchone()[0] is True
# second run: seed not due (lastDiscoveredAt recent) -> no seeds -> nothing new
assert run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()).artists == 0
assert run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig()).artists == 0
def test_unhealthy_source_contributes_nothing(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]}, healthy=False)
assert run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()).artists == 0
assert run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig()).artists == 0
assert _artist_rows(conn) == []
@@ -146,7 +146,7 @@ def test_source_with_raising_health_is_skipped_not_fatal(conn):
return []
good = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]})
result = run_discovery(conn, [BoomSource(), good], FakeMbBrowser(), DiscoveryConfig())
result = run_discovery(conn, [BoomSource(), good], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert result.artists == 1 # good source still processed; boom skipped, no crash
@@ -176,8 +176,8 @@ def test_score_sums_across_separate_chunks(conn):
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
cfg = DiscoveryConfig(chunk_size=1)
run_discovery(conn, [src], FakeMbBrowser(), cfg) # processes s1 (older)
run_discovery(conn, [src], FakeMbBrowser(), cfg) # processes s2
run_discovery(conn, [src], FakeMbBrowser(default_core=True), cfg) # processes s1 (older)
run_discovery(conn, [src], FakeMbBrowser(default_core=True), cfg) # processes s2
# replace-not-accumulate would leave 0.5/seedCount1; contributions give the sum.
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
@@ -193,7 +193,7 @@ def test_partial_sweep_preserves_other_seeds_contribution(conn):
"s1": [SimilarArtist("c1", "Shared", 0.6)],
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()) # both -> 1.1/2
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig()) # both -> 1.1/2
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
# s1 due again (older than interval), s2 fresh; s1 now scores c1 at 0.7.
@@ -202,7 +202,7 @@ def test_partial_sweep_preserves_other_seeds_contribution(conn):
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s', ("s2",))
conn.commit()
src2 = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Shared", 0.7)]})
run_discovery(conn, [src2], FakeMbBrowser(), DiscoveryConfig())
run_discovery(conn, [src2], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (2.0, 2) # 1.0 (new s1) + 1.0 (kept s2), max-normalized
@@ -215,7 +215,7 @@ def test_seed_dropoff_lowers_seedcount(conn):
"s1": [SimilarArtist("c1", "Shared", 0.6)],
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
# s1 re-swept but no longer finds c1 -> its contribution is removed, c1 recomputed.
@@ -224,7 +224,7 @@ def test_seed_dropoff_lowers_seedcount(conn):
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s', ("s2",))
conn.commit()
src2 = FakeSimilaritySource(similar={"s1": [SimilarArtist("c9", "Other", 0.9)]})
run_discovery(conn, [src2], FakeMbBrowser(), DiscoveryConfig())
run_discovery(conn, [src2], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (1.0, 1) # only s2 remains (max-normalized to 1.0)
@@ -241,7 +241,7 @@ def test_evicts_contributions_from_removed_seed(conn):
"s1": [SimilarArtist("c1", "Shared", 0.6)],
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
# Unfollow s1 (no FK cascade on DiscoverySeedContribution).
@@ -254,7 +254,7 @@ def test_evicts_contributions_from_removed_seed(conn):
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() - interval \'200 hours\' WHERE mbid = %s', ("s2",))
conn.commit()
src2 = FakeSimilaritySource(similar={"s2": [SimilarArtist("c1", "Shared", 0.5)]})
run_discovery(conn, [src2], FakeMbBrowser(), DiscoveryConfig())
run_discovery(conn, [src2], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (1.0, 1) # s1's stale contribution evicted; s2 alone -> 1.0
with conn.cursor() as cur:
@@ -271,9 +271,38 @@ def test_run_discovery_processes_at_most_chunk_size_seeds(conn):
insert_watched_artist(conn, mbid=f"s{i}", name=f"Seed{i}")
src = FakeSimilaritySource(similar={f"s{i}": [SimilarArtist(f"c{i}", f"Cand{i}", 0.9)] for i in range(4)})
cfg = DiscoveryConfig.from_config({"discover.chunkSize": "2"})
result = run_discovery(conn, [src], FakeMbBrowser(), cfg)
result = run_discovery(conn, [src], FakeMbBrowser(default_core=True), cfg)
assert result.seeds == 2 # only chunk_size seeds this call
# exactly 2 seeds now have lastDiscoveredAt set
with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NOT NULL')
assert cur.fetchone()[0] == 2
def test_reset_pending_clears_state_and_marks_seeds_due(conn):
from lyra_worker.discovery import reset_pending
from tests.conftest import insert_discovery_suggestion
insert_watched_artist(conn, mbid="s1", name="Seed", last_discovered_sql="now()")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c1", status="pending")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c2", status="dismissed")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c3", status="wanted")
with conn.cursor() as cur:
cur.execute('INSERT INTO "DiscoverySeedContribution" (id, "candidateMbid", "candidateName", '
'"seedMbid", score, sources, "updatedAt") VALUES '
"(gen_random_uuid()::text, 'c1', 'C1', 's1', 1.0, '{}', now())")
cur.execute('INSERT INTO "DiscoverySeed" (mbid, "lastDiscoveredAt") VALUES (%s, now())', ("libseed",))
conn.commit()
reset_pending(conn)
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM \"DiscoverySuggestion\" WHERE status='pending'")
assert cur.fetchone()[0] == 0 # pending cleared
cur.execute("SELECT count(*) FROM \"DiscoverySuggestion\" WHERE status IN ('dismissed','wanted')")
assert cur.fetchone()[0] == 2 # user decisions kept
cur.execute('SELECT count(*) FROM "DiscoverySeedContribution"')
assert cur.fetchone()[0] == 0
cur.execute('SELECT count(*) FROM "DiscoverySeed"')
assert cur.fetchone()[0] == 0
cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NULL')
assert cur.fetchone()[0] == 1 # seed due again
+58 -1
View File
@@ -2,7 +2,7 @@ from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource
from lyra_worker.browser import ReleaseGroupInfo
from lyra_worker.discovery import DiscoveryConfig, run_discovery
from lyra_worker.similarity.base import SimilarArtist
from tests.conftest import insert_monitored_release, insert_watched_artist
from tests.conftest import insert_discovery_suggestion, insert_monitored_release, insert_watched_artist
def _album_rows(conn):
@@ -12,6 +12,13 @@ def _album_rows(conn):
return cur.fetchall()
def _artist_rows(conn):
with conn.cursor() as cur:
cur.execute("SELECT \"artistMbid\" FROM \"DiscoverySuggestion\" "
"WHERE kind = 'artist' AND status = 'pending' ORDER BY \"artistMbid\"")
return cur.fetchall()
def _reasons(conn):
with conn.cursor() as cur:
cur.execute('SELECT "rgMbid", "albumReason" FROM "DiscoverySuggestion" '
@@ -92,6 +99,56 @@ def test_popular_matches_by_title_when_lastfm_mbid_missing_and_skips_owned(conn)
assert _reasons(conn) == {"rg-new": "newest", "rg-match": "popular"}
def test_rederiving_replaces_stale_pending_album_suggestions(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
# a stale pending album suggestion for c1 that is no longer a pick (e.g. an old single)
insert_discovery_suggestion(conn, kind="album", artist_mbid="c1", rg_mbid="rg-old-single",
album="Old Single", status="pending")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
browser = FakeMbBrowser(releases={"c1": [
ReleaseGroupInfo("rg-new", "New Album", "Album", (), "2022-01-01"),
]})
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1))
# the stale single is gone; only the freshly-derived album remains
assert [r[1] for r in _album_rows(conn)] == ["rg-new"]
def test_rederiving_keeps_wanted_album_suggestions(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
insert_discovery_suggestion(conn, kind="album", artist_mbid="c1", rg_mbid="rg-wanted",
album="Wanted One", status="wanted")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
browser = FakeMbBrowser(releases={"c1": [
ReleaseGroupInfo("rg-new", "New Album", "Album", (), "2022-01-01"),
]})
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1))
with conn.cursor() as cur: # wanted row untouched (only pending stale rows are replaced)
cur.execute('SELECT count(*) FROM "DiscoverySuggestion" WHERE "rgMbid" = %s AND status = \'wanted\'', ("rg-wanted",))
assert cur.fetchone()[0] == 1
def test_drops_artist_with_no_core_discography(conn):
# Two candidates: one real (has a core album), one a "producer" whose only release is a
# secondary-typed soundtrack (like Max Martin's & Juliet) — the latter must be dropped.
insert_watched_artist(conn, mbid="s1", name="Seed")
src = FakeSimilaritySource(similar={"s1": [
SimilarArtist("real", "Real Artist", 0.9), SimilarArtist("producer", "Producer", 0.8)]})
browser = FakeMbBrowser(releases={
"real": [ReleaseGroupInfo("rg-r", "Record", "Album", (), "2020-01-01")],
"producer": [ReleaseGroupInfo("rg-st", "A Musical", "Album", ("Soundtrack",), "2019-01-01")],
})
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1))
assert [r[0] for r in _artist_rows(conn)] == ["real"] # producer dropped
def test_drops_artist_with_empty_discography(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("member", "Band Member", 0.9)]})
browser = FakeMbBrowser(releases={"member": []}) # no release groups at all
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1))
assert _artist_rows(conn) == []
def test_no_popularity_provider_yields_newest_only(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
+50
View File
@@ -0,0 +1,50 @@
from lyra_worker.floor import (
press_paused,
job_delay_seconds,
kill_requested,
consume_kill_switch,
)
def test_press_paused_reads_truthy_values():
assert press_paused({"floor.paused": "true"}) is True
assert press_paused({"floor.paused": "ON"}) is True
assert press_paused({"floor.paused": "false"}) is False
assert press_paused({}) is False
def test_job_delay_seconds_parses_and_defaults():
assert job_delay_seconds({"floor.jobDelaySeconds": "30"}) == 30.0
assert job_delay_seconds({}) == 0.0
assert job_delay_seconds({"floor.jobDelaySeconds": "-5"}) == 0.0
assert job_delay_seconds({"floor.jobDelaySeconds": "junk"}) == 0.0
def test_kill_requested_reads_flag():
assert kill_requested({"floor.killRequested": "true"}) is True
assert kill_requested({}) is False
def test_consume_kill_switch_clears_flag_and_exits(conn):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Config"(key, value, secret, "updatedAt") '
"VALUES ('floor.killRequested', 'true', false, now())"
)
conn.commit()
calls = []
fired = consume_kill_switch(conn, exit_fn=lambda code: calls.append(code))
assert fired is True
assert calls == [0]
with conn.cursor() as cur:
cur.execute("SELECT value FROM \"Config\" WHERE key = 'floor.killRequested'")
assert cur.fetchone()[0] == "false"
def test_consume_kill_switch_noop_when_unset(conn):
calls = []
fired = consume_kill_switch(conn, exit_fn=lambda code: calls.append(code))
assert fired is False
assert calls == []
+125
View File
@@ -0,0 +1,125 @@
"""When the top-ranked source downloads but is incomplete (e.g. Qobuz consistently fails one
track), the pipeline must fall through to the next-ranked source instead of failing the whole
job — only giving up (needs_attention) when every candidate is incomplete."""
import os
from lyra_worker.claim import claim_next
from lyra_worker.pipeline import run_pipeline
from lyra_worker.types import Candidate, DownloadResult, Quality
from tests.conftest import insert_request
_HIRES = Quality(fmt="FLAC", lossless=True, bit_depth=24, sample_rate=96000)
_CD = Quality(fmt="FLAC", lossless=True, bit_depth=16, sample_rate=44100)
def _stage(dest, n):
os.makedirs(dest, exist_ok=True)
for i in range(n):
with open(os.path.join(dest, f"{i + 1:02d}.flac"), "wb") as fh:
fh.write(b"audio")
class _Qobuz:
"""Higher-quality (ranks first) Qobuz that promises 3 tracks but delivers `deliver`."""
name = "qobuz"
tier = 0
def __init__(self, deliver):
self._deliver = deliver
def health(self):
return True
def search(self, target):
return [Candidate(source="qobuz", source_ref="q", matched_artist=target.artist,
matched_album=target.album, quality=_HIRES, track_count=3, source_tier=0)]
def download(self, candidate, dest, on_progress):
_stage(dest, self._deliver)
return DownloadResult(ok=True, path=dest, track_count=self._deliver)
class _Soulseek:
"""Lower-quality (ranks second) Soulseek that promises 3 tracks and delivers `deliver`."""
name = "soulseek"
tier = 1
def __init__(self, deliver):
self._deliver = deliver
def health(self):
return True
def search(self, target):
return [Candidate(source="soulseek", source_ref="s", matched_artist=target.artist,
matched_album=target.album, quality=_CD, track_count=3, source_tier=1)]
def download(self, candidate, dest, on_progress):
_stage(dest, self._deliver)
return DownloadResult(ok=True, path=dest, track_count=self._deliver)
def _state_error(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT state, error FROM "Job" WHERE id = %s', (job_id,))
return cur.fetchone()
def _chosen_source(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
row = cur.fetchone()
return row[0] if row else None
def test_incomplete_top_source_falls_through_to_complete_next(conn, tmp_path):
# Qobuz (ranks first on quality) delivers only 2 of its promised 3 tracks -> incomplete.
# The pipeline must try the next source (Soulseek, complete) rather than failing the job.
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [_Qobuz(deliver=2), _Soulseek(deliver=3)], dest_root=str(tmp_path))
assert _state_error(conn, job_id) == ("imported", None)
assert _chosen_source(conn, job_id) == "soulseek"
def test_all_sources_incomplete_still_needs_attention(conn, tmp_path):
# Every candidate is incomplete -> no fallback succeeds -> needs_attention (incomplete).
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [_Qobuz(deliver=2), _Soulseek(deliver=1)], dest_root=str(tmp_path))
assert _state_error(conn, job_id) == ("needs_attention", "incomplete download")
assert not (tmp_path / "Radiohead" / "In Rainbows").exists() # nothing imported
class _CountingIncompleteQobuz:
"""Offers `n` candidates that all download but come back incomplete; counts download calls."""
name = "qobuz"
tier = 0
def __init__(self, n):
self.n = n
self.calls = 0
def health(self):
return True
def search(self, target):
q = _HIRES
return [Candidate(source="qobuz", source_ref=f"r{i}", matched_artist=target.artist,
matched_album=target.album, quality=q, track_count=3, source_tier=0)
for i in range(self.n)]
def download(self, candidate, dest, on_progress):
self.calls += 1
_stage(dest, 1) # 1 of 3 → always incomplete
return DownloadResult(ok=True, path=dest, track_count=1)
def test_fall_through_is_capped(conn):
from lyra_worker.pipeline import _MAX_DOWNLOAD_ATTEMPTS
a = _CountingIncompleteQobuz(n=20)
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [a], dest_root="/tmp/lib-cap")
assert _state_error(conn, job_id) == ("needs_attention", "incomplete download")
assert a.calls == _MAX_DOWNLOAD_ATTEMPTS # capped, not all 20 peers tried
+46
View File
@@ -72,6 +72,52 @@ def test_grabbed_past_window_is_fulfilled_not_enqueued(conn):
assert _state(conn, rel_id) == "fulfilled"
def test_owned_by_rgmbid_despite_name_mismatch_is_fulfilled(conn):
# The monitor followed "Kanye West" but the library stores MB's canonical "Ye"; matching on
# rgMbid recognises ownership so the release is fulfilled, not re-pressed.
rel_id = insert_monitored_release(conn, artist_name="Kanye West", album="Graduation",
rg_mbid="rg-grad", monitored=True, state="wanted")
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "LibraryItem" (id, artist, album, path, source, format, '
'"qualityClass", "rgMbid", "importedAt") '
"VALUES (gen_random_uuid()::text, 'Ye', 'Graduation', '/m', 'soulseek', 'FLAC', 2, "
"'rg-grad', now())"
)
conn.commit()
assert enqueue_due(conn, CFG) == 0
assert _state(conn, rel_id) == "fulfilled"
def test_re_enqueue_retires_prior_failed_attempt(conn):
# A hard-to-match release re-pressed each retry interval must not pile up needs_attention
# Request rows; the prior failed attempt is retired when the next one is enqueued.
rel_id = insert_monitored_release(conn, artist_name="X", album="Y", rg_mbid="rg-1",
monitored=True, state="wanted")
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Request" (id, artist, album, status, "monitoredReleaseId", "createdAt") '
"VALUES (gen_random_uuid()::text, 'X', 'Y', 'needs_attention', %s, now()) RETURNING id",
(rel_id,),
)
req = cur.fetchone()[0]
cur.execute(
'INSERT INTO "Job" (id, "requestId", state, "currentStage", attempts, "createdAt", "updatedAt") '
"VALUES (gen_random_uuid()::text, %s, 'needs_attention', 'rank', 1, now(), now())",
(req,),
)
conn.commit()
assert enqueue_due(conn, CFG) == 1
assert _job_count_for(conn, rel_id) == 1 # old failed attempt retired, one fresh attempt
with conn.cursor() as cur:
cur.execute(
'SELECT j.state FROM "Job" j JOIN "Request" r ON r.id = j."requestId" '
'WHERE r."monitoredReleaseId" = %s',
(rel_id,),
)
assert cur.fetchone()[0] == "requested"
def test_already_in_library_at_cutoff_is_fulfilled(conn):
rel_id = insert_monitored_release(conn, artist_name="A", album="B", rg_mbid="rg-1",
monitored=True, state="wanted")
+50 -4
View File
@@ -22,17 +22,27 @@ def _make_job(conn, rel_id, artist, album, state):
return req, job
def _add_library(conn, req, artist, album, quality):
def _add_library(conn, req, artist, album, quality, rg_mbid=None):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "LibraryItem" (id, "requestId", artist, album, path, source, format, '
'"qualityClass", "importedAt") '
"VALUES (gen_random_uuid()::text, %s, %s, %s, '/m', 'qobuz', 'FLAC', %s, now())",
(req, artist, album, quality),
'"qualityClass", "rgMbid", "importedAt") '
"VALUES (gen_random_uuid()::text, %s, %s, %s, '/m', 'qobuz', 'FLAC', %s, %s, now())",
(req, artist, album, quality, rg_mbid),
)
conn.commit()
def _needs_attention_count(conn, rel_id):
with conn.cursor() as cur:
cur.execute(
'SELECT count(*) FROM "Request" r JOIN "Job" j ON j."requestId" = r.id '
"WHERE r.\"monitoredReleaseId\" = %s AND j.state = 'needs_attention'",
(rel_id,),
)
return cur.fetchone()[0]
def _release(conn, rel_id):
with conn.cursor() as cur:
cur.execute(
@@ -106,6 +116,42 @@ def test_fulfill_owned_releases_ignores_below_cutoff(conn):
assert _release(conn, rel)[0] == "wanted"
def test_reconcile_matches_by_rgmbid_when_artist_name_canonicalized(conn):
# Real 'Kanye West' -> 'Ye' bug: the MonitoredRelease keeps the followed name, but the
# imported LibraryItem carries MusicBrainz's canonical artist. Matching on rgMbid lets the
# release reconcile to fulfilled instead of looking un-owned and re-pressing forever.
rel = insert_monitored_release(conn, artist_name="Kanye West", album="Graduation",
rg_mbid="rg-grad", monitored=True, state="wanted")
req, job = _make_job(conn, rel, "Kanye West", "Graduation", "imported")
_add_library(conn, req, "Ye", "Graduation", 3, rg_mbid="rg-grad")
reconcile(conn, job, CFG)
assert _release(conn, rel) == ("fulfilled", 3, True)
def test_reconcile_fulfilled_retires_failed_sibling_attempts(conn):
# 'V' by Maroon 5 phantom: earlier failed attempts leave needs_attention Request rows that
# keep showing on the press even after a later attempt imports. A successful import retires
# those failed siblings so the press stops showing an owned album as needing attention.
rel = insert_monitored_release(conn, artist_name="Maroon 5", album="V", rg_mbid="rg-v",
monitored=True, state="wanted")
_make_job(conn, rel, "Maroon 5", "V", "needs_attention")
_make_job(conn, rel, "Maroon 5", "V", "needs_attention")
req, job = _make_job(conn, rel, "Maroon 5", "V", "imported")
_add_library(conn, req, "Maroon 5", "V", 2)
reconcile(conn, job, CFG)
assert _release(conn, rel)[0] == "fulfilled"
assert _needs_attention_count(conn, rel) == 0 # failed siblings retired
def test_fulfill_owned_releases_matches_by_rgmbid(conn):
# Ne-Yo -> NeYo (typographic hyphen) name drift: still owned by rgMbid.
rel = insert_monitored_release(conn, artist_name="Ne-Yo", album="Because of You",
rg_mbid="rg-ney", monitored=True, state="grabbed")
_add_library(conn, None, "NeYo", "Because of You", 2, rg_mbid="rg-ney")
assert fulfill_owned_releases(conn, MonitorConfig(quality_cutoff=2)) == 1
assert _release(conn, rel) == ("fulfilled", 2, True)
def test_unlinked_job_is_ignored(conn):
with conn.cursor() as cur:
cur.execute(
+174
View File
@@ -0,0 +1,174 @@
import musicbrainzngs
import pytest
from lyra_worker._musicbrainz import _best_release_group, _pick_release, _with_retry
# Offline unit tests for the pure release-group selection logic. The live resolve()
# path (network) is covered by test_musicbrainz_live.py.
def _rg(title, primary_type, artist=""):
g = {"id": title.lower(), "title": title, "primary-type": primary_type}
if artist:
g["artist-credit"] = [{"artist": {"name": artist, "id": artist.lower()}}]
return g
def test_prefers_album_over_same_titled_single():
# Real-world "Off the Wall" bug: MusicBrainz returns the Single release-group
# first, tied on title with the Album. We must pick the Album so a 10-track
# download isn't tagged from the 2-track single's tracklist.
groups = [
_rg("Off the Wall", "Single", "Michael Jackson"),
_rg("Off the Wall", "Album", "Michael Jackson"),
]
assert _best_release_group("Michael Jackson", "Off the Wall", groups)["primary-type"] == "Album"
def test_album_preference_is_order_independent():
groups = [
_rg("Off the Wall", "Album", "Michael Jackson"),
_rg("Off the Wall", "Single", "Michael Jackson"),
]
assert _best_release_group("Michael Jackson", "Off the Wall", groups)["primary-type"] == "Album"
def test_closer_title_still_wins_over_album_type():
# Title similarity dominates the album-type preference (for a single artist).
groups = [
_rg("Continuum", "Single", "John Mayer"),
_rg("Continuum Deluxe Reissue", "Album", "John Mayer"),
]
assert _best_release_group("John Mayer", "Continuum", groups)["title"] == "Continuum"
def test_below_threshold_returns_none():
groups = [_rg("Something Totally Different", "Album", "Michael Jackson")]
assert _best_release_group("Michael Jackson", "Off the Wall", groups) is None
def test_empty_groups_returns_none():
assert _best_release_group("Michael Jackson", "Off the Wall", []) is None
def test_prefers_original_group_over_later_reissue():
# Real "Hybrid Theory" bug: MB lists a 2000 original and a 2023 reissue as separate same-titled
# Album groups (tie on artist/title/type). The reissue's longer tracklist fails completeness
# checks, so the original (earliest) must win regardless of MB's relevance order.
groups = [
{"id": "reissue", "title": "Hybrid Theory", "primary-type": "Album",
"first-release-date": "2023-07-21",
"artist-credit": [{"artist": {"name": "Linkin Park"}}]},
{"id": "original", "title": "Hybrid Theory", "primary-type": "Album",
"first-release-date": "2000-10-24",
"artist-credit": [{"artist": {"name": "Linkin Park"}}]},
]
assert _best_release_group("Linkin Park", "Hybrid Theory", groups)["id"] == "original"
def test_prefers_credited_artist_over_same_titled_other_artist_album():
# Real-world "Fun Machine" bug: Lake Street Dive's EP is what we followed, but two
# unrelated bands also have a release group literally titled "Fun Machine" as an Album.
# All three tie on title (1.0), so the old is_album tiebreak wrongly grabbed a foreign
# Album. The followed artist must win over title-tied albums by other artists.
groups = [
_rg("Fun Machine", "EP", "Lake Street Dive"),
_rg("Fun Machine", "Album", "Bastards of Melody"),
_rg("Fun Machine", "Album", "Teledildonics 5000"),
]
best = _best_release_group("Lake Street Dive", "Fun Machine", groups)
assert best["artist-credit"][0]["artist"]["name"] == "Lake Street Dive"
# --- _with_retry: transient MusicBrainz failures (SSL EOF, 503 rate-limit) ---
def test_pick_release_prefers_original_official_over_later_reissue():
# Real "Hybrid Theory" bug: MB's arbitrary releases[0] was a 2023 13-track reissue; the 2000
# original is what sources deliver, so completeness checks must reference it.
releases = [
{"id": "reissue", "status": "Official", "date": "2023-06-01"},
{"id": "original", "status": "Official", "date": "2000-10-24"},
{"id": "deluxe", "status": "Official", "date": "2020-10-09"},
]
assert _pick_release(releases)["id"] == "original"
def test_pick_release_skips_non_official_and_undated():
releases = [
{"id": "promo", "status": "Promotion", "date": "1999-01-01"}, # earliest but not official
{"id": "undated", "status": "Official", "date": ""},
{"id": "official", "status": "Official", "date": "2001-03-03"},
]
assert _pick_release(releases)["id"] == "official"
def test_pick_release_empty_is_none():
assert _pick_release([]) is None
def test_retry_returns_value_without_backoff_when_first_call_succeeds():
sleeps: list[float] = []
calls = {"n": 0}
def fn():
calls["n"] += 1
return "ok"
assert _with_retry(fn, sleep=sleeps.append) == "ok"
assert calls["n"] == 1
assert sleeps == [] # no backoff when nothing failed
def test_retry_recovers_from_transient_network_error():
# The real symptom: <urlopen error [SSL: UNEXPECTED_EOF_WHILE_READING]> arrives
# as musicbrainzngs.NetworkError. A couple of retries clears it.
sleeps: list[float] = []
calls = {"n": 0}
def fn():
calls["n"] += 1
if calls["n"] < 3:
raise musicbrainzngs.NetworkError("SSL: UNEXPECTED_EOF_WHILE_READING")
return "recovered"
assert _with_retry(fn, sleep=sleeps.append) == "recovered"
assert calls["n"] == 3
assert sleeps == [1.0, 2.0] # exponential backoff before each retry
def test_retry_recovers_from_transient_response_error():
# MusicBrainz's own 503 rate-limit surfaces as ResponseError.
calls = {"n": 0}
def fn():
calls["n"] += 1
if calls["n"] < 2:
raise musicbrainzngs.ResponseError("503 Service Unavailable")
return "ok"
assert _with_retry(fn, sleep=lambda _: None) == "ok"
assert calls["n"] == 2
def test_retry_reraises_after_exhausting_attempts():
sleeps: list[float] = []
def fn():
raise musicbrainzngs.NetworkError("still down")
with pytest.raises(musicbrainzngs.NetworkError):
_with_retry(fn, attempts=4, sleep=sleeps.append)
assert sleeps == [1.0, 2.0, 4.0] # 3 backoffs across 4 attempts
def test_retry_does_not_swallow_non_transient_errors():
calls = {"n": 0}
def fn():
calls["n"] += 1
raise ValueError("bug, not a network blip")
with pytest.raises(ValueError):
_with_retry(fn, sleep=lambda _: None)
assert calls["n"] == 1 # non-transient errors propagate immediately, no retry
+31 -1
View File
@@ -1,4 +1,34 @@
from lyra_worker._mutagen import plan_track_names
from lyra_worker._mutagen import _clean_extra_title, plan_track_names
def test_clean_extra_title_strips_number_and_artist_prefix():
assert _clean_extra_title(
"13. John Mayer - St. Patrick's Day (Album Version)", "John Mayer"
) == "St. Patrick's Day (Album Version)"
def test_clean_extra_title_strips_number_without_artist_prefix():
assert _clean_extra_title("13. St. Patrick's Day", "John Mayer") == "St. Patrick's Day"
def test_clean_extra_title_leaves_plain_title_and_numeric_titles():
assert _clean_extra_title("Hidden Track", "John Mayer") == "Hidden Track"
# "99 Luftballons" has no delimiter after the number -> not a track-number prefix
assert _clean_extra_title("99 Luftballons", "Nena") == "99 Luftballons"
def test_plan_canonicalizes_bonus_track_beyond_tracklist():
names = [f"{i:02d} Track{i}.flac" for i in range(1, 13)] + [
"13. John Mayer - St. Patrick's Day (Album Version).flac"
]
tracklist = tuple(f"Track{i}" for i in range(1, 13))
plan = plan_track_names(names, tracklist, artist="John Mayer")
assert plan[12] == (
"13. John Mayer - St. Patrick's Day (Album Version).flac",
"13 St. Patrick's Day (Album Version).flac",
"St. Patrick's Day (Album Version)",
13,
)
def test_plan_uses_tracklist_titles_and_numbers():
+27
View File
@@ -195,6 +195,33 @@ def test_below_threshold_candidates_are_persisted_for_diagnosis(conn):
assert cur.fetchone()[0] >= 1 # found-but-rejected candidate retained for diagnosis
def test_searches_run_concurrently_across_adapters(conn):
# Two sources that each take ~0.5s to search must run in parallel: total match time is ~the
# slowest single search (~0.5s), not the sum (~1.0s). Also verifies concurrency drops no
# candidates — both sources' contributions are persisted.
import time as _t
class _SlowQobuz(FakeQobuz):
def search(self, target):
_t.sleep(0.5)
return super().search(target)
class _SlowSoulseek(FakeSoulseek):
def search(self, target):
_t.sleep(0.5)
return super().search(target)
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
start = _t.monotonic()
run_pipeline(conn, job_id, [_SlowQobuz(), _SlowSoulseek()], dest_root="/tmp/lib")
elapsed = _t.monotonic() - start
assert elapsed < 0.9 # concurrent (~0.5s), not sequential (~1.0s)
with conn.cursor() as cur:
cur.execute('SELECT count(DISTINCT source) FROM "Candidate" WHERE "jobId" = %s', (job_id,))
assert cur.fetchone()[0] == 2 # both adapters contributed candidates
def test_duplicate_adapter_names_raise(conn):
import pytest
from lyra_worker.adapters.fakes import FakeQobuz
+179
View File
@@ -0,0 +1,179 @@
from datetime import date
from lyra_worker.qobuz_gate import (
QobuzPacing,
today_cap,
is_allowed,
next_gap_seconds,
gate_open,
record_download,
)
# ---- QobuzPacing.from_config -------------------------------------------------
def test_from_config_defaults():
p = QobuzPacing.from_config({})
assert p.enabled is True
assert p.active_start == 8 and p.active_end == 23
assert p.daily_cap == 40
assert p.warmup_start_date == ""
def test_from_config_reads_values():
p = QobuzPacing.from_config({
"qobuz.pacing.enabled": "false",
"qobuz.pacing.activeStartHour": "9",
"qobuz.pacing.activeEndHour": "22",
"qobuz.pacing.dailyCap": "25",
"qobuz.pacing.warmupStartDate": "2026-07-15",
})
assert p.enabled is False
assert p.active_start == 9 and p.active_end == 22
assert p.daily_cap == 25
assert p.warmup_start_date == "2026-07-15"
# ---- today_cap (warm-up ramp) ------------------------------------------------
def test_today_cap_no_warmup_uses_steady():
p = QobuzPacing(daily_cap=40, warmup_start_date="")
assert today_cap(p, date(2026, 7, 20)) == 40
def test_today_cap_ramp_boundaries():
p = QobuzPacing(daily_cap=40, warmup_start_date="2026-07-15")
assert today_cap(p, date(2026, 7, 15)) == 5 # day 0
assert today_cap(p, date(2026, 7, 17)) == 5 # day 2
assert today_cap(p, date(2026, 7, 18)) == 10 # day 3
assert today_cap(p, date(2026, 7, 21)) == 10 # day 6
assert today_cap(p, date(2026, 7, 22)) == 20 # day 7
assert today_cap(p, date(2026, 7, 28)) == 20 # day 13
assert today_cap(p, date(2026, 7, 29)) == 40 # day 14
assert today_cap(p, date(2026, 8, 30)) == 40 # long after
def test_today_cap_steady_below_ramp_step_is_the_ceiling():
# A small steady cap is never exceeded by a ramp step.
p = QobuzPacing(daily_cap=3, warmup_start_date="2026-07-15")
assert today_cap(p, date(2026, 7, 29)) == 3
# ---- is_allowed (the gate decision) -----------------------------------------
_P = QobuzPacing(enabled=True, active_start=8, active_end=23, daily_cap=40)
def test_disabled_always_allows():
p = QobuzPacing(enabled=False, active_start=8, active_end=23)
assert is_allowed(p, now_hour=3, count_today=999, cap=0, now_epoch=0, next_allowed_epoch=1e18) is True
def test_blocked_outside_active_hours():
assert is_allowed(_P, now_hour=7, count_today=0, cap=40, now_epoch=100, next_allowed_epoch=0) is False
assert is_allowed(_P, now_hour=23, count_today=0, cap=40, now_epoch=100, next_allowed_epoch=0) is False
def test_blocked_at_or_over_cap():
assert is_allowed(_P, now_hour=10, count_today=40, cap=40, now_epoch=100, next_allowed_epoch=0) is False
assert is_allowed(_P, now_hour=10, count_today=41, cap=40, now_epoch=100, next_allowed_epoch=0) is False
def test_blocked_before_next_allowed():
assert is_allowed(_P, now_hour=10, count_today=0, cap=40, now_epoch=100, next_allowed_epoch=200) is False
def test_allowed_when_all_conditions_met():
assert is_allowed(_P, now_hour=10, count_today=5, cap=40, now_epoch=300, next_allowed_epoch=200) is True
# ---- next_gap_seconds (spread + jitter) -------------------------------------
def test_next_gap_spreads_cap_across_active_window():
# 15h window (8..23) / cap 40 = 1350s base; ±30%; midpoint rand -> base.
p = QobuzPacing(active_start=8, active_end=23, daily_cap=40)
assert abs(next_gap_seconds(p, cap=40, rand01=0.5) - 1350.0) < 1.0
assert abs(next_gap_seconds(p, cap=40, rand01=0.0) - 1350.0 * 0.7) < 1.0 # low end
assert abs(next_gap_seconds(p, cap=40, rand01=1.0) - 1350.0 * 1.3) < 1.0 # high end
def test_next_gap_has_a_floor():
# A huge cap would give a tiny gap; a 60s floor protects the request rate.
p = QobuzPacing(active_start=8, active_end=23, daily_cap=100000)
assert next_gap_seconds(p, cap=100000, rand01=0.0) == 60.0
# ---- DB helpers: gate_open / record_download --------------------------------
def _set(conn, key, value):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Config"(key,value,secret,"updatedAt") VALUES (%s,%s,false,now()) '
'ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, "updatedAt"=now()',
(key, value),
)
conn.commit()
def _get(conn, key):
with conn.cursor() as cur:
cur.execute('SELECT value FROM "Config" WHERE key=%s', (key,))
row = cur.fetchone()
return row[0] if row else None
def test_record_download_increments_and_sets_next_allowed(conn):
_set(conn, "qobuz.pacing.dailyCap", "40")
record_download(conn, rand01=0.5)
assert _get(conn, "qobuz.pacing.countToday") == "1"
assert _get(conn, "qobuz.pacing.countDay") is not None
assert _get(conn, "qobuz.pacing.nextAllowedAt") is not None
record_download(conn, rand01=0.5)
assert _get(conn, "qobuz.pacing.countToday") == "2"
def test_record_download_resets_counter_on_new_day(conn):
# A stale count from a previous day must reset to 1, not accumulate.
_set(conn, "qobuz.pacing.dailyCap", "40")
_set(conn, "qobuz.pacing.countDay", "2000-01-01")
_set(conn, "qobuz.pacing.countToday", "39")
record_download(conn, rand01=0.5)
assert _get(conn, "qobuz.pacing.countToday") == "1"
def test_gate_open_with_always_open_window(conn):
# An all-day window (0..24) removes the time-of-day dependency: no prior downloads and no
# next-allowed set → the gate is open regardless of when the test runs.
_set(conn, "qobuz.pacing.activeStartHour", "0")
_set(conn, "qobuz.pacing.activeEndHour", "24")
assert gate_open(conn) is True
def test_gate_closed_by_empty_active_window(conn):
# start == end is an empty window (h >= 5 AND h < 5 is never true) → always closed.
_set(conn, "qobuz.pacing.activeStartHour", "5")
_set(conn, "qobuz.pacing.activeEndHour", "5")
assert gate_open(conn) is False
def test_gate_closed_when_cap_reached_today(conn):
_set(conn, "qobuz.pacing.activeStartHour", "0")
_set(conn, "qobuz.pacing.activeEndHour", "24")
_set(conn, "qobuz.pacing.dailyCap", "3")
# Simulate 3 downloads already today.
with conn.cursor() as cur:
cur.execute("SELECT current_date::text")
today = cur.fetchone()[0]
_set(conn, "qobuz.pacing.countDay", today)
_set(conn, "qobuz.pacing.countToday", "3")
assert gate_open(conn) is False
def test_gate_ignores_stale_count_from_a_previous_day(conn):
# A high count from yesterday must not keep the gate closed today.
_set(conn, "qobuz.pacing.activeStartHour", "0")
_set(conn, "qobuz.pacing.activeEndHour", "24")
_set(conn, "qobuz.pacing.dailyCap", "3")
_set(conn, "qobuz.pacing.countDay", "2000-01-01")
_set(conn, "qobuz.pacing.countToday", "99")
assert gate_open(conn) is True
+85
View File
@@ -0,0 +1,85 @@
"""The Qobuz budget gate, wired into the pipeline: when the gate is closed the job falls through
to another source; when open it uses Qobuz and records the download; a Qobuz-only setup is never
gated (nothing to fall through to)."""
from lyra_worker.adapters.fakes import FakeQobuz, FakeSoulseek
from lyra_worker.claim import claim_next
from lyra_worker.pipeline import run_pipeline
from tests.conftest import insert_request
def _cfg(conn, key, value):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Config"(key,value,secret,"updatedAt") VALUES (%s,%s,false,now()) '
'ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, "updatedAt"=now()',
(key, value),
)
conn.commit()
def _open_gate(conn):
_cfg(conn, "qobuz.pacing.activeStartHour", "0")
_cfg(conn, "qobuz.pacing.activeEndHour", "24")
def _closed_gate(conn):
_cfg(conn, "qobuz.pacing.activeStartHour", "5") # empty window (5..5) → always closed
_cfg(conn, "qobuz.pacing.activeEndHour", "5")
def _state(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT state FROM "Job" WHERE id = %s', (job_id,))
return cur.fetchone()[0]
def _chosen(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
row = cur.fetchone()
return row[0] if row else None
def _cfg_get(conn, key):
with conn.cursor() as cur:
cur.execute('SELECT value FROM "Config" WHERE key = %s', (key,))
row = cur.fetchone()
return row[0] if row else None
def test_closed_gate_drops_qobuz_and_falls_through(conn):
_closed_gate(conn)
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [FakeQobuz(), FakeSoulseek()], dest_root="/tmp/lib-gate")
assert _state(conn, job_id) == "imported"
assert _chosen(conn, job_id) == "soulseek" # Qobuz dropped by the closed gate
def test_open_gate_uses_qobuz_and_records_the_download(conn):
_open_gate(conn)
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [FakeQobuz(), FakeSoulseek()], dest_root="/tmp/lib-gate")
assert _state(conn, job_id) == "imported"
assert _chosen(conn, job_id) == "qobuz"
assert _cfg_get(conn, "qobuz.pacing.countToday") == "1" # recorded against today's budget
assert _cfg_get(conn, "qobuz.pacing.nextAllowedAt") is not None
def test_qobuz_only_setup_is_never_gated(conn):
_closed_gate(conn)
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [FakeQobuz()], dest_root="/tmp/lib-gate")
assert _state(conn, job_id) == "imported"
assert _chosen(conn, job_id) == "qobuz" # not dropped — nothing to fall through to
def test_non_qobuz_download_does_not_touch_the_counter(conn):
_closed_gate(conn) # forces Soulseek
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [FakeQobuz(), FakeSoulseek()], dest_root="/tmp/lib-gate")
assert _chosen(conn, job_id) == "soulseek"
assert _cfg_get(conn, "qobuz.pacing.countToday") is None # only Qobuz downloads count
+69
View File
@@ -0,0 +1,69 @@
from lyra_worker.claim import claim_next, requeue_or_fail
from tests.conftest import insert_request
def _set_attempts(conn, job_id, attempts, state="matching", stage="match"):
with conn.cursor() as cur:
cur.execute(
'UPDATE "Job" SET attempts = %s, state = %s, "currentStage" = %s, "claimedAt" = now() '
'WHERE id = %s',
(attempts, state, stage, job_id),
)
conn.commit()
def _job(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT state, "currentStage", "claimedAt", error FROM "Job" WHERE id = %s', (job_id,))
return cur.fetchone()
def _request_status(conn, job_id):
with conn.cursor() as cur:
cur.execute(
'SELECT status FROM "Request" WHERE id = (SELECT "requestId" FROM "Job" WHERE id = %s)',
(job_id,),
)
return cur.fetchone()[0]
def test_below_cap_requeues_to_requested(conn):
# A transient failure (e.g. MB SSL EOF) mid-'matching' must not strand the job — it returns
# to 'requested' so claim_next picks it up again instead of orphaning it.
jid = insert_request(conn)
_set_attempts(conn, jid, 2)
requeue_or_fail(conn, jid, "SSL: UNEXPECTED_EOF_WHILE_READING")
st, stage, claimed_at, err = _job(conn, jid)
assert st == "requested"
assert stage == "intake"
assert claimed_at is None
assert err is None
assert _request_status(conn, jid) == "pending"
assert claim_next(conn) == jid # immediately re-claimable
def test_at_cap_marks_needs_attention_with_error(conn):
jid = insert_request(conn)
_set_attempts(conn, jid, 5) # hit the cap
requeue_or_fail(conn, jid, "SSL: UNEXPECTED_EOF_WHILE_READING")
st, _stage, claimed_at, err = _job(conn, jid)
assert st == "needs_attention"
assert err == "SSL: UNEXPECTED_EOF_WHILE_READING"
assert claimed_at is None
assert _request_status(conn, jid) == "needs_attention"
assert claim_next(conn) is None # not re-claimed
def test_requeues_until_cap_then_fails(conn):
jid = insert_request(conn)
for attempts in (1, 2, 3, 4):
_set_attempts(conn, jid, attempts)
requeue_or_fail(conn, jid, "boom")
assert _job(conn, jid)[0] == "requested"
_set_attempts(conn, jid, 5)
requeue_or_fail(conn, jid, "boom")
assert _job(conn, jid)[0] == "needs_attention"
def test_missing_job_is_noop(conn):
requeue_or_fail(conn, "does-not-exist", "boom") # must not raise
+218
View File
@@ -0,0 +1,218 @@
"""slskd download behaviour: abandoned transfers are cancelled (no pile-up), a stalled peer is
dropped fast, a slow-but-progressing peer is kept, and search candidates are ranked fast/free
peers first."""
import json
import pytest
import lyra_worker.adapters._slskd as slskd_mod
from lyra_worker.adapters._slskd import (
_ETA_CAP_SECONDS,
_MAX_XFER_POLLS,
_MIN_XFER_POLLS,
_POLL_SECONDS,
SlskdClient,
_eta_seconds,
_max_polls,
_parse_search_responses,
)
class _Resp:
def __init__(self, payload=None):
self._p = payload if payload is not None else {}
def raise_for_status(self):
pass
def json(self):
return self._p
class _SeqRequests:
"""post OK; get() returns successive poll payloads (last one repeats); records DELETEs."""
def __init__(self, polls):
self._polls = list(polls)
self._i = 0
self.deleted = []
def post(self, url, **kw):
return _Resp({})
def get(self, url, **kw):
p = self._polls[min(self._i, len(self._polls) - 1)]
self._i += 1
return _Resp(p)
def delete(self, url, **kw):
self.deleted.append(url)
return _Resp({})
def _poll(state, bytes_transferred, fid="t1"):
return {"directories": [{"files": [
{"filename": "f1.flac", "size": 100, "state": state,
"bytesTransferred": bytes_transferred, "id": fid},
]}]}
def _client(tmp_path):
return SlskdClient({"slskd.url": "http://x", "slskd.api_key": "k",
"slskd.downloadsDir": str(tmp_path)})
_REF = json.dumps({"username": "peerA", "files": [{"filename": "f1.flac", "size": 100}]})
def test_stalled_peer_is_abandoned_and_cancelled(tmp_path, monkeypatch):
# No byte progress across polls → stall → TimeoutError → the transfer is cancelled.
monkeypatch.setattr(slskd_mod, "requests", _SeqRequests([_poll("InProgress", 0)]))
monkeypatch.setattr(slskd_mod.time, "sleep", lambda _s: None)
monkeypatch.setattr(slskd_mod, "_STALL_POLLS", 3)
fake = slskd_mod.requests
with pytest.raises(TimeoutError):
_client(tmp_path).download(_REF, str(tmp_path / "dest"), lambda *_a: None)
assert any(u.endswith("/api/v0/transfers/downloads/peerA/t1") for u in fake.deleted)
def test_error_state_is_cancelled(tmp_path, monkeypatch):
monkeypatch.setattr(slskd_mod, "requests", _SeqRequests([_poll("Completed, Errored", 0)]))
monkeypatch.setattr(slskd_mod.time, "sleep", lambda _s: None)
fake = slskd_mod.requests
with pytest.raises(RuntimeError):
_client(tmp_path).download(_REF, str(tmp_path / "dest"), lambda *_a: None)
assert any(u.endswith("/peerA/t1") for u in fake.deleted)
def test_slow_but_progressing_peer_is_not_abandoned(tmp_path, monkeypatch):
# Bytes climb for more polls than _STALL_POLLS, then complete → success, not a premature stall.
(tmp_path / "01.flac").write_bytes(b"audio") # so _retrieve finds the finished file
ref = json.dumps({"username": "peerA", "files": [{"filename": r"d\Album\01.flac", "size": 5}]})
polls = [
{"directories": [{"files": [{"filename": r"d\Album\01.flac", "size": 5,
"state": "InProgress", "bytesTransferred": b, "id": "t1"}]}]}
for b in (10, 20, 30, 40, 50)
] + [
{"directories": [{"files": [{"filename": r"d\Album\01.flac", "size": 5,
"state": "Completed, Succeeded", "bytesTransferred": 5, "id": "t1"}]}]}
]
monkeypatch.setattr(slskd_mod, "requests", _SeqRequests(polls))
monkeypatch.setattr(slskd_mod.time, "sleep", lambda _s: None)
monkeypatch.setattr(slskd_mod, "_STALL_POLLS", 2) # would fire early if progress were ignored
out = _client(tmp_path).download(ref, str(tmp_path / "dest"), lambda *_a: None)
assert out["track_count"] == 1
assert slskd_mod.requests.deleted == [] # completed cleanly → nothing cancelled
def test_parse_ranks_free_and_fast_peers_first():
responses = [
{"username": "slow", "hasFreeUploadSlot": True, "uploadSpeed": 100, "queueLength": 0,
"files": [{"filename": r"a\Artist - Album\01.flac", "size": 5}]},
{"username": "fast", "hasFreeUploadSlot": True, "uploadSpeed": 9000, "queueLength": 0,
"files": [{"filename": r"b\Artist - Album\01.flac", "size": 5}]},
{"username": "queued", "hasFreeUploadSlot": False, "uploadSpeed": 99999, "queueLength": 40,
"files": [{"filename": r"c\Artist - Album\01.flac", "size": 5}]},
]
peers = [json.loads(c["source_ref"])["username"] for c in _parse_search_responses(responses)]
assert peers == ["fast", "slow", "queued"] # free+fast first; no-slot peer last despite speed
def test_parse_uses_requested_artist_when_present_in_path():
# Real "Rated R" MP3-over-FLAC bug: the artist is in a PARENT folder ("Rihanna\(2009) Rated
# R\…"), so the "Artist - Album" folder parse yields no artist and confidence tanks. When the
# requested artist is in the path, trust it so the candidate scores as Rihanna.
responses = [
{"username": "p", "hasFreeUploadSlot": True, "uploadSpeed": 1000, "queueLength": 0,
"files": [{"filename": r"@@x\Music\Rihanna\(2009) Rated R\01. Mad House.flac", "size": 5}]},
]
c = _parse_search_responses(responses, artist="Rihanna")[0]
assert c["artist"] == "Rihanna"
def test_parse_without_artist_falls_back_to_folder_parse():
responses = [
{"username": "p", "hasFreeUploadSlot": True, "uploadSpeed": 1000, "queueLength": 0,
"files": [{"filename": r"x\Some Band - Some Album\01.flac", "size": 5}]},
]
c = _parse_search_responses(responses)[0] # no artist arg → folder parse
assert c["artist"] == "Some Band"
def test_parse_prefers_shorter_transfer_over_raw_speed():
# The Hybrid Theory case: a peer advertising higher speed but serving a ~2x-larger rip (24-bit
# / bloated FLAC) should lose to a peer with lower speed but a smaller standard rip that
# finishes sooner — both are the same quality class to Lyra, so faster-to-finish wins.
responses = [
{"username": "bloated", "hasFreeUploadSlot": True, "uploadSpeed": 20000, "queueLength": 0,
"files": [{"filename": rf"x\LP - Album\{i:02}.flac", "size": 40_000_000} for i in range(12)]},
{"username": "lean", "hasFreeUploadSlot": True, "uploadSpeed": 15000, "queueLength": 0,
"files": [{"filename": rf"y\LP - Album\{i:02}.flac", "size": 24_000_000} for i in range(12)]},
]
# bloated est = 480MB/20000 = 24000s; lean est = 288MB/15000 = 19200s → lean finishes first
peers = [json.loads(c["source_ref"])["username"] for c in _parse_search_responses(responses)]
assert peers[0] == "lean"
def test_eta_seconds_from_measured_rate():
assert _eta_seconds(1000, 250, 250.0) == 3 # 750 bytes left / 250 B/s
assert _eta_seconds(1000, 0, 100.0) == 10
def test_eta_seconds_unknown_cases():
assert _eta_seconds(1000, 250, 0.0) is None # no speed sample yet
assert _eta_seconds(0, 0, 100.0) is None # unknown total size
assert _eta_seconds(1000, 1000, 100.0) is None # already complete
def test_max_polls_scales_backstop_to_album_size():
# A 521MB album (Drake "Scorpion", 25 FLACs) from a slow serial peer must get far more than
# the old flat 1200-poll/60min budget, or it gets abandoned mid-download and restarts from zero.
scorpion_bytes = 521_000_000
polls = _max_polls(scorpion_bytes)
assert polls > 1200 # more headroom than the old flat cap
assert polls * _POLL_SECONDS >= 90 * 60 # at least ~90 min of wall-clock budget
# A tiny single stays on the floor, not zero.
assert _max_polls(3_000_000) == _MIN_XFER_POLLS
assert _max_polls(0) == _MIN_XFER_POLLS
# An enormous set is clamped to the ceiling (bounds worst-case slot hold).
assert _max_polls(50_000_000_000) == _MAX_XFER_POLLS
# Monotonic: bigger album never gets a smaller budget.
assert _max_polls(200_000_000) <= _max_polls(400_000_000)
def test_eta_seconds_capped_when_peer_nearly_stalls():
# A near-stalled peer (tiny speed) would yield an ETA far past int4, overflowing the
# downloadEtaSeconds column write. The estimate must be clamped to a safe ceiling.
eta = _eta_seconds(30_000_000, 0, 0.001) # 30MB / 0.001 B/s ~= 3e10s uncapped
assert eta == _ETA_CAP_SECONDS
assert eta < 2_147_483_647 # fits in int4
def test_download_reports_shrinking_eta(tmp_path, monkeypatch):
# A 900-byte file arriving 300 bytes per 3s poll → measured speed 100 B/s → ETA counts down
# (6s, 3s) then None on completion. on_progress gets (fraction, eta_seconds).
(tmp_path / "01.flac").write_bytes(b"x" * 900) # so _retrieve finds the finished file
ref = json.dumps({"username": "peerA", "files": [{"filename": r"d\Album\01.flac", "size": 900}]})
polls = [
{"directories": [{"files": [{"filename": r"d\Album\01.flac", "size": 900,
"state": "InProgress", "bytesTransferred": b, "id": "t1"}]}]}
for b in (300, 600)
] + [
{"directories": [{"files": [{"filename": r"d\Album\01.flac", "size": 900,
"state": "Completed, Succeeded", "bytesTransferred": 900, "id": "t1"}]}]}
]
monkeypatch.setattr(slskd_mod, "requests", _SeqRequests(polls))
monkeypatch.setattr(slskd_mod.time, "sleep", lambda _s: None)
clock = iter(float(i) * 3 for i in range(100)) # 0,3,6,… deterministic monotonic time
monkeypatch.setattr(slskd_mod.time, "monotonic", lambda: next(clock))
calls: list = []
_client(tmp_path).download(ref, str(tmp_path / "dest"), lambda *a: calls.append(a))
etas = [a[1] for a in calls if len(a) > 1]
assert etas == [6, 3, None] # counts down at 100 B/s, then None once complete
+150
View File
@@ -0,0 +1,150 @@
"""Soulseek search fallback: the Soulseek server drops searches containing blocklisted terms
(major-label artists / album titles) → 0 responses. search_album retries with album-only then
artist-only, keeping only path-matching results so it never grabs a same-titled foreign album."""
import json
import lyra_worker.adapters._slskd as slskd_mod
from lyra_worker.adapters._slskd import (
SlskdClient,
_keep_matching,
_norm,
_path_matches,
)
# --- pure helpers -----------------------------------------------------------------------------
def test_norm_strips_diacritics_and_punctuation():
assert _norm("Beyoncé") == "beyonce"
assert _norm("I Am… Sasha Fierce") == "i am sasha fierce"
assert _norm("AC/DC") == "ac dc"
def test_path_matches_is_accent_and_punctuation_tolerant():
p = r"@@bckfj\All\Beyonce\I Am... Sasha Fierce CD1\03 Halo.flac"
assert _path_matches("Beyoncé", p) # accent folded
assert _path_matches("I Am… Sasha Fierce", p) # ellipsis / punctuation ignored
assert not _path_matches("Rihanna", p)
def test_path_matches_short_needle_requires_whole_string():
assert _path_matches("U2", r"x\U2 - Achtung Baby\01.flac")
assert not _path_matches("U2", r"x\Blur - 13\01.flac")
def test_keep_matching_drops_foreign_and_empty_responses():
responses = [
{"username": "a", "files": [{"filename": r"x\Rihanna - Unapologetic\01.flac"}]},
{"username": "b", "files": [{"filename": r"x\Someone Else - Unapologetic\01.flac"}]},
]
kept = _keep_matching(responses, "Rihanna")
assert [r["username"] for r in kept] == ["a"]
# --- fallback orchestration (HTTP faked) ------------------------------------------------------
class _Resp:
def __init__(self, payload):
self._p = payload
def raise_for_status(self):
pass
def json(self):
return self._p
class _FakeSearch:
"""Routes slskd search HTTP by URL: POST returns an id, GET on the search returns a completed
state, GET on /responses returns the canned responses for that query. Records queries sent."""
def __init__(self, by_query):
self.by_query = by_query # {searchText: [responses]}
self.queries = [] # searchText values POSTed, in order
self._id_query = {}
self._n = 0
def post(self, url, **kw):
text = kw["json"]["searchText"]
self.queries.append(text)
self._n += 1
sid = f"s{self._n}"
self._id_query[sid] = text
return _Resp({"id": sid})
def get(self, url, **kw):
if url.endswith("/responses"):
sid = url.rsplit("/", 2)[-2]
return _Resp(self.by_query.get(self._id_query[sid], []))
return _Resp({"state": "Completed, TimedOut"})
def _client():
return SlskdClient({"slskd.url": "http://x", "slskd.api_key": "k", "slskd.downloadsDir": "/x"})
def _install(monkeypatch, fake):
monkeypatch.setattr(slskd_mod, "requests", fake)
monkeypatch.setattr(slskd_mod.time, "sleep", lambda _s: None)
def test_primary_hit_does_not_trigger_fallback(monkeypatch):
fake = _FakeSearch({
"Rihanna Unapologetic": [
{"username": "a", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
"files": [{"filename": r"x\Rihanna - Unapologetic\01.flac", "size": 5}]},
],
})
_install(monkeypatch, fake)
out = _client().search_album("Rihanna", "Unapologetic")
assert len(out) == 1
assert fake.queries == ["Rihanna Unapologetic"] # no fallback searches
def test_album_only_fallback_recovers_blocklisted_artist(monkeypatch):
# Primary (artist blocked) → 0; album-only returns the artist's folder → recovered.
fake = _FakeSearch({
"Beyoncé I Am… Sasha Fierce": [], # blocked
"i am sasha fierce": [
{"username": "a", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
"files": [{"filename": r"x\Beyonce - I Am... Sasha Fierce\01.flac", "size": 5}]},
{"username": "b", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
"files": [{"filename": r"x\Other Artist - I Am Sasha Fierce\01.flac", "size": 5}]},
],
})
_install(monkeypatch, fake)
out = _client().search_album("Beyoncé", "I Am… Sasha Fierce")
assert fake.queries == ["Beyoncé I Am… Sasha Fierce", "i am sasha fierce"]
peers = [json.loads(c["source_ref"])["username"] for c in out]
assert peers == ["a"] # foreign same-titled album dropped
def test_artist_only_fallback_when_album_title_blocked(monkeypatch):
# Album-only also blocked/empty → fall through to artist-only, filtered by album in path.
fake = _FakeSearch({
"Foo Lemonade": [],
"lemonade": [], # album title itself blocked
"foo": [
{"username": "a", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
"files": [{"filename": r"x\Foo - Lemonade\01.flac", "size": 5}]},
],
})
_install(monkeypatch, fake)
out = _client().search_album("Foo", "Lemonade")
assert fake.queries == ["Foo Lemonade", "lemonade", "foo"]
assert len(out) == 1
def test_fallback_yields_nothing_when_title_too_generic(monkeypatch):
# 'Adele 21': artist blocked, album title '21' returns lots of non-Adele content → filtered
# to nothing rather than grabbing a wrong album.
fake = _FakeSearch({
"Adele 21": [],
"21": [
{"username": "z", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
"files": [{"filename": r"x\21 Savage - Savage Mode\01.flac", "size": 5}]},
],
"adele": [], # artist blocked
})
_install(monkeypatch, fake)
out = _client().search_album("Adele", "21")
assert out == []
@@ -0,0 +1,101 @@
"""MusicBrainz picks an arbitrary releases[0] that is often a deluxe/expanded edition with more
tracks (and a longer runtime) than the standard album a source legitimately delivers. Completeness
and the duration guard must be judged against what the CHOSEN SOURCE offered, not MB's canonical
edition — otherwise a complete standard-edition download is falsely rejected as 'incomplete'
(The Script "No Sound Without Silence": 11 delivered vs MB 12) or 'too short' (Skillet "Unleashed":
12 delivered ~40 min vs MB's 20-track deluxe ~73 min). A genuinely short download must still fail.
"""
import os
from dataclasses import replace
from lyra_worker.adapters.fakes import FakeQobuz
from lyra_worker.claim import claim_next
from lyra_worker.pipeline import run_pipeline
from lyra_worker.types import DownloadResult, MBTarget
from tests.conftest import insert_request
class _StandardEditionQobuz(FakeQobuz):
"""Qobuz-like source offering a fixed-size *standard* edition (source_tracks), independent of
MB's possibly-larger deluxe count; stages that many real (non-empty) files and reports them."""
def __init__(self, source_tracks):
super().__init__()
self._n = source_tracks
def search(self, target):
return super().search(replace(target, track_count=self._n))
def download(self, candidate, dest, on_progress):
os.makedirs(dest, exist_ok=True)
for i in range(candidate.track_count):
with open(os.path.join(dest, f"{i + 1:02d} Track.flac"), "wb") as fh:
fh.write(b"audio")
on_progress(1.0)
return DownloadResult(ok=True, path=dest, track_count=candidate.track_count)
class _PartialQobuz(FakeQobuz):
"""Promises `promised` tracks but one fails mid-download: stages and reports `promised - 1`
(the real John Mayer "Roll it on Home" IncompleteRead case)."""
def __init__(self, promised):
super().__init__()
self._n = promised
def search(self, target):
return super().search(replace(target, track_count=self._n))
def download(self, candidate, dest, on_progress):
os.makedirs(dest, exist_ok=True)
delivered = candidate.track_count - 1
for i in range(delivered):
with open(os.path.join(dest, f"{i + 1:02d} Track.flac"), "wb") as fh:
fh.write(b"audio")
return DownloadResult(ok=True, path=dest, track_count=delivered)
class _DeluxeResolver:
"""Resolver whose canonical release is a larger (deluxe) edition than the source delivers."""
def __init__(self, mb_tracks, mb_total_s):
self._t, self._s = mb_tracks, mb_total_s
def resolve(self, artist, album):
return MBTarget(artist=artist, album=album, track_count=self._t, total_duration_s=self._s)
def _state_error(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT state, error FROM "Job" WHERE id = %s', (job_id,))
return cur.fetchone()
def test_standard_edition_not_rejected_as_incomplete(conn):
# The Script: source delivers the 11-track standard edition; MB's canonical release is 12.
job_id = insert_request(conn, artist="The Script", album="No Sound Without Silence")
claim_next(conn)
run_pipeline(conn, job_id, [_StandardEditionQobuz(source_tracks=11)],
resolver=_DeluxeResolver(mb_tracks=12, mb_total_s=2943),
measure_duration=lambda _s: 2700.0, dest_root="/tmp/lib-se")
assert _state_error(conn, job_id) == ("imported", None)
def test_smaller_edition_not_rejected_as_too_short(conn):
# Skillet: source delivers the 12-track standard (~40 min); MB's release is a 20-track deluxe
# (~73 min). The unscaled duration guard falsely rejects; scaling to the delivered edition
# (12/20) must let it pass.
job_id = insert_request(conn, artist="Skillet", album="Unleashed")
claim_next(conn)
run_pipeline(conn, job_id, [_StandardEditionQobuz(source_tracks=12)],
resolver=_DeluxeResolver(mb_tracks=20, mb_total_s=4380),
measure_duration=lambda _s: 2400.0, dest_root="/tmp/lib-se")
assert _state_error(conn, job_id) == ("imported", None)
def test_genuinely_incomplete_still_rejected(conn):
# John Mayer: the source promised 12 tracks but one failed mid-download → only 11 land.
# This is a real partial and must still be rejected (the fix must not swallow it).
job_id = insert_request(conn, artist="John Mayer", album="The Search for Everything")
claim_next(conn)
run_pipeline(conn, job_id, [_PartialQobuz(promised=12)],
resolver=_DeluxeResolver(mb_tracks=12, mb_total_s=2580),
measure_duration=lambda _s: 2400.0, dest_root="/tmp/lib-se")
assert _state_error(conn, job_id) == ("needs_attention", "incomplete download")
+41
View File
@@ -0,0 +1,41 @@
from lyra_worker.adapters._streamrip import _ProgressAggregator
def _record():
seen: list[float] = []
return seen, seen.append
def test_equal_size_tracks_aggregate_monotonically_to_near_one():
# 4 tracks of 100 bytes each, all started up front (concurrency covers the album), then
# each downloaded in 25-byte chunks. Fraction must climb monotonically 0→~1.
seen, cb = _record()
agg = _ProgressAggregator(4, cb)
for _ in range(4):
agg.start_track(100)
for _ in range(16): # 16 chunks * 25 bytes = 400 bytes = full album
agg.advance(25)
assert seen == sorted(seen) # monotonic non-decreasing
assert all(0.0 <= f <= 0.99 for f in seen) # never negative, capped at 0.99
assert seen[-1] == 0.99 # reaches the cap when all bytes are in
assert seen[len(seen) // 2] > 0.3 # genuinely climbs mid-download (no 0→100 jump)
def test_estimate_uses_track_count_before_all_tracks_start():
# Only the first of 4 tracks has started; with a known track count the estimate already
# spans the whole album, so finishing track 1 reports ~1/4, not ~1/1.
seen, cb = _record()
agg = _ProgressAggregator(4, cb)
agg.start_track(100)
agg.advance(100) # track 1 fully downloaded
assert abs(seen[-1] - 0.25) < 0.01
def test_zero_or_missing_sizes_never_emit_and_never_divide_by_zero():
seen, cb = _record()
agg = _ProgressAggregator(0, cb)
agg.advance(50) # no track started yet -> no emit
agg.start_track(0) # a track with unknown size
agg.advance(50) # est_total still 0 -> still no emit, no crash
assert seen == []
+133
View File
@@ -0,0 +1,133 @@
"""Upgrade guard: when an album is already in the library and no currently-available source can
beat it (e.g. Qobuz is gated by pacing and only a same-quality Soulseek copy is left), the pipeline
must NOT waste a download — keep-if-better would discard it anyway. It should skip and let the
release be re-attempted later (when Qobuz frees up). A genuinely better source still upgrades."""
import os
from lyra_worker.claim import claim_next
from lyra_worker.pipeline import run_pipeline
from lyra_worker.types import Candidate, DownloadResult, Quality
_CD = Quality(fmt="FLAC", lossless=True, bit_depth=16, sample_rate=44100) # class 2
_HIRES = Quality(fmt="FLAC", lossless=True, bit_depth=24, sample_rate=96000) # class 3
def _cfg(conn, key, value):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "Config"(key,value,secret,"updatedAt") VALUES (%s,%s,false,now()) '
'ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, "updatedAt"=now()',
(key, value),
)
conn.commit()
class _Counting:
def __init__(self, name, tier, quality):
self.name, self.tier, self._q = name, tier, quality
self.calls = 0
self.searches = 0
def health(self):
return True
def search(self, target):
self.searches += 1
return [Candidate(source=self.name, source_ref=self.name, matched_artist=target.artist,
matched_album=target.album, quality=self._q, track_count=3, source_tier=self.tier)]
def download(self, candidate, dest, on_progress):
self.calls += 1
os.makedirs(dest, exist_ok=True)
for i in range(3):
with open(os.path.join(dest, f"0{i}.flac"), "wb") as fh:
fh.write(b"a")
return DownloadResult(ok=True, path=dest, track_count=3)
def _seed_library(conn, artist, album, quality_class, path):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "LibraryItem" (id, artist, album, path, source, format, "qualityClass", "importedAt") '
"VALUES (gen_random_uuid()::text, %s, %s, %s, 'soulseek', 'FLAC', %s, now())",
(artist, album, path, quality_class),
)
conn.commit()
def _insert_upgrade_job(conn, artist, album):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "MonitoredRelease" (id, "artistMbid", "artistName", "rgMbid", album, '
'monitored, state, "secondaryTypes", "createdAt") '
"VALUES (gen_random_uuid()::text, 'm', %s, 'rg', %s, true, 'grabbed', '{}', now()) RETURNING id",
(artist, album),
)
rel_id = cur.fetchone()[0]
cur.execute(
'INSERT INTO "Request" (id, artist, album, status, "monitoredReleaseId", "createdAt") '
"VALUES (gen_random_uuid()::text, %s, %s, 'pending', %s, now()) RETURNING id",
(artist, album, rel_id),
)
req = cur.fetchone()[0]
cur.execute(
'INSERT INTO "Job" (id, "requestId", state, "currentStage", attempts, "createdAt", "updatedAt") '
"VALUES (gen_random_uuid()::text, %s, 'requested', 'intake', 0, now(), now()) RETURNING id",
(req,),
)
job = cur.fetchone()[0]
conn.commit()
return job
def _lib(conn, artist, album):
with conn.cursor() as cur:
cur.execute('SELECT path, "qualityClass" FROM "LibraryItem" WHERE artist=%s AND album=%s', (artist, album))
return cur.fetchone()
def _state(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT state FROM "Job" WHERE id=%s', (job_id,))
return cur.fetchone()[0]
def test_upgrade_skipped_pre_search_when_qobuz_gated(conn):
# Qobuz gated (empty active window) + existing >= CD copy: only hi-res Qobuz could improve it,
# so defer WITHOUT even searching — avoids re-searching every owned album each monitor cycle.
_cfg(conn, "qobuz.pacing.activeStartHour", "5")
_cfg(conn, "qobuz.pacing.activeEndHour", "5") # empty window → gate closed
_seed_library(conn, "A", "B", 2, "/m/existing")
job_id = _insert_upgrade_job(conn, "A", "B")
claim_next(conn)
soulseek = _Counting("soulseek", 1, _CD)
run_pipeline(conn, job_id, [soulseek], dest_root="/tmp/lib-ug", upgrade_cutoff=3)
assert soulseek.searches == 0 # no wasted search
assert soulseek.calls == 0 # no wasted download
assert _lib(conn, "A", "B") == ("/m/existing", 2)
assert _state(conn, job_id) == "imported"
def test_upgrade_skipped_post_search_when_only_same_quality_found(conn):
# Gate OPEN, so it searches, but the only source found is same-quality → skip the download.
_cfg(conn, "qobuz.pacing.activeStartHour", "0")
_cfg(conn, "qobuz.pacing.activeEndHour", "24") # gate open
_seed_library(conn, "A", "B", 2, "/m/existing")
job_id = _insert_upgrade_job(conn, "A", "B")
claim_next(conn)
soulseek = _Counting("soulseek", 1, _CD)
run_pipeline(conn, job_id, [soulseek], dest_root="/tmp/lib-ug", upgrade_cutoff=3)
assert soulseek.searches == 1 # it did search (gate open)
assert soulseek.calls == 0 # but skipped the useless download
assert _lib(conn, "A", "B") == ("/m/existing", 2)
def test_upgrade_proceeds_when_a_better_source_is_available(conn):
# Existing class-2 copy; a class-3 (hi-res) source IS available → download + upgrade.
_seed_library(conn, "A", "B", 2, "/m/existing")
job_id = _insert_upgrade_job(conn, "A", "B")
claim_next(conn)
qobuz = _Counting("qobuz", 0, _HIRES)
run_pipeline(conn, job_id, [qobuz], dest_root="/tmp/lib-ug2", upgrade_cutoff=3)
assert qobuz.calls == 1 # downloaded the better copy
assert _lib(conn, "A", "B")[1] == 3 # upgraded to hi-res