Files
Lyra/worker/lyra_worker/pipeline.py
T
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

467 lines
22 KiB
Python

import os
import random
import shutil
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from typing import Callable, Sequence
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
_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
# 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
def _measure_staged_duration_s(staging: str) -> float | None:
"""Sum the playtime (seconds) of the non-empty staged audio via mutagen. Returns None if
nothing could be measured (no audio, or an unreadable container) so the caller skips the
check rather than failing a good download on a probe hiccup. Lazy mutagen import; not
exercised by the offline fakes (which stage no real audio)."""
try:
import mutagen
except Exception:
return None
total = 0.0
measured = False
for root, _dirs, files in os.walk(staging):
for f in files:
if os.path.splitext(f)[1].lower() not in _AUDIO_EXT:
continue
path = os.path.join(root, f)
try:
if os.path.getsize(path) == 0:
continue
audio = mutagen.File(path)
length = getattr(getattr(audio, "info", None), "length", None)
except Exception:
continue
if length:
total += float(length)
measured = True
return total if measured else None
def _count_staged_audio(staging: str) -> int:
"""Count NON-EMPTY audio files anywhere under the staging dir (streamrip nests them in a
subfolder). 0-byte files are skipped: streamrip leaves an empty placeholder when a track
download fails (e.g. Qobuz's >100-header responses), and a dead track must not count toward
completeness — otherwise a broken album imports as 'complete'."""
n = 0
for root, _dirs, files in os.walk(staging):
for f in files:
if os.path.splitext(f)[1].lower() in _AUDIO_EXT:
try:
if os.path.getsize(os.path.join(root, f)) > 0:
n += 1
except OSError:
pass
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
(capped 0.99) — the FALLBACK for adapters (streamrip/Qobuz) that report no byte-level
progress. Skips writing while `reports` is set, i.e. the in-flight adapter is driving
progress itself via on_progress (yt-dlp/slskd). Uses its own short-lived connection
(psycopg conns aren't shareable across threads). A missing DSN or expected<=0 just no-ops."""
dsn = os.environ.get("DATABASE_URL")
if not dsn or expected <= 0:
return
conn = psycopg.connect(dsn)
try:
while not stop.is_set():
if not reports.is_set(): # the adapter isn't reporting real progress → estimate
frac = min(_count_staged_audio(staging) / expected, 0.99)
with conn.cursor() as cur:
cur.execute('UPDATE "Job" SET "downloadProgress" = %s WHERE id = %s', (frac, job_id))
conn.commit()
stop.wait(1.5)
except Exception as e: # a progress poller must never affect the job
print(f"pipeline: download progress poller error: {e}", flush=True)
finally:
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(
'UPDATE "Job" SET state = %s, "currentStage" = %s, "updatedAt" = now() WHERE id = %s',
(state, stage, job_id),
)
conn.commit()
def _set_download_progress(conn: psycopg.Connection, job_id: str, frac: float) -> None:
with conn.cursor() as cur:
cur.execute('UPDATE "Job" SET "downloadProgress" = %s WHERE id = %s', (frac, 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`."""
state = {"frac": -1.0, "t": 0.0}
def on_progress(pct: float) -> 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:
state["frac"] = frac
state["t"] = now
try:
_set_download_progress(conn, job_id, frac)
except Exception as e: # a progress write must never fail the download
print(f"pipeline: on_progress write failed: {e}", flush=True)
return on_progress
def _request_id(conn: psycopg.Connection, job_id: str) -> str:
with conn.cursor() as cur:
cur.execute('SELECT "requestId" FROM "Job" WHERE id = %s', (job_id,))
return cur.fetchone()[0]
def _fail(conn: psycopg.Connection, job_id: str, reason: str) -> None:
with conn.cursor() as cur:
cur.execute(
'UPDATE "Job" SET state = \'needs_attention\', error = %s, "updatedAt" = now() WHERE id = %s',
(reason, job_id),
)
cur.execute(
'UPDATE "Request" SET status = \'needs_attention\' WHERE id = (SELECT "requestId" FROM "Job" WHERE id = %s)',
(job_id,),
)
conn.commit()
def _load_target(conn: psycopg.Connection, job_id: str) -> MBTarget:
with conn.cursor() as cur:
cur.execute(
'SELECT artist, album FROM "Request" WHERE id = (SELECT "requestId" FROM "Job" WHERE id = %s)',
(job_id,),
)
artist, album = cur.fetchone()
return MBTarget(artist=artist, album=album)
def _already_in_library(conn: psycopg.Connection, target: MBTarget) -> bool:
with conn.cursor() as cur:
cur.execute(
'SELECT 1 FROM "LibraryItem" WHERE artist = %s AND album = %s LIMIT 1',
(target.artist, target.album),
)
return cur.fetchone() is not None
def _is_upgrade_job(conn: psycopg.Connection, job_id: str) -> bool:
with conn.cursor() as cur:
cur.execute(
'SELECT "monitoredReleaseId" IS NOT NULL FROM "Request" '
'WHERE id = (SELECT "requestId" FROM "Job" WHERE id = %s)',
(job_id,),
)
row = cur.fetchone()
return bool(row and row[0])
def _is_force_job(conn: psycopg.Connection, job_id: str) -> bool:
"""A user-forced re-acquire (Library 'Replace / upgrade'): skip the already-in-library
dedupe so an owned album is re-downloaded. The import step still keeps the new copy only
if it's higher quality, so a forced upgrade can never downgrade what's on disk."""
with conn.cursor() as cur:
cur.execute(
'SELECT force FROM "Request" WHERE id = (SELECT "requestId" FROM "Job" WHERE id = %s)',
(job_id,),
)
row = cur.fetchone()
return bool(row and row[0])
def _already_in_library_at_cutoff(conn: psycopg.Connection, target: MBTarget, cutoff: int) -> bool:
with conn.cursor() as cur:
cur.execute(
'SELECT 1 FROM "LibraryItem" WHERE artist = %s AND album = %s '
'AND "qualityClass" >= %s LIMIT 1',
(target.artist, target.album, cutoff),
)
return cur.fetchone() is not None
def _library_quality(conn: psycopg.Connection, target: MBTarget) -> int | None:
with conn.cursor() as cur:
cur.execute(
'SELECT "qualityClass" FROM "LibraryItem" WHERE artist = %s AND album = %s',
(target.artist, target.album),
)
row = cur.fetchone()
return row[0] if row else None
def _persist_candidates(conn: psycopg.Connection, job_id: str, candidates: list[Candidate]) -> None:
with conn.cursor() as cur:
for c in candidates:
cur.execute(
'INSERT INTO "Candidate" (id, "jobId", source, format, "qualityClass", '
'"trackCount", confidence, "sourceRef", chosen, "createdAt") '
"VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s, %s, %s, false, now())",
(job_id, c.source, c.quality.fmt, quality_class(c.quality),
c.track_count, c.confidence, c.source_ref),
)
conn.commit()
def _mark_chosen(conn: psycopg.Connection, job_id: str, source_ref: str) -> None:
"""Mark exactly one candidate (the one now being downloaded) as chosen, clearing any
prior choice. Called at each download attempt so `chosen` reflects the source in flight."""
with conn.cursor() as cur:
cur.execute(
'UPDATE "Candidate" SET chosen = ("sourceRef" = %s) WHERE "jobId" = %s',
(source_ref, job_id),
)
conn.commit()
def _import(conn: psycopg.Connection, job_id: str, target: MBTarget,
winner: Candidate, path: str) -> None:
request_id = _request_id(conn, job_id)
track_names = list_audio_files(path) # the real files just imported into the album folder
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "LibraryItem" (id, "requestId", artist, album, path, source, '
'format, "qualityClass", "trackNames", "rgMbid", "artistMbid", "importedAt") '
"VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now()) "
'ON CONFLICT (artist, album) DO UPDATE SET '
' "requestId" = EXCLUDED."requestId", path = EXCLUDED.path, source = EXCLUDED.source, '
' format = EXCLUDED.format, "qualityClass" = EXCLUDED."qualityClass", '
' "trackNames" = EXCLUDED."trackNames", "rgMbid" = COALESCE(EXCLUDED."rgMbid", "LibraryItem"."rgMbid"), '
' "artistMbid" = COALESCE(EXCLUDED."artistMbid", "LibraryItem"."artistMbid"), "importedAt" = now() '
'WHERE EXCLUDED."qualityClass" > "LibraryItem"."qualityClass"',
(request_id, target.artist, target.album, path, winner.source,
winner.quality.fmt, quality_class(winner.quality), track_names,
target.rg_mbid or None, target.artist_mbid or None),
)
cur.execute(
"UPDATE \"Request\" SET status = 'completed' WHERE id = %s", (request_id,)
)
conn.commit()
def run_pipeline(
conn: psycopg.Connection,
job_id: str,
adapters: Sequence[SourceAdapter],
resolver=None,
tagger=None,
min_confidence: float = 0.7,
dest_root: str = "/music",
staging_root: str | None = None,
upgrade_cutoff: int | None = None,
measure_duration: Callable[[str], float | None] = _measure_staged_duration_s,
) -> None:
"""Real staged acquisition using source-agnostic adapters. Fakes in this plan."""
names = [a.name for a in adapters]
if len(names) != len(set(names)):
raise ValueError(f"adapter names must be unique, got {names}")
# 1. intake
_set_state(conn, job_id, "matching", "intake")
target = _load_target(conn, job_id)
if resolver is not None:
resolved = resolver.resolve(target.artist, target.album)
if resolved is not None:
target = resolved
# dedupe: a monitor-driven upgrade job proceeds unless the existing copy already meets
# the cutoff; a plain request short-circuits on any existing copy (slice-1 behavior). A
# user-forced re-acquire skips the dedupe entirely (keep-if-better still guards the import).
if _is_force_job(conn, job_id):
_dedupe_hit = False
elif upgrade_cutoff is not None and _is_upgrade_job(conn, job_id):
_dedupe_hit = _already_in_library_at_cutoff(conn, target, upgrade_cutoff)
else:
_dedupe_hit = _already_in_library(conn, target)
if _dedupe_hit:
_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()
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] = []
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)))
_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)
if not ranked:
_fail(conn, job_id, "no candidate above confidence threshold")
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
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()
_reports = threading.Event() # set while the in-flight adapter reports real byte progress
_poller = threading.Thread(
target=_poll_download_progress, args=(job_id, staging, expected, _stop, _reports), daemon=True
)
_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 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:
# 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. promote + tag the verified winner
_set_state(conn, job_id, "tagging", "tag")
final = album_dir(dest_root, target)
existing_q = _library_quality(conn, target)
new_q = quality_class(winner.quality)
_set_state(conn, job_id, "imported", "import")
if existing_q is None or new_q > existing_q:
import_album(staging, final) # clean audio(+cover) move, atomically replacing any prior copy
if tagger is not None:
try:
tagger.tag_album(final, target)
except Exception as e: # a tagging failure must not discard a good download
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()
finally:
shutil.rmtree(staging, ignore_errors=True) # a partial/failed download never persists