video downloads Phase C: auto-retry + alternate-query requery

When a grabbed release fails (transfer error / peer-cancel / never lands), the engine
now retries instead of giving up — the music-style depth:
- Grab stores the OTHER accepted results as a retry pool + the search context (schema
  v16: candidates / search_ctx / tried_queries / tried_files / attempts).
- core/video/retry.py (pure, tested): plan_retry() → try the next-best candidate; when
  the pool is dry, next_query() generates an ALTERNATE query (movie: drop the year; TV:
  numbering variants) to re-search; budget MAX_ATTEMPTS=6. merge_candidates dedupes
  against already-tried releases.
- Monitor: on failure, _fail_or_retry hops to the next candidate inline; if none, flips
  the row to a new 'searching' state and a background requery thread re-searches the
  alternate query, evaluates against the profile, and starts the best fresh hit — or
  marks failed once truly exhausted. 'searching' rows are owned by their thread.
- Page: 'Searching' status (Trying another release…) + a 'Nx' attempt badge.
16 tests (retry engine + candidate-retry transition); ruff clean on touched files.
This commit is contained in:
BoulderBadgeDad 2026-06-19 17:12:44 -07:00
parent 5661ccbfd2
commit 3af3a1cd24
9 changed files with 401 additions and 21 deletions

View file

@ -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})

View file

@ -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

91
core/video/retry.py Normal file
View file

@ -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"]

View file

@ -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:

View file

@ -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

View file

@ -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

69
tests/test_video_retry.py Normal file
View file

@ -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"

View file

@ -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) {

View file

@ -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 ? '<span class="vdpg-qchip">' + esc(d.quality_label) + '</span>' : '';
var metaHTML = chip + '<span class="vdpg-mctx' + (d.status === 'completed' && d.dest_path ? ' vdpg-dest' : '') + '">' + esc(ctx) + '</span>';
@ -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 ? '<button class="vdpg-open" type="button" data-vdpg-open="' + esc(d.media_id) +