diff --git a/api/video/downloads.py b/api/video/downloads.py index 0ec4aa83..893c867d 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -260,6 +260,14 @@ def register_routes(bp): if not started.get("ok"): return jsonify({"ok": False, "error": started.get("error") or "slskd refused the download."}), 502 + import json as _json + from core.video.slskd_search import build_query + # The OTHER accepted results become the retry pool; the search context drives + # the alternate-query requery when the pool runs dry. + ctx = body.get("search_ctx") if isinstance(body.get("search_ctx"), dict) else {} + candidates = [c for c in (body.get("candidates") or []) if isinstance(c, dict) and c.get("filename") != filename] + first_query = build_query(ctx.get("scope") or body.get("kind") or "movie", ctx.get("title") or body.get("title"), + year=ctx.get("year"), season=ctx.get("season"), episode=ctx.get("episode")) dl_id = db.add_video_download({ "kind": str(body.get("kind") or "movie"), "title": body.get("title"), "release_title": body.get("release_title") or body.get("filename"), @@ -269,6 +277,9 @@ def register_routes(bp): "media_id": (str(body.get("media_id")) if body.get("media_id") is not None else None), "media_source": body.get("media_source"), "year": body.get("year"), "poster_url": body.get("poster_url"), + "candidates": _json.dumps(candidates), "search_ctx": _json.dumps(ctx), + "tried_queries": _json.dumps([first_query] if first_query else []), + "tried_files": _json.dumps([filename]), "attempts": 0, }) ensure_started(get_video_db) return jsonify({"ok": True, "id": dl_id}) diff --git a/core/video/download_monitor.py b/core/video/download_monitor.py index 26a7197a..34275054 100644 --- a/core/video/download_monitor.py +++ b/core/video/download_monitor.py @@ -13,6 +13,7 @@ Isolated: stdlib + the sibling video modules + shared config_manager; no music i from __future__ import annotations +import json import os import shutil import threading @@ -21,7 +22,13 @@ import time from utils.logging_config import get_logger from core.video.download_pipeline import dest_path_for, find_completed_file -from core.video.slskd_download import classify_state, find_transfer, list_downloads, progress_pct +from core.video.slskd_download import ( + classify_state, + find_transfer, + list_downloads, + progress_pct, + start_download, +) logger = get_logger("video.download_monitor") @@ -80,10 +87,146 @@ def _walk(root: str): _GIVE_UP_AFTER = 8 # consecutive 'transfer gone, no file' polls before failing it _misses: dict = {} # download id -> consecutive missing polls +_db_provider = None # set by ensure_started; used by the requery worker thread +_requerying: set = set() # download ids with a requery thread in flight + + +def _now(): + return time.strftime("%Y-%m-%d %H:%M:%S") + + +# ── auto-retry ──────────────────────────────────────────────────────────────── +def _apply_candidate(db, dl_id, row, cand, rest) -> bool: + """Start the next candidate download and flip the row back to 'downloading'. + Returns False if slskd refused to start it.""" + started = start_download(cand.get("username"), cand.get("filename"), cand.get("size_bytes") or 0) + if not started.get("ok"): + return False + tried = [] + try: + tried = json.loads(row.get("tried_files") or "[]") + except (ValueError, TypeError): + tried = [] + tried.append(cand.get("filename")) + db.update_video_download( + dl_id, status="downloading", progress=0, error=None, completed_at=None, + username=cand.get("username"), filename=cand.get("filename"), + release_title=cand.get("release_title") or cand.get("filename"), + size_bytes=int(cand.get("size_bytes") or 0), quality_label=cand.get("quality_label"), + candidates=json.dumps(rest), tried_files=json.dumps(tried), + attempts=int(row.get("attempts") or 0) + 1) + _misses.pop(dl_id, None) + return True + + +def _fail_or_retry(db, dl, error_msg) -> None: + """A download just failed/disappeared. Try the next candidate inline; if none, + hand off to a requery thread; if nothing left, mark it failed for real.""" + from core.video.retry import plan_retry + plan = plan_retry(dl) + if plan["action"] == "candidate" and _apply_candidate(db, dl["id"], dl, plan["candidate"], plan["rest"]): + return + if plan["action"] in ("candidate", "requery"): + db.update_video_download(dl["id"], status="searching", error=None) + _spawn_requery(dl["id"]) + return + db.update_video_download(dl["id"], status="failed", error=error_msg or "Download failed", + completed_at=_now()) + + +def _search_for_retry(query, max_seconds=22): + """A bounded blocking slskd search for the retry worker (shorter than the UI's).""" + from core.video.slskd_search import poll_search, start_search + res = start_search(query) + sid = res.get("id") + if not sid: + return {"hits": [], "total_files": 0} + deadline = time.monotonic() + max_seconds + last = {"hits": [], "total_files": 0} + while time.monotonic() < deadline: + last = poll_search(sid) + if len(last.get("hits") or []) >= 12: + break + time.sleep(1.5) + return last + + +def _requery_worker(dl_id) -> None: + from core.video.quality_eval import evaluate_release + from core.video.quality_profile import load as load_profile + from core.video.release_parse import parse_release + from core.video.retry import merge_candidates, plan_retry + try: + db = _db_provider() if _db_provider else None + if db is None: + return + profile = load_profile(db) + for _ in range(8): # hard loop cap on top of the attempt budget + row = db.get_video_download(dl_id) + if not row or row.get("status") != "searching": + return + plan = plan_retry(row) + if plan["action"] == "candidate": + if _apply_candidate(db, dl_id, row, plan["candidate"], plan["rest"]): + return + continue + if plan["action"] != "requery": + break + query, ctx = plan["query"], plan.get("ctx") or {} + # record the attempt + the query we're about to try + tq = [] + try: + tq = json.loads(row.get("tried_queries") or "[]") + except (ValueError, TypeError): + tq = [] + tq.append(query) + db.update_video_download(dl_id, tried_queries=json.dumps(tq), + attempts=int(row.get("attempts") or 0) + 1) + polled = _search_for_retry(query) + accepted = [] + for hit in (polled.get("hits") or []): + v = evaluate_release(parse_release(hit.get("title")), profile, + scope=ctx.get("scope") or "movie", + want_season=ctx.get("season"), want_episode=ctx.get("episode")) + if v["accepted"]: + accepted.append(hit) + row2 = db.get_video_download(dl_id) + if not row2 or row2.get("status") != "searching": + return + tried_files = [] + try: + tried_files = json.loads(row2.get("tried_files") or "[]") + except (ValueError, TypeError): + tried_files = [] + fresh = merge_candidates(accepted, tried_files) + if fresh and _apply_candidate(db, dl_id, row2, fresh[0], fresh[1:]): + return + # this query gave nothing usable → loop tries the next query (or fails) + db.update_video_download(dl_id, status="failed", + error="No working release found after retries", completed_at=_now()) + except Exception: + logger.exception("video download %s: requery worker failed", dl_id) + try: + if _db_provider: + _db_provider().update_video_download(dl_id, status="failed", + error="Retry error", completed_at=_now()) + except Exception: + logger.exception("video download %s: could not mark failed", dl_id) + finally: + _requerying.discard(dl_id) + + +def _spawn_requery(dl_id) -> None: + if dl_id in _requerying: + return + _requerying.add(dl_id) + threading.Thread(target=_requery_worker, args=(dl_id,), daemon=True, + name="video-dl-requery-%s" % dl_id).start() def _tick(db) -> None: - active = db.get_active_video_downloads() + # 'searching' rows are owned by their requery thread — skip them here. + active = [d for d in db.get_active_video_downloads() if d.get("status") != "searching"] if not active: _misses.clear() return @@ -97,25 +240,22 @@ def _tick(db) -> None: if not upd: continue if upd.get("_missing"): - # slskd no longer has the transfer and the file never appeared. Give it a - # few polls (a just-cancelled transfer vanishes), then mark it failed so it - # doesn't sit on 'downloading' forever. n = _misses.get(dl["id"], 0) + 1 _misses[dl["id"]] = n if n >= _GIVE_UP_AFTER: _misses.pop(dl["id"], None) - db.update_video_download(dl["id"], status="failed", - error="Soulseek transfer disappeared", - completed_at=time.strftime("%Y-%m-%d %H:%M:%S")) + _fail_or_retry(db, dl, "Soulseek transfer disappeared") continue _misses.pop(dl["id"], None) - if upd.get("status") in ("completed", "failed", "cancelled"): - upd.setdefault("completed_at", time.strftime("%Y-%m-%d %H:%M:%S")) + if upd.get("status") == "failed": + _fail_or_retry(db, dl, upd.get("error")) # auto-retry before truly failing + continue + if upd.get("status") in ("completed", "cancelled"): + upd.setdefault("completed_at", _now()) try: db.update_video_download(dl["id"], **upd) except Exception: logger.exception("video download %s: failed to persist update", dl.get("id")) - # drop miss counters for ids that are no longer active for k in [k for k in _misses if k not in live_ids]: _misses.pop(k, None) @@ -134,8 +274,9 @@ def _run(db_provider) -> None: def ensure_started(db_provider) -> None: """Start the monitor thread once (idempotent). Called when the first grab happens.""" - global _started + global _started, _db_provider with _lock: + _db_provider = db_provider if _started: return _started = True diff --git a/core/video/retry.py b/core/video/retry.py new file mode 100644 index 00000000..125dd3a1 --- /dev/null +++ b/core/video/retry.py @@ -0,0 +1,91 @@ +"""Auto-retry logic for video downloads (the music-style depth). + +When a grabbed release fails (transfer error / peer cancel / never lands), the engine +shouldn't just give up — it should try the NEXT-best candidate from the same search, +and when those run out, RE-SEARCH with a different query (e.g. a movie without its +year) and try those. This module is the pure decision engine; the monitor performs +the I/O (start the download / run the requery). + +Pure (json + stdlib only); unit-tested. Isolated — no music imports. +""" + +from __future__ import annotations + +import json +from typing import Any + +MAX_ATTEMPTS = 6 # total tries (candidate hops + requeries) before giving up + + +def next_query(ctx: dict, tried: Any) -> str | None: + """The next alternate slskd query to try for a search context, or None when + exhausted. Movie: 'Title Year' then 'Title'. TV keeps the SxxExx/Sxx identity but + offers a couple of numbering variants.""" + ctx = ctx or {} + triedset = set(tried or []) + scope = str(ctx.get("scope") or "movie").lower() + title = str(ctx.get("title") or "").strip() + cands = [] + if scope == "movie": + if ctx.get("year"): + cands.append(("%s %s" % (title, ctx["year"])).strip()) + cands.append(title) + elif scope == "episode" and ctx.get("season") is not None and ctx.get("episode") is not None: + s, e = int(ctx["season"]), int(ctx["episode"]) + cands.append("%s S%02dE%02d" % (title, s, e)) + cands.append("%s %dx%02d" % (title, s, e)) + elif scope == "season" and ctx.get("season") is not None: + s = int(ctx["season"]) + cands.append("%s S%02d" % (title, s)) + cands.append("%s Season %d" % (title, s)) + else: + cands.append(title) + for q in cands: + if q and q not in triedset: + return q + return None + + +def _loads(s, default): + try: + v = json.loads(s) if s else default + return v if v is not None else default + except (ValueError, TypeError): + return default + + +def plan_retry(row: dict, max_attempts: int = MAX_ATTEMPTS) -> dict: + """Decide what to do for a failed download row. Returns one of: + {action: 'candidate', candidate: {...}, rest: [...]} — try the next stored hit + {action: 'requery', query: str, ctx: {...}} — re-search a new query + {action: 'fail', reason: str} — genuinely out of options + Pure: reads the row's JSON columns (candidates / tried_files / search_ctx / tried_queries).""" + if int(row.get("attempts") or 0) >= max_attempts: + return {"action": "fail", "reason": "retry budget reached"} + tried_files = set(_loads(row.get("tried_files"), [])) + fresh = [c for c in _loads(row.get("candidates"), []) if c.get("filename") not in tried_files] + if fresh: + return {"action": "candidate", "candidate": fresh[0], "rest": fresh[1:]} + ctx = _loads(row.get("search_ctx"), {}) + q = next_query(ctx, _loads(row.get("tried_queries"), [])) + if q: + return {"action": "requery", "query": q, "ctx": ctx} + return {"action": "fail", "reason": "no candidates or queries left"} + + +def merge_candidates(new_accepted: Any, tried_files: Any) -> list: + """Turn fresh accepted search results into candidate dicts, dropping anything + already attempted (so a requery never re-tries the same failing release).""" + seen = set(tried_files or []) + out = [] + for r in (new_accepted or []): + fn = r.get("filename") + if not fn or fn in seen: + continue + seen.add(fn) + out.append({"username": r.get("username"), "filename": fn, "size_bytes": r.get("size_bytes"), + "quality_label": r.get("quality_label"), "release_title": r.get("title") or r.get("release_title")}) + return out + + +__all__ = ["MAX_ATTEMPTS", "next_query", "plan_retry", "merge_candidates"] diff --git a/database/video_database.py b/database/video_database.py index cec825d9..a690fac7 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -31,7 +31,7 @@ logger = get_logger("video_database") # Bump when video_schema.sql changes in a way worth recording. Stored in # PRAGMA user_version as a backstop indicator (nothing gates on it yet). -SCHEMA_VERSION = 15 +SCHEMA_VERSION = 16 _DEFAULT_DB_PATH = "database/video_library.db" _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" @@ -123,6 +123,12 @@ _COLUMN_MIGRATIONS = [ ("video_downloads", "media_source", "TEXT"), ("video_downloads", "year", "INTEGER"), ("video_downloads", "poster_url", "TEXT"), + # video_downloads — auto-retry state (remaining candidates + requery context). + ("video_downloads", "candidates", "TEXT"), + ("video_downloads", "search_ctx", "TEXT"), + ("video_downloads", "tried_queries", "TEXT"), + ("video_downloads", "tried_files", "TEXT"), + ("video_downloads", "attempts", "INTEGER"), ("movies", "tmdb_match_status", "TEXT"), ("movies", "tmdb_last_attempted", "TEXT"), ("shows", "tmdb_match_status", "TEXT"), @@ -1134,7 +1140,8 @@ class VideoDatabase: # ── video downloads (the grab → transfer pipeline) ──────────────────────── _DL_FIELDS = ("kind", "title", "release_title", "source", "username", "filename", "size_bytes", "quality_label", "target_dir", "status", - "media_id", "media_source", "year", "poster_url") + "media_id", "media_source", "year", "poster_url", + "candidates", "search_ctx", "tried_queries", "tried_files", "attempts") def add_video_download(self, rec: dict) -> int: """Insert a download row (status defaults to 'downloading'); returns its id.""" @@ -1176,7 +1183,7 @@ class VideoDatabase: conn = self._get_connection() try: rows = conn.execute( - "SELECT * FROM video_downloads WHERE status IN ('queued', 'downloading') ORDER BY id" + "SELECT * FROM video_downloads WHERE status IN ('queued', 'downloading', 'searching') ORDER BY id" ).fetchall() return [dict(r) for r in rows] finally: diff --git a/database/video_schema.sql b/database/video_schema.sql index 2f5cac71..a599d017 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -539,6 +539,11 @@ CREATE TABLE IF NOT EXISTS video_downloads ( status TEXT NOT NULL DEFAULT 'downloading', progress REAL DEFAULT 0, error TEXT, + candidates TEXT, -- JSON: remaining best-first hits to retry + search_ctx TEXT, -- JSON: {scope,title,year,season,episode} + tried_queries TEXT, -- JSON: slskd queries already searched + tried_files TEXT, -- JSON: release filenames already attempted + attempts INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), completed_at TEXT diff --git a/tests/test_video_download_pipeline.py b/tests/test_video_download_pipeline.py index 24885628..48a7f97d 100644 --- a/tests/test_video_download_pipeline.py +++ b/tests/test_video_download_pipeline.py @@ -154,6 +154,51 @@ def test_process_download_completed_but_file_not_settled(): assert upd == {"progress": 100.0} # no status change — retries next tick +def test_fail_or_retry_starts_next_candidate(tmp_path, monkeypatch): + import json + import core.video.download_monitor as mon + import core.video.slskd_download as slskd + from database.video_database import VideoDatabase + + monkeypatch.setattr(slskd, "start_download", lambda *a, **k: {"ok": True}) + monkeypatch.setattr(mon, "start_download", lambda *a, **k: {"ok": True}) + + db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) + dl_id = db.add_video_download({ + "kind": "movie", "title": "Dune", "release_title": "Dune.2021.1080p.x265-A", + "source": "soulseek", "username": "alice", "filename": r"@@a\A\dune.mkv", + "target_dir": "/m", "status": "downloading", "attempts": 0, + "tried_files": json.dumps([r"@@a\A\dune.mkv"]), + "candidates": json.dumps([{"username": "bob", "filename": r"@@b\B\dune.mkv", + "size_bytes": 9, "quality_label": "1080p", "release_title": "Dune.2021.1080p.x265-B"}]), + "search_ctx": json.dumps({"scope": "movie", "title": "Dune", "year": 2021}), + "tried_queries": json.dumps(["Dune 2021"]), + }) + row = db.get_video_download(dl_id) + mon._fail_or_retry(db, row, "Soulseek transfer Errored") + after = db.get_video_download(dl_id) + # rolled onto the next candidate (bob's release), back to downloading, attempt counted + assert after["status"] == "downloading" and after["username"] == "bob" + assert after["release_title"] == "Dune.2021.1080p.x265-B" and after["attempts"] == 1 + assert json.loads(after["candidates"]) == [] # pool consumed + + +def test_fail_or_retry_marks_failed_when_exhausted(tmp_path, monkeypatch): + import json + import core.video.download_monitor as mon + from database.video_database import VideoDatabase + db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) + dl_id = db.add_video_download({ + "kind": "movie", "title": "Dune", "source": "soulseek", "username": "a", + "filename": "x.mkv", "target_dir": "/m", "status": "downloading", "attempts": 0, + "candidates": "[]", "tried_files": json.dumps(["x.mkv"]), + "search_ctx": json.dumps({"scope": "movie", "title": "Dune"}), # no year → only one query + "tried_queries": json.dumps(["Dune"]), + }) + mon._fail_or_retry(db, db.get_video_download(dl_id), "boom") + assert db.get_video_download(dl_id)["status"] == "failed" + + def test_process_download_move_failure_marks_failed(): from core.video.download_monitor import process_download diff --git a/tests/test_video_retry.py b/tests/test_video_retry.py new file mode 100644 index 00000000..6f3f4ca3 --- /dev/null +++ b/tests/test_video_retry.py @@ -0,0 +1,69 @@ +"""Auto-retry decision engine — next_query / plan_retry / merge_candidates. Pure, +isolated from music.""" + +from __future__ import annotations + +import json + +from core.video.retry import MAX_ATTEMPTS, merge_candidates, next_query, plan_retry + + +def test_next_query_movie_drops_year_then_exhausts(): + ctx = {"scope": "movie", "title": "Dune", "year": 2021} + assert next_query(ctx, []) == "Dune 2021" + assert next_query(ctx, ["Dune 2021"]) == "Dune" # fall back to no-year + assert next_query(ctx, ["Dune 2021", "Dune"]) is None # exhausted + + +def test_next_query_tv_variants(): + ep = {"scope": "episode", "title": "The Wire", "season": 2, "episode": 5} + assert next_query(ep, []) == "The Wire S02E05" + assert next_query(ep, ["The Wire S02E05"]) == "The Wire 2x05" + se = {"scope": "season", "title": "The Wire", "season": 2} + assert next_query(se, []) == "The Wire S02" + assert next_query(se, ["The Wire S02"]) == "The Wire Season 2" + + +def _row(**kw): + base = {"attempts": 0, "candidates": "[]", "tried_files": "[]", + "search_ctx": json.dumps({"scope": "movie", "title": "Dune", "year": 2021}), + "tried_queries": json.dumps(["Dune 2021"])} + base.update(kw) + return base + + +def test_plan_retry_next_candidate_first(): + cands = [{"filename": "a.mkv", "username": "u"}, {"filename": "b.mkv", "username": "v"}] + plan = plan_retry(_row(candidates=json.dumps(cands))) + assert plan["action"] == "candidate" and plan["candidate"]["filename"] == "a.mkv" + assert [c["filename"] for c in plan["rest"]] == ["b.mkv"] + + +def test_plan_retry_skips_already_tried_candidate(): + cands = [{"filename": "a.mkv"}, {"filename": "b.mkv"}] + plan = plan_retry(_row(candidates=json.dumps(cands), tried_files=json.dumps(["a.mkv"]))) + assert plan["action"] == "candidate" and plan["candidate"]["filename"] == "b.mkv" + + +def test_plan_retry_requeries_when_candidates_exhausted(): + plan = plan_retry(_row(candidates="[]")) # no candidates, year-query already tried + assert plan["action"] == "requery" and plan["query"] == "Dune" # the no-year variant + + +def test_plan_retry_fails_when_everything_exhausted(): + plan = plan_retry(_row(candidates="[]", tried_queries=json.dumps(["Dune 2021", "Dune"]))) + assert plan["action"] == "fail" + + +def test_plan_retry_respects_budget(): + cands = [{"filename": "a.mkv"}] + plan = plan_retry(_row(candidates=json.dumps(cands), attempts=MAX_ATTEMPTS)) + assert plan["action"] == "fail" and "budget" in plan["reason"] + + +def test_merge_candidates_dedupes_against_tried(): + new = [{"filename": "a.mkv", "username": "u", "title": "Dune.2021.1080p"}, + {"filename": "b.mkv", "username": "v"}, {"filename": "a.mkv", "username": "w"}] + out = merge_candidates(new, ["b.mkv"]) + assert [c["filename"] for c in out] == ["a.mkv"] # b excluded (tried), dup a collapsed + assert out[0]["release_title"] == "Dune.2021.1080p" diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index ac3b0f51..0f8fa9c2 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -292,12 +292,19 @@ btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); btn.textContent = '…'; var container = panel.closest('[data-vgm-dl-content]'); var o = (container && (container._opts || container._dl)) || {}; + // the other accepted (live slskd) hits become the auto-retry pool + var pool = (panel._rows || []).filter(function (x) { return x.accepted && x.username && x.filename !== r.filename; }) + .map(function (x) { return { username: x.username, filename: x.filename, size_bytes: x.size_bytes, + quality_label: x.quality_label, title: x.title }; }); postJSON('/api/video/downloads/grab', { kind: p.scope || 'movie', title: p.title || '', release_title: r.title, source: 'soulseek', username: r.username, filename: r.filename, size_bytes: r.size_bytes, quality_label: r.quality_label, media_id: o.id || o.mediaId, media_source: o.source || o.mediaSource, - year: o.year, poster_url: o.poster + year: o.year, poster_url: o.poster, + candidates: pool, + search_ctx: { scope: p.scope || 'movie', title: p.title || '', year: o.year, + season: p.season != null ? p.season : null, episode: p.episode != null ? p.episode : null } }).then(function (res) { btn.classList.remove('vdl-res-grab--busy'); if (res && res.ok) { diff --git a/webui/static/video/video-downloads-page.js b/webui/static/video/video-downloads-page.js index 483f5c4b..d58a0620 100644 --- a/webui/static/video/video-downloads-page.js +++ b/webui/static/video/video-downloads-page.js @@ -35,11 +35,12 @@ var STATUS = { downloading: { label: 'Downloading', cls: 'active' }, queued: { label: 'Queued', cls: 'queued' }, + searching: { label: 'Searching', cls: 'active' }, // retrying — finding another release completed: { label: 'Completed', cls: 'completed' }, failed: { label: 'Failed', cls: 'failed' }, cancelled: { label: 'Cancelled', cls: 'cancelled' } }; - function isActive(s) { return s === 'downloading' || s === 'queued'; } + function isActive(s) { return s === 'downloading' || s === 'queued' || s === 'searching'; } function isFail(s) { return s === 'failed' || s === 'cancelled'; } function matches(s) { return _filter === 'all' || (_filter === 'active' && isActive(s)) || @@ -74,6 +75,7 @@ function patchCard(el, d) { var info = STATUS[d.status] || STATUS.downloading; var cls = info.cls, active = isActive(d.status); + var showBar = d.status === 'downloading' || d.status === 'queued'; var pct = Math.max(0, Math.min(100, d.progress || 0)); var q = function (f) { return el.querySelector('[data-f="' + f + '"]'); }; @@ -97,7 +99,8 @@ // meta: quality chip + a context line (release / size·user·pct / dest) var ctx; if (d.status === 'completed' && d.dest_path) ctx = '→ ' + d.dest_path; - else if (active) ctx = [fmtSize(d.size_bytes), d.username ? ('👤 ' + d.username) : '', Math.round(pct) + '%'].filter(Boolean).join(' · '); + else if (d.status === 'searching') ctx = 'Trying another release…'; + else if (showBar) ctx = [fmtSize(d.size_bytes), d.username ? ('👤 ' + d.username) : '', Math.round(pct) + '%'].filter(Boolean).join(' · '); else ctx = (d.release_title && d.release_title !== (d.title || '')) ? d.release_title : fmtSize(d.size_bytes); var chip = d.quality_label ? '' + esc(d.quality_label) + '' : ''; var metaHTML = chip + '' + esc(ctx) + ''; @@ -109,14 +112,15 @@ err.style.display = errTxt ? '' : 'none'; var bar = q('bar'); - bar.style.display = active ? '' : 'none'; - if (active) q('fill').style.width = pct + '%'; + bar.style.display = showBar ? '' : 'none'; + if (showBar) q('fill').style.width = pct + '%'; var st = q('status'); var stWant = 'adl-row-status ' + cls; if (st.className !== stWant) st.className = stWant; var dot = q('dot'); var dotWant = 'adl-status-dot ' + cls; if (dot.className !== dotWant) dot.className = dotWant; - var lab = q('label'); if (lab.textContent !== info.label) lab.textContent = info.label; + var labelTxt = info.label + (d.attempts > 1 ? ' · ' + d.attempts + 'x' : ''); + var lab = q('label'); if (lab.textContent !== labelTxt) lab.textContent = labelTxt; var act = q('actions'); var openBtn = d.media_id ? '