OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 91d9dc9

Library ingest: the job writes into the library (0.9.4)
_remux_to_mp4 now creates the DESTINATION's directory rather than MP4_DIR, so an ingest can create a show folder and season folder that do not exist yet, and takes its temp path from _part_path. _run_ingest is the job body, split out of the thread so orchestration is testable without one. The ordering is deliberate: identify FIRST and decline outright if we cannot, because everything past that point writes a file, and a file we cannot name correctly is worse than no file. Resolution happens in the job, not at queue time -- variant URLs are signed and IP-locked with a ~4h lifetime, so a queued job would otherwise start against a dead link. An episode already in the library is a no-op at any height, so a second quality request never produces a second file and nothing is deleted for an upgrade. _start_mp4_job holds no reference to a playback session, so pause and stop cannot affect an ingest in flight. That property is the feature; nothing here may become cancellable-on-stop. NOTE: the /mp4/ route still reads the legacy mp4-cache path, so it does not yet serve an ingested file. Nothing user-facing depends on it -- _append_mp4_row only emits a row when that legacy file exists -- and the next commit reconciles serving with the library. 8 new tests, 93 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91d9dc9eba2c4162311e9148814bc7e14ef32cbb
by plankatron <fleeky@gmail.com> · 2026-08-11T14:14:32.000Z

Changed files (2) +171 -22

addon.py +56 -22
……@@ -533,7 +533,7 @@ TV_GENRES = [
533533
534534MANIFEST = {
535535 "id": "org.tabertron.vsembed",
536 "version": "0.9.3",
536 "version": "0.9.4",
537537 "name": "Tabertron VsEmbed",
538538 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
539539 "types": ["movie", "series"],
……@@ -1247,8 +1247,10 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None:
12471247 seeked over HTTP. Written to `.part` and renamed, so a crashed or killed
12481248 remux can never leave a half file looking complete.
12491249 """
1250 tmp = out_path + ".part"
1251 os.makedirs(MP4_DIR, exist_ok=True)
1250 tmp = _part_path(out_path)
1251 # The destination's OWN directory, not MP4_DIR: an ingest writes into the
1252 # library, where the show folder and season folder may not exist yet.
1253 os.makedirs(os.path.dirname(out_path), exist_ok=True)
12521254 proc = subprocess.run(
12531255 ["ffmpeg", "-v", "error", "-y",
12541256 "-headers", "Referer: https://vidcore.net/\r\n",
……@@ -1264,8 +1266,51 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None:
12641266 os.replace(tmp, out_path)
12651267
12661268
1269def _run_ingest(kind: str, ident: str, season: int, episode: int,
1270 height: int) -> str:
1271 """Fetch one title into the library. Synchronous; returns the final state.
1272
1273 Split out from the thread so the orchestration is testable without one, and
1274 so the ordering below is explicit: identify FIRST, and decline outright if we
1275 cannot. Everything after that point writes a file, and a file we cannot name
1276 correctly is worse than no file at all.
1277
1278 Resolution happens HERE rather than when the job was queued: variant URLs are
1279 signed and IP-locked with a ~4h lifetime, so a job that sat in a queue would
1280 otherwise start against a dead link.
1281 """
1282 key = _mp4_key(kind, ident, season, episode, height)
1283 meta = _library_meta(kind, ident, season, episode)
1284 if not meta:
1285 return f"failed: cannot identify {ident}"
1286 dest = _library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
1287 season, episode, meta["episode_title"])
1288 if _library_has(kind, ident, season, episode):
1289 log.info("ingest: library already holds %s", os.path.basename(dest))
1290 return "done"
1291 url = _resolve_vidcore(kind, ident, season, episode)
1292 if not url:
1293 return "failed: could not resolve title"
1294 variant = _cap_variant(url, height)
1295 log.info("ingest start: %s -> %s", key, dest)
1296 started = time.time()
1297 try:
1298 _remux_to_mp4(variant, dest)
1299 except Exception as exc:
1300 log.warning("ingest failed for %s: %s", key, exc)
1301 return f"failed: {exc}"
1302 log.info("ingest done: %s in %.0fs (%.2f GB)", key,
1303 time.time() - started, os.path.getsize(dest) / 1e9)
1304 return "done"
1305
1306
12671307def _start_mp4_job(kind: str, ident: str, season: int, episode: int, height: int) -> str:
1268 """Kick off a remux in the background. Returns the job state."""
1308 """Kick off an ingest in the background. Returns the job state.
1309
1310 The job holds no reference to any playback session, so pausing or stopping
1311 the player cannot affect it — which is the whole point. Nothing here may ever
1312 become cancellable-on-stop.
1313 """
12691314 key = _mp4_key(kind, ident, season, episode, height)
12701315 with _mp4_jobs_guard:
12711316 state = _mp4_jobs.get(key)
……@@ -1276,27 +1321,16 @@ def _start_mp4_job(kind: str, ident: str, season: int, episode: int, height: int
12761321 def run() -> None:
12771322 acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT)
12781323 try:
1279 if not acquired:
1280 raise RuntimeError("remux slots busy")
1281 url = _resolve_vidcore(kind, ident, season, episode)
1282 if not url:
1283 raise RuntimeError("could not resolve title")
1284 variant = _cap_variant(url, height)
1285 out = _mp4_path(kind, ident, season, episode, height)
1286 log.info("mp4 remux start: %s -> %s", key, os.path.basename(out))
1287 started = time.time()
1288 _remux_to_mp4(variant, out)
1289 log.info("mp4 remux done: %s in %.0fs (%.2f GB)", key,
1290 time.time() - started, os.path.getsize(out) / 1e9)
1291 with _mp4_jobs_guard:
1292 _mp4_jobs[key] = "done"
1293 except Exception as exc:
1294 log.warning("mp4 remux failed for %s: %s", key, exc)
1295 with _mp4_jobs_guard:
1296 _mp4_jobs[key] = f"failed: {exc}"
1324 state = (_run_ingest(kind, ident, season, episode, height)
1325 if acquired else "failed: remux slots busy")
1326 except Exception as exc: # never leave a job "running"
1327 log.warning("ingest crashed for %s: %s", key, exc)
1328 state = f"failed: {exc}"
12971329 finally:
12981330 if acquired:
12991331 _mp4_sem.release()
1332 with _mp4_jobs_guard:
1333 _mp4_jobs[key] = state
13001334
13011335 threading.Thread(target=run, name=f"mp4-{key}", daemon=True).start()
13021336 return "running"
test_library_ingest.py +115 -0
……@@ -265,3 +265,118 @@ def test_an_existing_copy_at_any_height_counts_as_present(tmdb):
265265
266266def test_an_unidentifiable_title_is_reported_absent_not_crashing(tmdb):
267267 assert addon._library_has("series", "tmdb:999999", 1, 1) is False
268
269
270# --- the remux writes where it is told --------------------------------------
271
272@pytest.fixture
273def fake_ffmpeg(monkeypatch):
274 """Stand in for ffmpeg: record the argv and create whatever output it was
275 asked for, so the surrounding file handling is what gets exercised."""
276 class Fake:
277 runs = []
278 fail = False
279
280 class Result:
281 returncode = 0
282 stderr = ""
283
284 def fake_run(argv, **kw):
285 Fake.runs.append(argv)
286 Result.returncode = 1 if Fake.fail else 0
287 if not Fake.fail:
288 with open(argv[-1], "wb") as f:
289 f.write(b"video" * 100)
290 return Result
291
292 monkeypatch.setattr(addon.subprocess, "run", fake_run)
293 return Fake
294
295
296def test_remux_creates_the_destination_directory(fake_ffmpeg, tmp_path):
297 """The season folder will not exist on the first episode of a show. The old
298 code made MP4_DIR, which is the wrong volume entirely."""
299 dest = os.path.join(str(tmp_path), "Shows", "S (2020) [tmdbid-1]",
300 "Season 01", "S (2020) - S01E01.mp4")
301 addon._remux_to_mp4("http://cdn/x.m3u8", dest)
302 assert os.path.exists(dest)
303
304
305def test_remux_writes_to_part_then_renames(fake_ffmpeg, tmp_path):
306 """ffmpeg must be handed the .part path, never the final one: the moov atom
307 is written last, so a crash mid-remux would otherwise leave a truncated file
308 that _library_has reports as a complete copy."""
309 dest = os.path.join(str(tmp_path), "out.mp4")
310 addon._remux_to_mp4("http://cdn/x.m3u8", dest)
311 assert fake_ffmpeg.runs[0][-1] == addon._part_path(dest)
312 assert os.path.exists(dest)
313 assert not os.path.exists(addon._part_path(dest))
314
315
316def test_a_failed_remux_leaves_no_final_file(fake_ffmpeg, tmp_path):
317 dest = os.path.join(str(tmp_path), "out.mp4")
318 fake_ffmpeg.fail = True
319 with pytest.raises(RuntimeError):
320 addon._remux_to_mp4("http://cdn/x.m3u8", dest)
321 assert not os.path.exists(dest)
322
323
324# --- ingest orchestration ---------------------------------------------------
325
326@pytest.fixture
327def ingest(monkeypatch, tmdb):
328 """A resolvable series with a stubbed remux, so only orchestration runs."""
329 tmdb.responses["tv/1"] = {"name": "Show", "first_air_date": "2020-01-01"}
330 tmdb.responses["tv/1/season/1/episode/1"] = {"name": "Pilot"}
331 remuxed = []
332 monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "http://cdn/master.m3u8")
333 monkeypatch.setattr(addon, "_cap_variant", lambda url, h: f"{url}?h={h}")
334
335 def fake_remux(url, dest):
336 remuxed.append((url, dest))
337 os.makedirs(os.path.dirname(dest), exist_ok=True)
338 open(dest, "wb").write(b"x" * 10)
339
340 monkeypatch.setattr(addon, "_remux_to_mp4", fake_remux)
341 return remuxed
342
343
344def test_ingest_writes_into_the_library(ingest):
345 state = addon._run_ingest("series", "tmdb:1", 1, 1, 720)
346 assert state == "done"
347 dest = addon._library_path("series", "Show", "2020", "1",
348 season=1, episode=1, episode_title="Pilot")
349 assert os.path.exists(dest)
350 assert ingest and ingest[0][1] == dest
351
352
353def test_ingest_declines_a_title_it_cannot_identify(ingest, tmdb):
354 """No identity, no file -- not a guessed folder name."""
355 state = addon._run_ingest("series", "tmdb:999999", 1, 1, 720)
356 assert state.startswith("failed")
357 assert ingest == []
358
359
360def test_ingest_is_a_noop_when_the_library_already_has_it(ingest):
361 addon._run_ingest("series", "tmdb:1", 1, 1, 720)
362 ingest.clear()
363 state = addon._run_ingest("series", "tmdb:1", 1, 1, 1080)
364 assert state == "done"
365 assert ingest == [], "a second height must not produce a second file"
366
367
368def test_ingest_fails_cleanly_when_the_source_cannot_be_resolved(ingest, monkeypatch):
369 monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: None)
370 state = addon._run_ingest("series", "tmdb:1", 1, 1, 720)
371 assert state.startswith("failed")
372 assert ingest == []
373
374
375def test_ingest_resolves_at_run_time_not_at_queue_time(ingest, monkeypatch):
376 """Variant URLs are signed and expire in ~4h. A job that resolved when it
377 was queued would fetch a dead link once the queue backs up."""
378 calls = []
379 monkeypatch.setattr(addon, "_resolve_vidcore",
380 lambda *a, **k: calls.append(a) or "http://cdn/m.m3u8")
381 addon._run_ingest("series", "tmdb:1", 1, 1, 720)
382 assert calls, "the ingest must resolve for itself"