OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / dd689e6

Job control: phase, source, cancel and an attempt history (0.21.0)
labs#13 part 1. The download manager needs to know what a job is doing, where its bytes come from, how to stop it, and what happened to every attempt. - _job_info (in memory, life of _run_job): phase waiting -> resolving -> downloading, source vidcore|hdhub, dest, title, start time. The persisted queue keeps its running/done/failed meaning; resume is untouched. - Cancel: per-job Event. _run_remux checks it every poll and kills ffmpeg; the file-lock and slot waits poll so a queued job cancels without waiting out the one ahead. RemuxCancelled never falls through to HdHub (that would fetch the title the user just stopped). .part kept. State "cancelled" is not resumed at boot and can be started again. - Seasons: position (k of M) + cancel. The episode's event is registered under the season lock before the cancel check, so a season Cancel cannot slip between picking an episode and it becoming cancellable. Cancelling one episode skips it; the season carries on. - _run_job now marks its own key running: season episodes never went through _start_mp4_job, so mid-season the web page offered to fetch them again. - ingest_history.jsonl: append-only, one line per finished attempt (outcome, source, bytes, took). Reader skips torn lines and never rewrites the file. - .gitignore: ingest_queue.json, ingest_history.jsonl, jellyfin.json. 20 tests (test_job_control.py) using a real process under the real _run_remux for the cancel path. Suite 390. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dd689e6aafb4d647cc23e37956516ea6b90bd64d
by plankatron <fleeky@gmail.com> · 2026-10-05T00:42:40.000Z

Changed files (3) +608 -27

.gitignore +5 -0
……@@ -15,3 +15,8 @@ vidcore_reputation.json
1515source_breaker.json
1616mp4-cache/
1717library_verified.json
18
19# local state / secrets
20ingest_queue.json
21ingest_history.jsonl
22jellyfin.json
addon.py +269 -27
……@@ -619,7 +619,7 @@ TV_GENRES = [
619619
620620MANIFEST = {
621621 "id": "org.tabertron.vsembed",
622 "version": "0.20.0",
622 "version": "0.21.0",
623623 "name": "Tabertron VsEmbed",
624624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
625625 "types": ["movie", "series"],
……@@ -1226,6 +1226,127 @@ def _dest_lock(dest: str) -> threading.Lock:
12261226 with _dest_locks_guard:
12271227 return _dest_locks.setdefault(os.path.abspath(dest), threading.Lock())
12281228
1229
1230# What a live job is doing, for the download manager (labs#13). In memory only,
1231# deliberately: the persisted queue keeps its running/done/failed semantics,
1232# which the resume path depends on, and a phase is only meaningful while the
1233# process that owns the job is alive. Entries exist for exactly the life of
1234# _run_job.
1235# phase: waiting -> resolving -> downloading
1236# source: vidcore | hdhub | None (None = nothing fetched, e.g. already held)
1237_job_info: dict[str, dict] = {}
1238_cancel_events: dict[str, threading.Event] = {}
1239
1240
1241def _job_update(key: str, **fields) -> None:
1242 """Annotate a live job. A no-op for a key with no live job, so
1243 _run_ingest stays callable on its own (as its tests call it)."""
1244 info = _job_info.get(key)
1245 if info is not None:
1246 info.update(fields)
1247
1248
1249def _acquire_or_cancel(lock, cancel: threading.Event,
1250 timeout: Optional[float] = None) -> bool:
1251 """Acquire `lock` (a Lock or Semaphore) unless `cancel` fires first.
1252
1253 Polls in short slices because neither primitive can wait on an Event too.
1254 Returns False if cancelled or timed out, and then holds nothing.
1255 """
1256 deadline = None if timeout is None else time.time() + timeout
1257 while not cancel.is_set():
1258 if lock.acquire(timeout=0.25):
1259 if cancel.is_set():
1260 lock.release()
1261 return False
1262 return True
1263 if deadline is not None and time.time() > deadline:
1264 return False
1265 return False
1266
1267
1268def _cancel_job(key: str) -> bool:
1269 """Ask a live job to stop. True if there was one to ask.
1270
1271 It stops at the next point it can: immediately while waiting, when the
1272 resolve returns, or within one MP4_STALL_POLL while ffmpeg is copying
1273 (_run_remux kills it). The .part is left where it is -- ffmpeg's -y reuses
1274 it on the next attempt, and nothing in this module deletes to recover.
1275 """
1276 with _mp4_jobs_guard:
1277 event = _cancel_events.get(key)
1278 if event is None:
1279 return False
1280 event.set()
1281 log.info("ingest: cancel requested for %s", key)
1282 return True
1283
1284
1285# Every finished attempt, one JSON object per line, appended and never
1286# rewritten. The queue file cannot serve as history: it holds one LATEST state
1287# per key, so a failure followed by a success leaves no trace of the failure.
1288#
1289# Appends are serialised under a lock and each is a single write of one line.
1290# The reader skips a line it cannot parse rather than failing: this file is
1291# display-only and nothing ever rebuilds it from what was read, so the shape of
1292# the 2026-08-31 queue loss (an unreadable read persisted as empty) cannot occur.
1293INGEST_HISTORY_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
1294 "ingest_history.jsonl")
1295_history_guard = threading.Lock()
1296
1297
1298def _append_history(entry: dict) -> None:
1299 """Never raises: losing a history line must not fail the job it records."""
1300 try:
1301 line = json.dumps(entry, ensure_ascii=False) + "\n"
1302 with _history_guard:
1303 with open(INGEST_HISTORY_FILE, "a", encoding="utf-8") as f:
1304 f.write(line)
1305 except Exception as exc:
1306 log.warning("history append failed: %s", exc)
1307
1308
1309def _read_history(limit: int = 500) -> list[dict]:
1310 """Newest first. A missing file is an empty history."""
1311 try:
1312 with open(INGEST_HISTORY_FILE, encoding="utf-8") as f:
1313 lines = f.readlines()
1314 except FileNotFoundError:
1315 return []
1316 except OSError as exc:
1317 log.warning("history unreadable: %s", exc)
1318 return []
1319 out = []
1320 for line in reversed(lines):
1321 try:
1322 entry = json.loads(line)
1323 except ValueError:
1324 continue
1325 if isinstance(entry, dict):
1326 out.append(entry)
1327 if len(out) >= limit:
1328 break
1329 return out
1330
1331
1332def _record_attempt(key: str, info: dict, state: str, started: float) -> None:
1333 dest = info.get("dest")
1334 size = None
1335 if dest and state == "done":
1336 try:
1337 size = os.path.getsize(dest)
1338 except OSError:
1339 pass
1340 _append_history({
1341 "ts": time.time(), "started": started, "key": key,
1342 "kind": info.get("kind"), "ident": info.get("ident"),
1343 "season": info.get("season"), "episode": info.get("episode"),
1344 "height": info.get("height"), "title": info.get("title"),
1345 "label": info.get("label"), "outcome": state,
1346 "source": info.get("source"), "bytes": size,
1347 "took": round(time.time() - started, 1), "dest": dest,
1348 })
1349
12291350# Job state was in-memory only, so a restart lost every intent: a copy that was
12301351# mid-flight never resumed, and since a partial writes to `<dest>.part` while the
12311352# row keys off the finished file, nothing surfaced the loss. Rolling forward
……@@ -1718,6 +1839,24 @@ class RemuxTimedOut(RuntimeError):
17181839 """The copy exceeded its wall-clock budget while still making progress."""
17191840
17201841
1842class RemuxCancelled(RuntimeError):
1843 """Someone pressed Cancel. Not a failure of the source, so it must never be
1844 treated as one -- in particular it must not send _run_ingest on to the
1845 HdHub fallback, which would fetch the very title the user just stopped."""
1846
1847
1848# The job a thread is running, so _run_remux can see its cancel event without
1849# the event being threaded through _remux_to_mp4 and _copy_progressive (whose
1850# two-argument shape the test fakes also rely on). Set by _run_job for the life
1851# of one ingest; absent on any thread that is not running a job.
1852_job_ctx = threading.local()
1853
1854
1855def _cancel_requested() -> bool:
1856 event = getattr(_job_ctx, "cancel", None)
1857 return bool(event and event.is_set())
1858
1859
17211860def _read_back(f) -> str:
17221861 """Everything written to an open temp file, from the start. Never raises:
17231862 this only ever supplies a diagnostic, and a diagnostic that can fail the
……@@ -1781,6 +1920,8 @@ def _run_remux(cmd: list[str], out_path: str, stall_timeout: float,
17811920 break
17821921 except subprocess.TimeoutExpired:
17831922 pass
1923 if _cancel_requested():
1924 raise RemuxCancelled("cancelled")
17841925 now = time.time()
17851926 size = _size()
17861927 if size > last_size:
……@@ -2101,20 +2242,39 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int,
21012242 # a CDN that refuses the bytes, as on 2026-09-28) falls through.
21022243 started = time.time()
21032244 failure = None
2245 _job_update(key, phase="resolving", dest=dest)
21042246 url = _resolve_vidcore(kind, ident, season, episode)
2247 # The resolve is a browser subprocess and is let run to its end; Cancel is
2248 # honoured the moment it returns, before any byte is fetched.
2249 if _cancel_requested():
2250 return "cancelled"
21052251 if url:
21062252 variant = _cap_variant(url, height)
21072253 log.info("ingest start: %s -> %s", key, dest)
2254 _job_update(key, phase="downloading", source="vidcore",
2255 downloading_since=time.time())
21082256 try:
21092257 _remux_to_mp4(variant, dest)
2258 except RemuxCancelled:
2259 log.info("ingest cancelled: %s", key)
2260 return "cancelled"
21102261 except Exception as exc:
21112262 log.warning("ingest failed for %s: %s", key, exc)
21122263 failure = f"failed: {exc}"
21132264 else:
21142265 failure = "failed: could not resolve title"
21152266 if failure:
2267 if _cancel_requested():
2268 return "cancelled"
21162269 started = time.time()
2270 _job_update(key, phase="downloading", source="hdhub",
2271 downloading_since=started)
21172272 why = _ingest_from_hdhub(kind, ident, season, episode, height, dest, meta)
2273 # _ingest_from_hdhub turns every exception into a reason string, a
2274 # cancel included, so the event is the authority here, not `why`.
2275 if _cancel_requested():
2276 log.info("ingest cancelled: %s", key)
2277 return "cancelled"
21182278 if why:
21192279 log.info("ingest: hdhub fallback for %s declined: %s", key, why)
21202280 return f"{failure}; hdhub: {why}"
……@@ -2148,7 +2308,8 @@ def _start_mp4_job(kind: str, ident: str, season: int, episode: int,
21482308
21492309
21502310def _run_job(kind: str, ident: str, season: int, episode: int,
2151 height: int, force: bool = False) -> str:
2311 height: int, force: bool = False,
2312 cancel: Optional[threading.Event] = None) -> str:
21522313 """One ingest, through the remux semaphore, with its state recorded.
21532314
21542315 Split out of the thread so a season can drive episodes SEQUENTIALLY through
……@@ -2158,30 +2319,72 @@ def _run_job(kind: str, ident: str, season: int, episode: int,
21582319 actually wrong with.
21592320 """
21602321 key = _mp4_key(kind, ident, season, episode, height)
2161 # The file is claimed BEFORE a remux slot, so a job waiting on another
2162 # ident's copy of the same file does not sit on one of the two slots while
2163 # it waits. Once it gets the file, _run_ingest's _library_has check finds
2164 # the copy the first job made and answers "done" without fetching again.
2165 # No identity (TMDB down) means no file to claim; _run_ingest then declines
2166 # on its own, before writing anything.
2167 meta = _library_meta(kind, ident, season, episode)
2168 claim = (_dest_lock(_library_path(kind, meta["title"], meta["year"],
2169 meta["tmdb_id"], season, episode,
2170 meta["episode_title"]))
2171 if meta else contextlib.nullcontext())
2172 with claim:
2173 acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT)
2174 try:
2175 state = (_run_ingest(kind, ident, season, episode, height, force)
2176 if acquired else "failed: remux slots busy")
2177 except Exception as exc: # never leave a job "running"
2178 log.warning("ingest crashed for %s: %s", key, exc)
2179 state = f"failed: {exc}"
2180 finally:
2181 if acquired:
2182 _mp4_sem.release()
2322 started = time.time()
2323 # A season passes in an event it registered BEFORE checking its own cancel
2324 # flag, so a season Cancel cannot fall between choosing this episode and
2325 # this job becoming cancellable.
2326 cancel = cancel or threading.Event()
2327 label = f"S{season:02d}E{episode:02d}" if kind == "series" else "movie"
2328 with _mp4_jobs_guard:
2329 _job_info[key] = {"phase": "waiting", "source": None, "dest": None,
2330 "started": started, "kind": kind, "ident": ident,
2331 "season": season, "episode": episode,
2332 "height": height, "label": label, "title": ident}
2333 _cancel_events[key] = cancel
2334 # A season drives episodes through here directly, never through
2335 # _start_mp4_job, so without this an episode being fetched by a season
2336 # read as not-running -- and the web page offered to fetch it again.
2337 if _mp4_jobs.get(key) != "running":
2338 _set_job_state(key, "running",
2339 args=(kind, ident, season, episode, height))
2340 _job_ctx.cancel = cancel
2341 meta = None
2342 acquired = False
2343 try:
2344 # The file is claimed BEFORE a remux slot, so a job waiting on another
2345 # ident's copy of the same file does not sit on one of the two slots
2346 # while it waits. Once it gets the file, _run_ingest's _library_has
2347 # check finds the copy the first job made and answers "done" without
2348 # fetching again. No identity (TMDB down) means no file to claim;
2349 # _run_ingest then declines on its own, before writing anything.
2350 meta = _library_meta(kind, ident, season, episode)
2351 if meta:
2352 _job_update(key, title=meta["title"])
2353 claim = (_dest_lock(_library_path(kind, meta["title"], meta["year"],
2354 meta["tmdb_id"], season, episode,
2355 meta["episode_title"]))
2356 if meta else None)
2357 # Both waits poll, so a job still queued behind another can be
2358 # cancelled without first waiting out the one in front of it.
2359 if claim is not None and not _acquire_or_cancel(claim, cancel):
2360 state = "cancelled"
2361 else:
2362 try:
2363 acquired = _acquire_or_cancel(_mp4_sem, cancel,
2364 timeout=MP4_REMUX_TIMEOUT)
2365 if cancel.is_set():
2366 state = "cancelled"
2367 elif not acquired:
2368 state = "failed: remux slots busy"
2369 else:
2370 state = _run_ingest(kind, ident, season, episode, height,
2371 force)
2372 finally:
2373 if acquired:
2374 _mp4_sem.release()
2375 if claim is not None:
2376 claim.release()
2377 except Exception as exc: # never leave a job "running"
2378 log.warning("ingest crashed for %s: %s", key, exc)
2379 state = f"failed: {exc}"
2380 finally:
2381 _job_ctx.cancel = None
2382 info = _job_info.get(key) or {}
2383 _record_attempt(key, info, state, started)
21832384 with _mp4_jobs_guard:
21842385 _set_job_state(key, state)
2386 _job_info.pop(key, None)
2387 _cancel_events.pop(key, None)
21852388 return state
21862389
21872390
……@@ -2211,6 +2414,26 @@ def _released_episodes(tmdb_id: str, season: int) -> list[int]:
22112414
22122415_season_jobs: dict[str, str] = {}
22132416_season_guard = threading.Lock()
2417# Live season jobs only, like _job_info: position in the episode list and the
2418# episode being fetched, for the download manager.
2419_season_info: dict[str, dict] = {}
2420_season_cancel: dict[str, threading.Event] = {}
2421
2422
2423def _cancel_season(key: str) -> bool:
2424 """Stop a season job: no further episodes, and the current one cancelled."""
2425 with _season_guard:
2426 event = _season_cancel.get(key)
2427 if event is None:
2428 return False
2429 # Both set under the season lock -- the same lock the loop holds while
2430 # it registers the next episode -- so no episode can start unseen.
2431 event.set()
2432 current = (_season_info.get(key) or {}).get("current_cancel")
2433 if current is not None:
2434 current.set()
2435 log.info("season: cancel requested for %s", key)
2436 return True
22142437
22152438
22162439def _start_season_job(ident: str, season: int, height: int,
……@@ -2223,28 +2446,47 @@ def _start_season_job(ident: str, season: int, height: int,
22232446 as it already did for a single episode.
22242447 """
22252448 key = f"season:{ident}:{season}:{height}"
2449 cancel = threading.Event()
22262450 with _season_guard:
22272451 if _season_jobs.get(key) == "running":
22282452 return "running"
22292453 _season_jobs[key] = "running"
2454 _season_cancel[key] = cancel
2455 _season_info[key] = {"ident": ident, "season": season,
2456 "height": height, "episodes": [], "index": 0,
2457 "current": None, "started": time.time()}
22302458
22312459 def run() -> None:
22322460 done = 0
22332461 try:
22342462 meta = _library_meta("series", ident, season, 1)
22352463 episodes = _released_episodes(meta["tmdb_id"], season) if meta else []
2464 _season_info[key].update(episodes=episodes,
2465 title=meta["title"] if meta else ident)
22362466 log.info("season %s S%02d: %d released episode(s)",
22372467 ident, season, len(episodes))
2238 for episode in episodes:
2468 for i, episode in enumerate(episodes, 1):
22392469 if not force and _library_has("series", ident, season, episode):
22402470 continue
2241 _run_job("series", ident, season, episode, height, force)
2471 episode_cancel = threading.Event()
2472 with _season_guard:
2473 if cancel.is_set():
2474 break
2475 _season_info[key].update(
2476 index=i, current_cancel=episode_cancel,
2477 current=_mp4_key("series", ident, season, episode,
2478 height))
2479 _run_job("series", ident, season, episode, height, force,
2480 cancel=episode_cancel)
22422481 done += 1
22432482 except Exception as exc:
22442483 log.warning("season job crashed for %s: %s", key, exc)
22452484 finally:
22462485 with _season_guard:
2247 _season_jobs[key] = f"done ({done})"
2486 _season_jobs[key] = (f"cancelled ({done})" if cancel.is_set()
2487 else f"done ({done})")
2488 _season_cancel.pop(key, None)
2489 _season_info.pop(key, None)
22482490
22492491 threading.Thread(target=run, name=f"season-{key}", daemon=True).start()
22502492 return "running"
test_job_control.py +334 -0
……@@ -0,0 +1,334 @@
1"""Job control for the download manager (labs#13): phase, source, cancel, history.
2
3The manager needs to say what a job is DOING (waiting / resolving /
4downloading), where the bytes come FROM, to STOP one on request, and what
5happened to every attempt AFTERWARDS. None of that existed: a job was one state
6string, and the only history was the journal.
7
8Cancel is the one with teeth. A cancelled job must:
9 - kill ffmpeg promptly (via _run_remux's existing poll loop),
10 - NOT fall through to the HdHub fallback, which would fetch the same title
11 from another source -- the opposite of what was asked,
12 - keep its .part (ffmpeg -y reuses it; nothing here deletes),
13 - record "cancelled", which _resume_ingests does not restart at boot.
14
15Run: .venv/bin/python3 -m pytest test_job_control.py -q
16"""
17
18import json
19import os
20import threading
21import time
22
23import pytest
24
25import addon
26
27
28META = {"tmdb_id": "138502", "title": "X-Men '97", "year": "2024",
29 "episode_title": "Bright Eyes", "runtime": 30}
30
31
32def _join_jobs():
33 for t in threading.enumerate():
34 if (t.name.startswith(("mp4-", "season-"))
35 and t is not threading.current_thread()):
36 t.join(15)
37
38
39@pytest.fixture
40def env(tmp_path, monkeypatch):
41 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "queue.json"))
42 monkeypatch.setattr(addon, "INGEST_HISTORY_FILE", str(tmp_path / "history.jsonl"))
43 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib"))
44 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE", str(tmp_path / "verified.json"))
45 monkeypatch.setattr(addon, "_mp4_jobs", {})
46 monkeypatch.setattr(addon, "_job_info", {})
47 monkeypatch.setattr(addon, "_season_jobs", {})
48 monkeypatch.setattr(addon, "_season_info", {})
49 monkeypatch.setattr(addon, "_library_meta",
50 lambda kind, ident, s=0, e=0: dict(META, episode_title=f"Ep{e}"))
51 monkeypatch.setattr(addon, "_enough_space", lambda: True)
52 monkeypatch.setattr(addon, "_cap_variant", lambda url, h=None: url)
53 monkeypatch.setattr(addon, "_fetch_subtitles_for", lambda *a, **k: None)
54 monkeypatch.setattr(addon, "_copy_is_complete", lambda *a, **k: True)
55 monkeypatch.setattr(addon, "_notify_jellyfin", lambda *a, **k: None, raising=False)
56 calls = {"resolve": 0, "hdhub": 0}
57
58 def resolve(*a, **k):
59 calls["resolve"] += 1
60 return "http://cdn/m.m3u8"
61 monkeypatch.setattr(addon, "_resolve_vidcore", resolve)
62
63 def hdhub(*a, **k):
64 calls["hdhub"] += 1
65 return "no usable file"
66 monkeypatch.setattr(addon, "_ingest_from_hdhub", hdhub)
67 yield calls
68 _join_jobs()
69
70
71def _write_dest(dest):
72 os.makedirs(os.path.dirname(dest), exist_ok=True)
73 with open(dest, "wb") as f:
74 f.write(b"x" * 2048)
75
76
77def _history():
78 with open(addon.INGEST_HISTORY_FILE) as f:
79 return [json.loads(line) for line in f if line.strip()]
80
81
82def _wait(pred, timeout=10):
83 end = time.time() + timeout
84 while time.time() < end:
85 if pred():
86 return True
87 time.sleep(0.05)
88 return False
89
90
91KEY = "series:tmdb:138502:2:6:1080"
92
93
94# --- history ----------------------------------------------------------------
95
96def test_a_finished_ingest_appends_one_history_line(env, monkeypatch):
97 monkeypatch.setattr(addon, "_remux_to_mp4", lambda url, dest: _write_dest(dest))
98 addon._run_job("series", "tmdb:138502", 2, 6, 1080)
99 (h,) = _history()
100 assert h["key"] == KEY and h["outcome"] == "done"
101 assert h["source"] == "vidcore"
102 assert h["title"] == "X-Men '97" and h["label"] == "S02E06"
103 assert h["bytes"] == 2048
104 assert h["took"] >= 0 and h["ts"] > 0
105
106
107def test_a_failure_is_recorded_with_its_reason(env, monkeypatch):
108 def boom(url, dest):
109 raise RuntimeError("cdn 403")
110 monkeypatch.setattr(addon, "_remux_to_mp4", boom)
111 addon._run_job("series", "tmdb:138502", 2, 6, 1080)
112 (h,) = _history()
113 assert h["outcome"].startswith("failed")
114 assert "cdn 403" in h["outcome"]
115
116
117def test_an_hdhub_copy_records_hdhub_as_its_source(env, monkeypatch):
118 monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: None)
119
120 def hdhub(kind, ident, s, e, h, dest, meta):
121 _write_dest(dest)
122 return None
123 monkeypatch.setattr(addon, "_ingest_from_hdhub", hdhub)
124 addon._run_job("series", "tmdb:138502", 2, 6, 1080)
125 assert _history()[0]["source"] == "hdhub"
126
127
128def test_a_title_already_held_is_recorded_as_such(env, monkeypatch):
129 monkeypatch.setattr(addon, "_library_has", lambda *a, **k: True)
130 addon._run_job("series", "tmdb:138502", 2, 6, 1080)
131 (h,) = _history()
132 assert h["outcome"] == "done" and h["source"] is None
133
134
135def test_history_reader_skips_a_torn_line_and_lists_newest_first(env):
136 with open(addon.INGEST_HISTORY_FILE, "w") as f:
137 f.write(json.dumps({"ts": 1, "key": "a"}) + "\n")
138 f.write('{"ts": 2, "key": "b"\n') # torn
139 f.write(json.dumps({"ts": 3, "key": "c"}) + "\n")
140 assert [h["key"] for h in addon._read_history()] == ["c", "a"]
141
142
143def test_a_missing_history_file_reads_empty(env):
144 assert addon._read_history() == []
145
146
147def test_history_appends_are_not_lost_under_concurrency(env):
148 threads = [threading.Thread(target=addon._append_history,
149 args=({"ts": i, "key": str(i)},))
150 for i in range(40)]
151 for t in threads:
152 t.start()
153 for t in threads:
154 t.join()
155 assert len(_history()) == 40
156
157
158# --- phase + source ---------------------------------------------------------
159
160def test_phase_and_source_are_visible_while_downloading(env, monkeypatch):
161 seen = {}
162
163 def remux(url, dest):
164 seen.update(addon._job_info.get(KEY) or {})
165 _write_dest(dest)
166 monkeypatch.setattr(addon, "_remux_to_mp4", remux)
167 addon._run_job("series", "tmdb:138502", 2, 6, 1080)
168 assert seen["phase"] == "downloading"
169 assert seen["source"] == "vidcore"
170 assert seen["dest"].endswith("S02E06 - Ep6.mp4")
171
172
173def test_a_started_job_is_waiting_before_it_runs(env, monkeypatch):
174 gate = threading.Event()
175 monkeypatch.setattr(addon, "_remux_to_mp4", lambda url, dest: gate.wait(5))
176 sem = threading.Semaphore(0) # no free slots
177 monkeypatch.setattr(addon, "_mp4_sem", sem)
178 addon._start_mp4_job("series", "tmdb:138502", 2, 6, 1080)
179 assert _wait(lambda: (addon._job_info.get(KEY) or {}).get("phase") == "waiting")
180 addon._cancel_job(KEY)
181 sem.release()
182 gate.set()
183
184
185# --- cancel -----------------------------------------------------------------
186
187def _real_remux_that_runs_forever(tmp_path):
188 """A stand-in for ffmpeg: a real process under the real _run_remux, so the
189 cancel path tested is the one production uses."""
190 def remux(url, dest):
191 part = addon._part_path(dest)
192 os.makedirs(os.path.dirname(part), exist_ok=True)
193 with open(part, "wb") as f:
194 f.write(b"partial")
195 addon._run_remux(["sleep", "60"], part, stall_timeout=120,
196 hard_timeout=120)
197 _write_dest(dest)
198 return remux
199
200
201def test_cancel_kills_the_remux_promptly(env, monkeypatch, tmp_path):
202 monkeypatch.setattr(addon, "_remux_to_mp4", _real_remux_that_runs_forever(tmp_path))
203 addon._start_mp4_job("series", "tmdb:138502", 2, 6, 1080)
204 assert _wait(lambda: (addon._job_info.get(KEY) or {}).get("phase") == "downloading")
205 t0 = time.time()
206 assert addon._cancel_job(KEY) is True
207 assert _wait(lambda: addon._mp4_jobs.get(KEY) == "cancelled", timeout=10)
208 assert time.time() - t0 < addon.MP4_STALL_POLL * 3
209
210
211def test_cancel_does_not_fall_through_to_hdhub(env, monkeypatch, tmp_path):
212 monkeypatch.setattr(addon, "_remux_to_mp4", _real_remux_that_runs_forever(tmp_path))
213 addon._start_mp4_job("series", "tmdb:138502", 2, 6, 1080)
214 assert _wait(lambda: (addon._job_info.get(KEY) or {}).get("phase") == "downloading")
215 addon._cancel_job(KEY)
216 _join_jobs()
217 assert env["hdhub"] == 0
218
219
220def test_cancel_keeps_the_part_file(env, monkeypatch, tmp_path):
221 monkeypatch.setattr(addon, "_remux_to_mp4", _real_remux_that_runs_forever(tmp_path))
222 addon._start_mp4_job("series", "tmdb:138502", 2, 6, 1080)
223 assert _wait(lambda: (addon._job_info.get(KEY) or {}).get("phase") == "downloading")
224 dest = addon._job_info[KEY]["dest"]
225 addon._cancel_job(KEY)
226 _join_jobs()
227 assert os.path.exists(addon._part_path(dest))
228 assert not os.path.exists(dest)
229
230
231def test_cancel_is_recorded_and_never_resumed_at_boot(env, monkeypatch, tmp_path):
232 monkeypatch.setattr(addon, "_remux_to_mp4", _real_remux_that_runs_forever(tmp_path))
233 addon._start_mp4_job("series", "tmdb:138502", 2, 6, 1080)
234 assert _wait(lambda: (addon._job_info.get(KEY) or {}).get("phase") == "downloading")
235 addon._cancel_job(KEY)
236 _join_jobs()
237 assert _history()[-1]["outcome"] == "cancelled"
238 with open(addon.INGEST_QUEUE_FILE) as f:
239 assert json.load(f)[KEY]["state"] == "cancelled"
240 started = []
241 monkeypatch.setattr(addon, "_start_mp4_job", lambda *a, **k: started.append(a))
242 addon._resume_ingests()
243 assert started == []
244
245
246def test_cancel_while_waiting_never_resolves(env, monkeypatch):
247 sem = threading.Semaphore(0)
248 monkeypatch.setattr(addon, "_mp4_sem", sem)
249 monkeypatch.setattr(addon, "_remux_to_mp4", lambda url, dest: _write_dest(dest))
250 addon._start_mp4_job("series", "tmdb:138502", 2, 6, 1080)
251 assert _wait(lambda: (addon._job_info.get(KEY) or {}).get("phase") == "waiting")
252 addon._cancel_job(KEY)
253 sem.release()
254 _join_jobs()
255 assert addon._mp4_jobs[KEY] == "cancelled"
256 assert env["resolve"] == 0
257
258
259def test_a_cancelled_job_can_be_started_again(env, monkeypatch):
260 addon._mp4_jobs[KEY] = "cancelled"
261 monkeypatch.setattr(addon, "_remux_to_mp4", lambda url, dest: _write_dest(dest))
262 assert addon._start_mp4_job("series", "tmdb:138502", 2, 6, 1080) == "running"
263 _join_jobs()
264 assert addon._mp4_jobs[KEY] == "done"
265
266
267def test_cancelling_an_unknown_job_is_false(env):
268 assert addon._cancel_job("series:tmdb:1:1:1:1080") is False
269
270
271def test_cancel_does_not_touch_other_jobs(env, monkeypatch, tmp_path):
272 monkeypatch.setattr(addon, "_remux_to_mp4", _real_remux_that_runs_forever(tmp_path))
273 other = "series:tmdb:138502:2:7:1080"
274 addon._start_mp4_job("series", "tmdb:138502", 2, 6, 1080)
275 addon._start_mp4_job("series", "tmdb:138502", 2, 7, 1080)
276 assert _wait(lambda: all((addon._job_info.get(k) or {}).get("phase") == "downloading"
277 for k in (KEY, other)))
278 addon._cancel_job(KEY)
279 assert _wait(lambda: addon._mp4_jobs.get(KEY) == "cancelled")
280 assert addon._mp4_jobs.get(other) == "running"
281 addon._cancel_job(other)
282
283
284# --- season -----------------------------------------------------------------
285
286def test_a_season_job_reports_its_position(env, monkeypatch):
287 monkeypatch.setattr(addon, "_released_episodes", lambda tmdb, s: [1, 2, 3])
288 gate = threading.Event()
289 seen = []
290
291 def remux(url, dest):
292 seen.append(dict(addon._season_info["season:tmdb:138502:2:1080"]))
293 _write_dest(dest)
294 monkeypatch.setattr(addon, "_remux_to_mp4", remux)
295 addon._start_season_job("tmdb:138502", 2, 1080)
296 _join_jobs()
297 assert [s["index"] for s in seen] == [1, 2, 3]
298 assert seen[0]["episodes"] == [1, 2, 3]
299
300
301def test_cancelling_a_season_stops_the_remaining_episodes(env, monkeypatch, tmp_path):
302 monkeypatch.setattr(addon, "_released_episodes", lambda tmdb, s: [1, 2, 3])
303 monkeypatch.setattr(addon, "_remux_to_mp4", _real_remux_that_runs_forever(tmp_path))
304 skey = "season:tmdb:138502:2:1080"
305 addon._start_season_job("tmdb:138502", 2, 1080)
306 first = "series:tmdb:138502:2:1:1080"
307 assert _wait(lambda: (addon._job_info.get(first) or {}).get("phase") == "downloading")
308 assert addon._cancel_season(skey) is True
309 _join_jobs()
310 assert addon._mp4_jobs[first] == "cancelled"
311 assert "series:tmdb:138502:2:2:1080" not in addon._mp4_jobs
312 assert addon._season_jobs[skey].startswith("cancelled")
313
314
315def test_cancelling_one_episode_of_a_season_skips_only_that_episode(env, monkeypatch, tmp_path):
316 monkeypatch.setattr(addon, "_released_episodes", lambda tmdb, s: [1, 2])
317 forever = _real_remux_that_runs_forever(tmp_path)
318 calls = []
319
320 def remux(url, dest):
321 calls.append(dest)
322 if len(calls) == 1:
323 forever(url, dest) # episode 1 hangs until cancelled
324 else:
325 _write_dest(dest)
326 monkeypatch.setattr(addon, "_remux_to_mp4", remux)
327 addon._start_season_job("tmdb:138502", 2, 1080)
328 first = "series:tmdb:138502:2:1:1080"
329 assert _wait(lambda: (addon._job_info.get(first) or {}).get("phase") == "downloading")
330 addon._cancel_job(first)
331 _join_jobs()
332 assert addon._mp4_jobs[first] == "cancelled"
333 assert addon._mp4_jobs["series:tmdb:138502:2:2:1080"] == "done"
334 assert addon._season_jobs["season:tmdb:138502:2:1080"] == "done (2)"