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:
parent
5661ccbfd2
commit
3af3a1cd24
9 changed files with 401 additions and 21 deletions
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
91
core/video/retry.py
Normal 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"]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
69
tests/test_video_retry.py
Normal 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"
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) +
|
||||
|
|
|
|||
Loading…
Reference in a new issue