| … | … | @@ -533,7 +533,7 @@ TV_GENRES = [ |
| 533 | 533 | |
| 534 | 534 | MANIFEST = { |
| 535 | 535 | "id": "org.tabertron.vsembed", |
| 536 | "version": "0.9.4", | |
| 536 | "version": "0.9.5", | |
| 537 | 537 | "name": "Tabertron VsEmbed", |
| 538 | 538 | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 539 | 539 | "types": ["movie", "series"], |
| … | … | @@ -1336,13 +1336,40 @@ def _start_mp4_job(kind: str, ident: str, season: int, episode: int, height: int |
| 1336 | 1336 | return "running" |
| 1337 | 1337 | |
| 1338 | 1338 | |
| 1339 | def _playable_path(kind: str, ident: str, season: int = 0, episode: int = 0, | |
| 1340 | height: int = VIDCORE_MAX_HEIGHT) -> Optional[str]: | |
| 1341 | """The best complete local file for this title, or None. | |
| 1342 | ||
| 1343 | Library first, then the legacy mp4-cache. The fallback is not vestigial: | |
| 1344 | copies made before the library existed must stay reachable. Nothing here | |
| 1345 | deletes, so nothing here may orphan either. | |
| 1346 | ||
| 1347 | Falls back cleanly when TMDB is unreachable — _library_meta returns None and | |
| 1348 | the legacy path still answers. | |
| 1349 | """ | |
| 1350 | meta = _library_meta(kind, ident, season, episode) | |
| 1351 | if meta: | |
| 1352 | dest = _library_path(kind, meta["title"], meta["year"], meta["tmdb_id"], | |
| 1353 | season, episode, meta["episode_title"]) | |
| 1354 | try: | |
| 1355 | if os.path.getsize(dest) > 0: | |
| 1356 | return dest | |
| 1357 | except OSError: | |
| 1358 | pass | |
| 1359 | legacy = _mp4_path(kind, ident, season, episode, height) | |
| 1360 | try: | |
| 1361 | return legacy if os.path.getsize(legacy) > 0 else None | |
| 1362 | except OSError: | |
| 1363 | return None | |
| 1364 | ||
| 1365 | ||
| 1339 | 1366 | def _serve_mp4(kind: str, ident: str, season: int, episode: int): |
| 1340 | 1367 | height = _requested_height() |
| 1341 | if _mp4_ready(kind, ident, season, episode, height): | |
| 1368 | path = _playable_path(kind, ident, season, episode, height) | |
| 1369 | if path: | |
| 1342 | 1370 | # conditional=True is what answers Range requests, and Range is what |
| 1343 | 1371 | # makes the file seekable in the player. |
| 1344 | return send_file(_mp4_path(kind, ident, season, episode, height), | |
| 1345 | mimetype="video/mp4", conditional=True) | |
| 1372 | return send_file(path, mimetype="video/mp4", conditional=True) | |
| 1346 | 1373 | state = _start_mp4_job(kind, ident, season, episode, height) |
| 1347 | 1374 | # 503 rather than 404: the copy is being made, it is not an error. |
| 1348 | 1375 | return Response(f"remux {state}\n", status=503, mimetype="text/plain") |
| … | … | @@ -1385,19 +1412,25 @@ def _append_mp4_row(out: list, kind: str, ident: Optional[str], |
| 1385 | 1412 | if not ident: |
| 1386 | 1413 | return |
| 1387 | 1414 | for height in VIDCORE_HEIGHTS: |
| 1388 | if not _mp4_ready(kind, ident, season, episode, height): | |
| 1415 | local = _playable_path(kind, ident, season, episode, height) | |
| 1416 | if not local: | |
| 1389 | 1417 | continue |
| 1418 | in_library = local.startswith(LIBRARY_ROOT + os.sep) | |
| 1390 | 1419 | if kind == "series": |
| 1391 | 1420 | path = f"/mp4/series/{ident}:{season}:{episode}.mp4" |
| 1392 | 1421 | tag = f"S{season:02d}E{episode:02d} " |
| 1393 | 1422 | else: |
| 1394 | 1423 | path = f"/mp4/movie/{ident}.mp4" |
| 1395 | 1424 | tag = "" |
| 1425 | # A library copy is height-agnostic — one copy per episode — so the | |
| 1426 | # height is not part of its label. It is still sent in the query string | |
| 1427 | # because the legacy cache path is keyed by it. | |
| 1428 | label = "in library" if in_library else f"{height}p" | |
| 1396 | 1429 | out.append(_stream_dict(Stream( |
| 1397 | 1430 | url=f"http://{request.host}{path}?h={height}", |
| 1398 | quality=f"{height}p", name="local", | |
| 1431 | quality=label, name="local", | |
| 1399 | 1432 | referer="https://vidcore.net/", |
| 1400 | title_extra=f"{tag}{height}p seekable\nlocal copy"))) | |
| 1433 | title_extra=f"{tag}{label} seekable\nlocal copy"))) | |
| 1401 | 1434 | return |
| 1402 | 1435 | |
| 1403 | 1436 |
| … | … | @@ -372,6 +372,74 @@ def test_ingest_fails_cleanly_when_the_source_cannot_be_resolved(ingest, monkeyp |
| 372 | 372 | assert ingest == [] |
| 373 | 373 | |
| 374 | 374 | |
| 375 | # --- serving prefers the library, without orphaning the legacy cache -------- | |
| 376 | ||
| 377 | @pytest.fixture | |
| 378 | def served(monkeypatch, tmdb, tmp_path): | |
| 379 | tmdb.responses["tv/1"] = {"name": "Show", "first_air_date": "2020-01-01"} | |
| 380 | tmdb.responses["tv/1/season/1/episode/1"] = {"name": "Pilot"} | |
| 381 | monkeypatch.setattr(addon, "MP4_DIR", os.path.join(str(tmp_path), "cache")) | |
| 382 | os.makedirs(addon.MP4_DIR, exist_ok=True) | |
| 383 | monkeypatch.setattr(addon, "_prefetch_vidcore", lambda *a, **k: None) | |
| 384 | monkeypatch.setattr(addon, "_start_mp4_job", lambda *a, **k: "running") | |
| 385 | return tmdb | |
| 386 | ||
| 387 | ||
| 388 | def _write_library_copy(body=b"library"): | |
| 389 | dest = addon._library_path("series", "Show", "2020", "1", | |
| 390 | season=1, episode=1, episode_title="Pilot") | |
| 391 | os.makedirs(os.path.dirname(dest), exist_ok=True) | |
| 392 | open(dest, "wb").write(body) | |
| 393 | return dest | |
| 394 | ||
| 395 | ||
| 396 | def test_the_library_copy_is_what_gets_served(served): | |
| 397 | dest = _write_library_copy(b"library-bytes") | |
| 398 | assert addon._playable_path("series", "tmdb:1", 1, 1, 720) == dest | |
| 399 | ||
| 400 | ||
| 401 | def test_a_legacy_cache_copy_is_still_served(served): | |
| 402 | """The one file made by hand before the library existed must not become | |
| 403 | unreachable -- nothing is deleted, so nothing may be orphaned either.""" | |
| 404 | legacy = addon._mp4_path("series", "tmdb:1", 1, 1, 720) | |
| 405 | open(legacy, "wb").write(b"legacy") | |
| 406 | assert addon._playable_path("series", "tmdb:1", 1, 1, 720) == legacy | |
| 407 | ||
| 408 | ||
| 409 | def test_the_library_wins_over_a_legacy_copy(served): | |
| 410 | dest = _write_library_copy() | |
| 411 | open(addon._mp4_path("series", "tmdb:1", 1, 1, 720), "wb").write(b"legacy") | |
| 412 | assert addon._playable_path("series", "tmdb:1", 1, 1, 720) == dest | |
| 413 | ||
| 414 | ||
| 415 | def test_nothing_playable_yields_none(served): | |
| 416 | assert addon._playable_path("series", "tmdb:1", 1, 1, 720) is None | |
| 417 | ||
| 418 | ||
| 419 | def test_the_route_serves_the_library_copy_with_range_support(served): | |
| 420 | _write_library_copy(b"0123456789" * 10) | |
| 421 | client = addon.app.test_client() | |
| 422 | r = client.get("/mp4/series/tmdb:1:1:1.mp4?h=720", | |
| 423 | headers={"Range": "bytes=0-9"}) | |
| 424 | assert r.status_code == 206 # seeking depends on this | |
| 425 | assert r.get_data() == b"0123456789" | |
| 426 | ||
| 427 | ||
| 428 | def test_a_row_is_offered_once_the_library_holds_it(served): | |
| 429 | _write_library_copy() | |
| 430 | with addon.app.test_request_context("/stream/series/fxl:1:1:1.json"): | |
| 431 | out = [] | |
| 432 | addon._append_mp4_row(out, "series", "tmdb:1", 1, 1) | |
| 433 | assert len(out) == 1 and "/mp4/" in out[0]["url"] | |
| 434 | ||
| 435 | ||
| 436 | def test_no_playable_copy_means_no_row(served): | |
| 437 | with addon.app.test_request_context("/stream/series/fxl:1:1:1.json"): | |
| 438 | out = [] | |
| 439 | addon._append_mp4_row(out, "series", "tmdb:1", 1, 1) | |
| 440 | assert out == [] | |
| 441 | ||
| 442 | ||
| 375 | 443 | def test_ingest_resolves_at_run_time_not_at_queue_time(ingest, monkeypatch): |
| 376 | 444 | """Variant URLs are signed and expire in ~4h. A job that resolved when it |
| 377 | 445 | was queued would fetch a dead link once the queue backs up.""" |
| … | … | @@ -24,6 +24,11 @@ import addon |
| 24 | 24 | def tmp_cache(tmp_path, monkeypatch): |
| 25 | 25 | monkeypatch.setattr(addon, "MP4_DIR", str(tmp_path)) |
| 26 | 26 | monkeypatch.setattr(addon, "_mp4_jobs", {}) |
| 27 | # _playable_path consults TMDB to derive the library name. These tests cover | |
| 28 | # the LEGACY cache path and must never reach the network -- returning no | |
| 29 | # metadata is exactly the "TMDB unreachable" case, which must still serve a | |
| 30 | # cached copy. Library naming is covered in test_library_ingest.py. | |
| 31 | monkeypatch.setattr(addon, "_library_meta", lambda *a, **k: None) | |
| 27 | 32 | yield |
| 28 | 33 | |
| 29 | 34 |