| … | … | @@ -533,7 +533,7 @@ TV_GENRES = [ |
| 533 | 533 | |
| 534 | 534 | MANIFEST = { |
| 535 | 535 | "id": "org.tabertron.vsembed", |
| 536 | "version": "0.9.3", | |
| 536 | "version": "0.9.4", | |
| 537 | 537 | "name": "Tabertron VsEmbed", |
| 538 | 538 | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 539 | 539 | "types": ["movie", "series"], |
| … | … | @@ -1247,8 +1247,10 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None: |
| 1247 | 1247 | seeked over HTTP. Written to `.part` and renamed, so a crashed or killed |
| 1248 | 1248 | remux can never leave a half file looking complete. |
| 1249 | 1249 | """ |
| 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) | |
| 1252 | 1254 | proc = subprocess.run( |
| 1253 | 1255 | ["ffmpeg", "-v", "error", "-y", |
| 1254 | 1256 | "-headers", "Referer: https://vidcore.net/\r\n", |
| … | … | @@ -1264,8 +1266,51 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None: |
| 1264 | 1266 | os.replace(tmp, out_path) |
| 1265 | 1267 | |
| 1266 | 1268 | |
| 1269 | def _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 | ||
| 1267 | 1307 | def _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 | """ | |
| 1269 | 1314 | key = _mp4_key(kind, ident, season, episode, height) |
| 1270 | 1315 | with _mp4_jobs_guard: |
| 1271 | 1316 | state = _mp4_jobs.get(key) |
| … | … | @@ -1276,27 +1321,16 @@ def _start_mp4_job(kind: str, ident: str, season: int, episode: int, height: int |
| 1276 | 1321 | def run() -> None: |
| 1277 | 1322 | acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT) |
| 1278 | 1323 | 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}" | |
| 1297 | 1329 | finally: |
| 1298 | 1330 | if acquired: |
| 1299 | 1331 | _mp4_sem.release() |
| 1332 | with _mp4_jobs_guard: | |
| 1333 | _mp4_jobs[key] = state | |
| 1300 | 1334 | |
| 1301 | 1335 | threading.Thread(target=run, name=f"mp4-{key}", daemon=True).start() |
| 1302 | 1336 | return "running" |
| … | … | @@ -265,3 +265,118 @@ def test_an_existing_copy_at_any_height_counts_as_present(tmdb): |
| 265 | 265 | |
| 266 | 266 | def test_an_unidentifiable_title_is_reported_absent_not_crashing(tmdb): |
| 267 | 267 | 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 | |
| 273 | def 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 | ||
| 296 | def 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 | ||
| 305 | def 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 | ||
| 316 | def 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 | |
| 327 | def 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 | ||
| 344 | def 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 | ||
| 353 | def 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 | ||
| 360 | def 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 | ||
| 368 | def 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 | ||
| 375 | def 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" |