feat: track download progress on Job (migration + worker poller + API)

Add Job.downloadProgress (0.0-1.0), derived from a worker background
poller that counts audio files landing in staging / expected track
count, since streamrip only reports 0/1. No UI yet (slice B).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-13 23:23:15 +02:00
parent 52e74f7709
commit 9d5e1ff58a
7 changed files with 120 additions and 22 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Job" ADD COLUMN "downloadProgress" DOUBLE PRECISION NOT NULL DEFAULT 0;
+1
View File
@@ -53,6 +53,7 @@ model Job {
claimedAt DateTime? claimedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
downloadProgress Float @default(0)
candidates Candidate[] candidates Candidate[]
} }
+11 -1
View File
@@ -62,7 +62,15 @@ describe("requests API", () => {
data: { data: {
artist: "Chosen Artist", artist: "Chosen Artist",
album: "Chosen Album", album: "Chosen Album",
job: { create: { state: "downloading", currentStage: "download", attempts: 2, error: "boom" } }, job: {
create: {
state: "downloading",
currentStage: "download",
attempts: 2,
error: "boom",
downloadProgress: 0.42,
},
},
}, },
include: { job: true }, include: { job: true },
}); });
@@ -99,6 +107,7 @@ describe("requests API", () => {
expect(found.job.chosen).toEqual({ source: "qobuz", format: "flac", trackCount: 12 }); expect(found.job.chosen).toEqual({ source: "qobuz", format: "flac", trackCount: 12 });
expect(found.job.error).toBe("boom"); expect(found.job.error).toBe("boom");
expect(found.job.attempts).toBe(2); expect(found.job.attempts).toBe(2);
expect(found.job.downloadProgress).toBe(0.42);
}); });
it("reports no chosen candidate and zero count when a job has none", async () => { it("reports no chosen candidate and zero count when a job has none", async () => {
@@ -109,5 +118,6 @@ describe("requests API", () => {
const found = body.requests.find((r: { id: string }) => r.id === created.id); const found = body.requests.find((r: { id: string }) => r.id === created.id);
expect(found.job.candidateCount).toBe(0); expect(found.job.candidateCount).toBe(0);
expect(found.job.chosen).toBeNull(); expect(found.job.chosen).toBeNull();
expect(found.job.downloadProgress).toBe(0);
}); });
}); });
+1
View File
@@ -63,6 +63,7 @@ export async function GET() {
error: r.job.error, error: r.job.error,
claimedAt: r.job.claimedAt, claimedAt: r.job.claimedAt,
updatedAt: r.job.updatedAt, updatedAt: r.job.updatedAt,
downloadProgress: r.job.downloadProgress,
candidateCount: cands.length, candidateCount: cands.length,
chosen: chosen ? { source: chosen.source, format: chosen.format, trackCount: chosen.trackCount } : null, chosen: chosen ? { source: chosen.source, format: chosen.format, trackCount: chosen.trackCount } : null,
}; };
+53
View File
@@ -1,4 +1,6 @@
import os
import shutil import shutil
import threading
from dataclasses import replace from dataclasses import replace
from typing import Sequence from typing import Sequence
@@ -11,6 +13,39 @@ from lyra_worker.quality import quality_class
from lyra_worker.ranker import rank_candidates from lyra_worker.ranker import rank_candidates
from lyra_worker.types import Candidate, MBTarget from lyra_worker.types import Candidate, MBTarget
_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"}
def _count_staged_audio(staging: str) -> int:
"""Count audio files anywhere under the staging dir (streamrip nests them in a subfolder)."""
n = 0
for _root, _dirs, files in os.walk(staging):
for f in files:
if os.path.splitext(f)[1].lower() in _AUDIO_EXT:
n += 1
return n
def _poll_download_progress(job_id: str, staging: str, expected: int, stop: "threading.Event") -> None:
"""Until `stop`, periodically write Job.downloadProgress = files-in-staging / expected
(capped 0.99). 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():
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 _set_state(conn: psycopg.Connection, job_id: str, state: str, stage: str) -> None: def _set_state(conn: psycopg.Connection, job_id: str, state: str, stage: str) -> None:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -21,6 +56,12 @@ def _set_state(conn: psycopg.Connection, job_id: str, state: str, stage: str) ->
conn.commit() 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 _request_id(conn: psycopg.Connection, job_id: str) -> str: def _request_id(conn: psycopg.Connection, job_id: str) -> str:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT "requestId" FROM "Job" WHERE id = %s', (job_id,)) cur.execute('SELECT "requestId" FROM "Job" WHERE id = %s', (job_id,))
@@ -201,6 +242,14 @@ def run_pipeline(
staging = staging_dir(staging_root or f"{dest_root}/.staging", job_id) staging = staging_dir(staging_root or f"{dest_root}/.staging", job_id)
winner = None winner = None
result = None result = None
_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()
_poller = threading.Thread(
target=_poll_download_progress, args=(job_id, staging, expected, _stop), daemon=True
)
_poller.start()
try:
try: try:
for candidate in ranked: for candidate in ranked:
adapter = by_source.get(candidate.source) adapter = by_source.get(candidate.source)
@@ -212,9 +261,13 @@ def run_pipeline(
if result.ok: if result.ok:
winner = candidate winner = candidate
break break
finally:
_stop.set()
_poller.join(timeout=3)
if winner is None or result is None or not result.ok: if winner is None or result is None or not result.ok:
_fail(conn, job_id, "all downloads failed") _fail(conn, job_id, "all downloads failed")
return return
_set_download_progress(conn, job_id, 1.0) # download done — UI shows 100% into Finishing
# 5. verify completeness on the staging copy, then promote + tag # 5. verify completeness on the staging copy, then promote + tag
_set_state(conn, job_id, "tagging", "tag") _set_state(conn, job_id, "tagging", "tag")
+22
View File
@@ -0,0 +1,22 @@
import os
from lyra_worker.pipeline import _count_staged_audio
def test_count_staged_audio_counts_nested_audio_files_only(tmp_path):
nested = tmp_path / "Artist" / "Album"
nested.mkdir(parents=True)
(nested / "01.flac").write_bytes(b"")
(nested / "02.flac").write_bytes(b"")
(nested / "03.flac").write_bytes(b"")
(nested / "cover.jpg").write_bytes(b"")
assert _count_staged_audio(str(tmp_path)) == 3
def test_count_staged_audio_empty_dir_is_zero(tmp_path):
assert _count_staged_audio(str(tmp_path)) == 0
def test_count_staged_audio_missing_dir_is_zero():
assert _count_staged_audio("/nonexistent/path/for/sure") == 0
+9
View File
@@ -19,6 +19,12 @@ def _request_status(conn, job_id):
return cur.fetchone()[0] return cur.fetchone()[0]
def _download_progress(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT "downloadProgress" FROM "Job" WHERE id = %s', (job_id,))
return cur.fetchone()[0]
def test_success_picks_best_source_and_imports(conn): def test_success_picks_best_source_and_imports(conn):
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows") job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn) claim_next(conn)
@@ -26,6 +32,7 @@ def test_success_picks_best_source_and_imports(conn):
assert _job_state(conn, job_id) == ("imported", "import") assert _job_state(conn, job_id) == ("imported", "import")
assert _request_status(conn, job_id) == "completed" assert _request_status(conn, job_id) == "completed"
assert _download_progress(conn, job_id) == 1.0 # a successful download completes at 100%
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,)) cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
assert cur.fetchone()[0] == "qobuz" # highest quality won assert cur.fetchone()[0] == "qobuz" # highest quality won
@@ -41,6 +48,7 @@ def test_falls_through_when_best_download_fails(conn):
run_pipeline(conn, job_id, [FailingAdapter(), FakeSoulseek()], dest_root="/tmp/lib") run_pipeline(conn, job_id, [FailingAdapter(), FakeSoulseek()], dest_root="/tmp/lib")
assert _job_state(conn, job_id) == ("imported", "import") assert _job_state(conn, job_id) == ("imported", "import")
assert _download_progress(conn, job_id) == 1.0 # falls through, but still completes at 100%
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,)) cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
assert cur.fetchone()[0] == "soulseek" assert cur.fetchone()[0] == "soulseek"
@@ -75,6 +83,7 @@ def test_all_downloads_fail_goes_to_needs_attention(conn):
run_pipeline(conn, job_id, [FailingAdapter()], dest_root="/tmp/lib") run_pipeline(conn, job_id, [FailingAdapter()], dest_root="/tmp/lib")
assert _job_state(conn, job_id)[0] == "needs_attention" assert _job_state(conn, job_id)[0] == "needs_attention"
assert _download_progress(conn, job_id) == 0.0 # never reached 1.0 — all attempts failed
def test_incomplete_download_goes_to_needs_attention(conn): def test_incomplete_download_goes_to_needs_attention(conn):