67f374c470
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>
458 lines
21 KiB
Python
458 lines
21 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
|
|
|
|
|
|
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:
|
|
for candidate in ranked:
|
|
adapter = by_source.get(candidate.source)
|
|
if adapter is None:
|
|
continue
|
|
_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
|