| … | … | @@ -533,7 +533,7 @@ TV_GENRES = [ |
| 533 | 533 | |
| 534 | 534 | MANIFEST = { |
| 535 | 535 | "id": "org.tabertron.vsembed", |
| 536 | "version": "0.9.5", | |
| 536 | "version": "0.10.0", | |
| 537 | 537 | "name": "Tabertron VsEmbed", |
| 538 | 538 | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 539 | 539 | "types": ["movie", "series"], |
| … | … | @@ -1026,6 +1026,9 @@ def vidcore_movie(ident: str): |
| 1026 | 1026 | url = _resolve_vidcore("movie", ident) |
| 1027 | 1027 | if not url: |
| 1028 | 1028 | abort(404) |
| 1029 | # Playing is the honest "I chose this" signal, so keep a copy of what is | |
| 1030 | # being watched. Idempotent, and skipped when the library already has it. | |
| 1031 | _ensure_ingest("movie", ident, height=_requested_height()) | |
| 1029 | 1032 | return _serve_vidcore(url) |
| 1030 | 1033 | |
| 1031 | 1034 | |
| … | … | @@ -1038,6 +1041,8 @@ def vidcore_series(ident: str): |
| 1038 | 1041 | url = _resolve_vidcore("series", m.group(1), int(m.group(2)), int(m.group(3))) |
| 1039 | 1042 | if not url: |
| 1040 | 1043 | abort(404) |
| 1044 | _ensure_ingest("series", m.group(1), int(m.group(2)), int(m.group(3)), | |
| 1045 | height=_requested_height()) | |
| 1041 | 1046 | return _serve_vidcore(url) |
| 1042 | 1047 | |
| 1043 | 1048 | |
| … | … | @@ -1363,6 +1368,22 @@ def _playable_path(kind: str, ident: str, season: int = 0, episode: int = 0, |
| 1363 | 1368 | return None |
| 1364 | 1369 | |
| 1365 | 1370 | |
| 1371 | def _ensure_ingest(kind: str, ident: str, season: int = 0, episode: int = 0, | |
| 1372 | height: int = VIDCORE_MAX_HEIGHT) -> None: | |
| 1373 | """Start a copy of this title unless there is a reason not to. | |
| 1374 | ||
| 1375 | Idempotent: _start_mp4_job already refuses to run the same key twice, so | |
| 1376 | repeated plays of the same episode enqueue nothing new. | |
| 1377 | """ | |
| 1378 | if not ident: | |
| 1379 | return | |
| 1380 | if not _library_meta(kind, ident, season, episode): | |
| 1381 | return # cannot be named, so cannot be ingested | |
| 1382 | if _library_has(kind, ident, season, episode): | |
| 1383 | return | |
| 1384 | _start_mp4_job(kind, ident, season, episode, height) | |
| 1385 | ||
| 1386 | ||
| 1366 | 1387 | def _serve_mp4(kind: str, ident: str, season: int, episode: int): |
| 1367 | 1388 | height = _requested_height() |
| 1368 | 1389 | path = _playable_path(kind, ident, season, episode, height) |
| … | … | @@ -1370,9 +1391,15 @@ def _serve_mp4(kind: str, ident: str, season: int, episode: int): |
| 1370 | 1391 | # conditional=True is what answers Range requests, and Range is what |
| 1371 | 1392 | # makes the file seekable in the player. |
| 1372 | 1393 | return send_file(path, mimetype="video/mp4", conditional=True) |
| 1373 | state = _start_mp4_job(kind, ident, season, episode, height) | |
| 1374 | # 503 rather than 404: the copy is being made, it is not an error. | |
| 1375 | return Response(f"remux {state}\n", status=503, mimetype="text/plain") | |
| 1394 | # No copy yet. Start one, and hand the player the live stream meanwhile: | |
| 1395 | # this row has to be playable the instant it is clicked, and a 503 would | |
| 1396 | # surface as a playback failure while the copy silently proceeded. The same | |
| 1397 | # URL serves the local file once it lands, so the row upgrades itself. | |
| 1398 | _ensure_ingest(kind, ident, season, episode, height) | |
| 1399 | url = _resolve_vidcore(kind, ident, season, episode) | |
| 1400 | if not url: | |
| 1401 | abort(404) | |
| 1402 | return _serve_vidcore(url) | |
| 1376 | 1403 | |
| 1377 | 1404 | |
| 1378 | 1405 | @app.route("/mp4/movie/<path:ident>.mp4") |
| … | … | @@ -1411,27 +1438,40 @@ def _append_mp4_row(out: list, kind: str, ident: Optional[str], |
| 1411 | 1438 | """ |
| 1412 | 1439 | if not ident: |
| 1413 | 1440 | return |
| 1441 | if kind == "series": | |
| 1442 | path = f"/mp4/series/{ident}:{season}:{episode}.mp4" | |
| 1443 | tag = f"S{season:02d}E{episode:02d} " | |
| 1444 | else: | |
| 1445 | path = f"/mp4/movie/{ident}.mp4" | |
| 1446 | tag = "" | |
| 1447 | ||
| 1448 | def row(label: str, height: int) -> None: | |
| 1449 | out.append(_stream_dict(Stream( | |
| 1450 | url=f"http://{request.host}{path}?h={height}", | |
| 1451 | quality=label, name="local", | |
| 1452 | referer="https://vidcore.net/", | |
| 1453 | title_extra=f"{tag}{label}\nlocal copy"))) | |
| 1454 | ||
| 1414 | 1455 | for height in VIDCORE_HEIGHTS: |
| 1415 | 1456 | local = _playable_path(kind, ident, season, episode, height) |
| 1416 | 1457 | if not local: |
| 1417 | 1458 | continue |
| 1418 | in_library = local.startswith(LIBRARY_ROOT + os.sep) | |
| 1419 | if kind == "series": | |
| 1420 | path = f"/mp4/series/{ident}:{season}:{episode}.mp4" | |
| 1421 | tag = f"S{season:02d}E{episode:02d} " | |
| 1422 | else: | |
| 1423 | path = f"/mp4/movie/{ident}.mp4" | |
| 1424 | tag = "" | |
| 1425 | 1459 | # A library copy is height-agnostic — one copy per episode — so the |
| 1426 | 1460 | # 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" | |
| 1429 | out.append(_stream_dict(Stream( | |
| 1430 | url=f"http://{request.host}{path}?h={height}", | |
| 1431 | quality=label, name="local", | |
| 1432 | referer="https://vidcore.net/", | |
| 1433 | title_extra=f"{tag}{label} seekable\nlocal copy"))) | |
| 1434 | return | |
| 1461 | # because the legacy cache path IS keyed by it. | |
| 1462 | in_library = local.startswith(LIBRARY_ROOT + os.sep) | |
| 1463 | return row("in library — seekable" if in_library | |
| 1464 | else f"{height}p seekable", height) | |
| 1465 | ||
| 1466 | # No copy yet, but the row is still offered: it plays the live stream and | |
| 1467 | # keeps a copy as it goes. Offering it ONLY once a copy existed is what made | |
| 1468 | # the feature unreachable — the row had to exist for a copy to be made, and | |
| 1469 | # a copy had to exist for the row to be offered. | |
| 1470 | if not _library_meta(kind, ident, season, episode): | |
| 1471 | return # cannot be named, so the row would be a lie | |
| 1472 | running = _mp4_jobs.get( | |
| 1473 | _mp4_key(kind, ident, season, episode, VIDCORE_MAX_HEIGHT)) == "running" | |
| 1474 | row("saving to library…" if running else "play & keep", VIDCORE_MAX_HEIGHT) | |
| 1435 | 1475 | |
| 1436 | 1476 | |
| 1437 | 1477 | def _append_vidcore_row(out: list, kind: str, ident: Optional[str], |
| … | … | @@ -433,10 +433,87 @@ def test_a_row_is_offered_once_the_library_holds_it(served): |
| 433 | 433 | assert len(out) == 1 and "/mp4/" in out[0]["url"] |
| 434 | 434 | |
| 435 | 435 | |
| 436 | def test_no_playable_copy_means_no_row(served): | |
| 436 | def test_no_copy_yet_still_offers_a_row_that_will_make_one(served): | |
| 437 | """Superseded the old 'no copy means no row'. That WAS the chicken-and-egg: | |
| 438 | withholding the row until a copy existed meant no copy could ever be made | |
| 439 | through the UI.""" | |
| 437 | 440 | with addon.app.test_request_context("/stream/series/fxl:1:1:1.json"): |
| 438 | 441 | out = [] |
| 439 | 442 | addon._append_mp4_row(out, "series", "tmdb:1", 1, 1) |
| 443 | assert len(out) == 1 | |
| 444 | assert "in library" not in out[0]["title"] | |
| 445 | ||
| 446 | ||
| 447 | # --- triggers --------------------------------------------------------------- | |
| 448 | # Until now nothing could START an ingest: _append_mp4_row only offered a /mp4/ | |
| 449 | # row once the copy existed, and only a /mp4/ request created one. The two | |
| 450 | # guarded each other, so the single file on disk was made by hand. | |
| 451 | ||
| 452 | @pytest.fixture | |
| 453 | def triggered(monkeypatch, tmdb, tmp_path): | |
| 454 | tmdb.responses["tv/1"] = {"name": "Show", "first_air_date": "2020-01-01"} | |
| 455 | tmdb.responses["tv/1/season/1/episode/1"] = {"name": "Pilot"} | |
| 456 | monkeypatch.setattr(addon, "MP4_DIR", os.path.join(str(tmp_path), "cache")) | |
| 457 | os.makedirs(addon.MP4_DIR, exist_ok=True) | |
| 458 | monkeypatch.setattr(addon, "_prefetch_vidcore", lambda *a, **k: None) | |
| 459 | monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "http://cdn/m.m3u8") | |
| 460 | monkeypatch.setattr(addon, "_cap_variant", lambda url, h: f"{url}?h={h}") | |
| 461 | started = [] | |
| 462 | monkeypatch.setattr(addon, "_start_mp4_job", | |
| 463 | lambda *a, **k: started.append(a) or "running") | |
| 464 | return started | |
| 465 | ||
| 466 | ||
| 467 | def test_playing_a_stream_starts_the_copy(triggered): | |
| 468 | """Pressing play is the honest 'I chose this' signal.""" | |
| 469 | addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720") | |
| 470 | assert triggered, "playing must enqueue the copy" | |
| 471 | ||
| 472 | ||
| 473 | def test_playing_does_not_re_fetch_what_the_library_already_has(triggered): | |
| 474 | _write_library_copy() | |
| 475 | addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720") | |
| 476 | assert triggered == [] | |
| 477 | ||
| 478 | ||
| 479 | def test_an_unfinished_copy_redirects_to_the_live_stream(triggered): | |
| 480 | """The row must be playable the moment it is clicked. 503 would surface as a | |
| 481 | playback failure while the copy silently proceeded.""" | |
| 482 | r = addon.app.test_client().get("/mp4/series/tmdb:1:1:1.mp4?h=720") | |
| 483 | assert r.status_code == 302 | |
| 484 | assert "cdn" in r.headers["Location"] | |
| 485 | assert triggered, "and it must start the copy" | |
| 486 | ||
| 487 | ||
| 488 | def test_a_finished_copy_is_served_rather_than_redirected(triggered): | |
| 489 | _write_library_copy(b"0123456789") | |
| 490 | r = addon.app.test_client().get("/mp4/series/tmdb:1:1:1.mp4?h=720") | |
| 491 | assert r.status_code == 200 | |
| 492 | assert triggered == [] | |
| 493 | ||
| 494 | ||
| 495 | def test_the_keep_row_is_offered_before_any_copy_exists(triggered): | |
| 496 | """This is the chicken-and-egg: the row had to exist for a copy to be made, | |
| 497 | and a copy had to exist for the row to be offered.""" | |
| 498 | with addon.app.test_request_context("/stream/series/fxl:1:1:1.json"): | |
| 499 | out = [] | |
| 500 | addon._append_mp4_row(out, "series", "tmdb:1", 1, 1) | |
| 501 | assert len(out) == 1 and "/mp4/" in out[0]["url"] | |
| 502 | ||
| 503 | ||
| 504 | def test_the_row_says_when_the_copy_is_already_held(triggered): | |
| 505 | _write_library_copy() | |
| 506 | with addon.app.test_request_context("/stream/series/fxl:1:1:1.json"): | |
| 507 | out = [] | |
| 508 | addon._append_mp4_row(out, "series", "tmdb:1", 1, 1) | |
| 509 | assert "in library" in out[0]["title"] | |
| 510 | ||
| 511 | ||
| 512 | def test_an_unidentifiable_title_offers_no_keep_row(triggered): | |
| 513 | """Nothing can be ingested without an identity, so the row would be a lie.""" | |
| 514 | with addon.app.test_request_context("/stream/series/fxl:9:1:1.json"): | |
| 515 | out = [] | |
| 516 | addon._append_mp4_row(out, "series", "tmdb:999999", 1, 1) | |
| 440 | 517 | assert out == [] |
| 441 | 518 | |
| 442 | 519 |
| … | … | @@ -100,12 +100,22 @@ def test_ready_file_is_served_with_range_support(client): |
| 100 | 100 | assert r.get_data() == b"0123456789" |
| 101 | 101 | |
| 102 | 102 | |
| 103 | def test_a_missing_file_reports_not_ready_and_starts_the_job(client): | |
| 103 | def test_a_missing_file_falls_through_to_the_live_stream(client, monkeypatch): | |
| 104 | """Was 503. The row has to be playable the instant it is clicked, so a | |
| 105 | missing copy now redirects to the live stream while the copy is made. | |
| 106 | ||
| 107 | This fixture leaves _library_meta returning None (the TMDB-unreachable | |
| 108 | case), so no ingest is started -- a title we cannot name must not be | |
| 109 | written into the library under a guessed name. | |
| 110 | """ | |
| 104 | 111 | started = [] |
| 105 | addon._start_mp4_job = lambda *a, **k: started.append(a) or "started" | |
| 112 | monkeypatch.setattr(addon, "_start_mp4_job", | |
| 113 | lambda *a, **k: started.append(a) or "started") | |
| 114 | monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "http://cdn/m.m3u8") | |
| 115 | monkeypatch.setattr(addon, "_cap_variant", lambda url, h: url) | |
| 106 | 116 | r = client.get("/mp4/series/tmdb:138502:2:9.mp4?h=720") |
| 107 | assert r.status_code == 503 # not an error -- "come back later" | |
| 108 | assert started | |
| 117 | assert r.status_code == 302 | |
| 118 | assert started == [], "unnameable titles are not ingested" | |
| 109 | 119 | |
| 110 | 120 | |
| 111 | 121 | def test_a_bad_ident_is_rejected(client): |