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 ? '