OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / d43dbeb

One library file, one ingest, whatever ident asked for it (0.19.1)
The in-flight guard keyed on kind:ident:season:episode:height, but one episode arrives under two idents -- tt... from Kodi's cinemeta rows, tmdb:... from the flixlab catalog -- and both name the same _library_path. Neither job saw the other, so two ffmpeg processes wrote one .part. The web UI (labs#12) makes it likelier, since its point is to start a download from somewhere other than the Kodi row that may already be fetching it. _run_job now claims the destination file before a remux slot. The second job waits on the file (not on a slot), then _run_ingest's _library_has check finds the first job's copy and answers "done" with no second fetch. Different files still run two at a time. Red first: peak concurrent remuxes 2 for tt + tmdb: on one episode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d43dbeb2576e16b1d4620356701cfa92b976f8ae
by plankatron <fleeky@gmail.com> · 2026-10-04T23:55:13.000Z

Changed files (2) +139 -11

addon.py +37 -11
……@@ -5,6 +5,7 @@ The fmovies path runs node + Playwright via subprocess to handle the JS-driven e
55"""
66
77import calendar
8import contextlib
89import html
910import json
1011import logging
……@@ -617,7 +618,7 @@ TV_GENRES = [
617618
618619MANIFEST = {
619620 "id": "org.tabertron.vsembed",
620 "version": "0.19.0",
621 "version": "0.19.1",
621622 "name": "Tabertron VsEmbed",
622623 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
623624 "types": ["movie", "series"],
……@@ -1211,6 +1212,19 @@ _mp4_jobs: dict[str, str] = {} # key -> "running" | "done" | "failed: .
12111212_mp4_jobs_guard = threading.Lock()
12121213_mp4_sem = threading.Semaphore(2) # concurrent remuxes
12131214
1215# One lock per library FILE, because the job key above is not one-to-one with
1216# files: Kodi's cinemeta rows ask by `tt…`, the flixlab catalog and the web UI by
1217# `tmdb:…`, and both name the same destination. Keyed on the ident alone, two
1218# jobs wrote one .part at once (labs#12). Locks are never removed -- one small
1219# object per title ever fetched, which is bounded by the library itself.
1220_dest_locks: dict[str, threading.Lock] = {}
1221_dest_locks_guard = threading.Lock()
1222
1223
1224def _dest_lock(dest: str) -> threading.Lock:
1225 with _dest_locks_guard:
1226 return _dest_locks.setdefault(os.path.abspath(dest), threading.Lock())
1227
12141228# Job state was in-memory only, so a restart lost every intent: a copy that was
12151229# mid-flight never resumed, and since a partial writes to `<dest>.part` while the
12161230# row keys off the finished file, nothing surfaced the loss. Rolling forward
……@@ -2143,16 +2157,28 @@ def _run_job(kind: str, ident: str, season: int, episode: int,
21432157 actually wrong with.
21442158 """
21452159 key = _mp4_key(kind, ident, season, episode, height)
2146 acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT)
2147 try:
2148 state = (_run_ingest(kind, ident, season, episode, height, force)
2149 if acquired else "failed: remux slots busy")
2150 except Exception as exc: # never leave a job "running"
2151 log.warning("ingest crashed for %s: %s", key, exc)
2152 state = f"failed: {exc}"
2153 finally:
2154 if acquired:
2155 _mp4_sem.release()
2160 # The file is claimed BEFORE a remux slot, so a job waiting on another
2161 # ident's copy of the same file does not sit on one of the two slots while
2162 # it waits. Once it gets the file, _run_ingest's _library_has check finds
2163 # the copy the first job made and answers "done" without fetching again.
2164 # No identity (TMDB down) means no file to claim; _run_ingest then declines
2165 # on its own, before writing anything.
2166 meta = _library_meta(kind, ident, season, episode)
2167 claim = (_dest_lock(_library_path(kind, meta["title"], meta["year"],
2168 meta["tmdb_id"], season, episode,
2169 meta["episode_title"]))
2170 if meta else contextlib.nullcontext())
2171 with claim:
2172 acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT)
2173 try:
2174 state = (_run_ingest(kind, ident, season, episode, height, force)
2175 if acquired else "failed: remux slots busy")
2176 except Exception as exc: # never leave a job "running"
2177 log.warning("ingest crashed for %s: %s", key, exc)
2178 state = f"failed: {exc}"
2179 finally:
2180 if acquired:
2181 _mp4_sem.release()
21562182 with _mp4_jobs_guard:
21572183 _set_job_state(key, state)
21582184 return state
test_one_file_one_ingest.py +102 -0
……@@ -0,0 +1,102 @@
1"""One library file, one ingest -- whatever ident asked for it.
2
3The in-flight guard in _start_mp4_job keys on `kind:ident:season:episode:height`.
4But the same episode reaches this service under two idents: Kodi's cinemeta rows
5send `tt…`, the flixlab catalog and the web UI send `tmdb:…`. Both resolve
6through _library_meta to the SAME _library_path, and therefore the same .part --
7yet they are different keys, so neither job sees the other. Two ffmpeg processes
8then write one .part. (labs#12.)
9
10The web UI makes this likelier: the point of it is to start a download from
11somewhere other than the Kodi row that may already be fetching it.
12
13Run: .venv/bin/python3 -m pytest test_one_file_one_ingest.py -q
14"""
15
16import os
17import threading
18import time
19
20import pytest
21
22import addon
23
24
25META = {"tmdb_id": "138502", "title": "X-Men '97", "year": "2024",
26 "episode_title": "Bright Eyes", "runtime": 30}
27
28
29@pytest.fixture
30def ingest_env(tmp_path, monkeypatch):
31 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE",
32 os.path.join(str(tmp_path), "ingest_queue.json"))
33 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib"))
34 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE",
35 os.path.join(str(tmp_path), "library_verified.json"))
36 monkeypatch.setattr(addon, "_mp4_jobs", {})
37 monkeypatch.setattr(addon, "_library_meta", lambda *a, **k: dict(META))
38 monkeypatch.setattr(addon, "_enough_space", lambda: True)
39 monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "http://cdn/m.m3u8")
40 monkeypatch.setattr(addon, "_cap_variant", lambda url, h=None: url)
41 monkeypatch.setattr(addon, "_fetch_subtitles_for", lambda *a, **k: None)
42 monkeypatch.setattr(addon, "_copy_is_complete", lambda *a, **k: True)
43
44 calls = {"n": 0, "live": 0, "peak": 0}
45 lock = threading.Lock()
46
47 def fake_remux(variant_url, dest):
48 with lock:
49 calls["n"] += 1
50 calls["live"] += 1
51 calls["peak"] = max(calls["peak"], calls["live"])
52 time.sleep(0.3) # long enough for the other to start
53 os.makedirs(os.path.dirname(dest), exist_ok=True)
54 with open(dest, "wb") as f:
55 f.write(b"x" * 1024)
56 with lock:
57 calls["live"] -= 1
58
59 monkeypatch.setattr(addon, "_remux_to_mp4", fake_remux)
60 yield calls
61 # Same reason as test_ingest_queue: a job thread that outlives the patches
62 # writes its final state to the REAL queue.
63 for t in threading.enumerate():
64 if t.name.startswith("mp4-") and t is not threading.current_thread():
65 t.join(10)
66
67
68def _join_jobs():
69 for t in threading.enumerate():
70 if t.name.startswith("mp4-") and t is not threading.current_thread():
71 t.join(10)
72
73
74def test_two_idents_for_one_episode_fetch_it_once(ingest_env):
75 addon._start_mp4_job("series", "tt16026746", 2, 7, 1080)
76 addon._start_mp4_job("series", "tmdb:138502", 2, 7, 1080)
77 _join_jobs()
78 assert ingest_env["peak"] == 1, "two remuxes wrote the same .part at once"
79 assert ingest_env["n"] == 1, "the second ident fetched a copy already held"
80
81
82def test_the_second_job_reports_done_not_failed(ingest_env):
83 """It got what it asked for. Reporting a failure here is the #11 shape --
84 an outcome misreported -- and would put a false red row on /library."""
85 addon._start_mp4_job("series", "tt16026746", 2, 7, 1080)
86 addon._start_mp4_job("series", "tmdb:138502", 2, 7, 1080)
87 _join_jobs()
88 assert addon._mp4_jobs["series:tt16026746:2:7:1080"] == "done"
89 assert addon._mp4_jobs["series:tmdb:138502:2:7:1080"] == "done"
90
91
92def test_different_episodes_still_run_concurrently(ingest_env, monkeypatch):
93 """The guard is per FILE. It must not serialise unrelated ingests behind
94 one another -- that would turn the remux semaphore's two slots into one."""
95 metas = {1: dict(META, episode_title="One"), 2: dict(META, episode_title="Two")}
96 monkeypatch.setattr(addon, "_library_meta",
97 lambda kind, ident, s=0, e=0: metas[e])
98 addon._start_mp4_job("series", "tmdb:138502", 2, 1, 1080)
99 addon._start_mp4_job("series", "tmdb:138502", 2, 2, 1080)
100 _join_jobs()
101 assert ingest_env["n"] == 2
102 assert ingest_env["peak"] == 2