| … | … | @@ -595,7 +595,7 @@ TV_GENRES = [ |
| 595 | 595 | |
| 596 | 596 | MANIFEST = { |
| 597 | 597 | "id": "org.tabertron.vsembed", |
| 598 | "version": "0.13.1", | |
| 598 | "version": "0.13.2", | |
| 599 | 599 | "name": "Tabertron VsEmbed", |
| 600 | 600 | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 601 | 601 | "types": ["movie", "series"], |
| … | … | @@ -1437,17 +1437,89 @@ def _library_has(kind: str, ident: str, season: int = 0, |
| 1437 | 1437 | return False |
| 1438 | 1438 | |
| 1439 | 1439 | |
| 1440 | # How much of the source a remux must actually contain before it is published. | |
| 1441 | # | |
| 1442 | # An exit code cannot answer this. ffmpeg treats an HLS input that stops early | |
| 1443 | # -- an expired token, a 403 on a segment part-way down the ladder -- as end of | |
| 1444 | # stream, not as an error: it finalises the moov atom over what it got and exits | |
| 1445 | # 0. On 2026-08-26 that published Strange New Worlds S04E04 at 10.6 of 51 | |
| 1446 | # minutes (21%), S04E05 at 9.8 of 55 (18%) and Furious S01E07 at 27.7 of 52 | |
| 1447 | # (53%), each logged as `ingest done`. | |
| 1448 | # | |
| 1449 | # The tolerance absorbs the drift between an EXTINF sum and a container | |
| 1450 | # duration, which do not agree to the frame. It does not need to be generous: | |
| 1451 | # the smallest real loss observed was a little over half the episode, and | |
| 1452 | # failing here is cheap -- the copy is simply retried -- whereas failing to | |
| 1453 | # catch one is permanent, because _library_has asks only whether a non-empty | |
| 1454 | # file exists and will answer "done" for ever after. | |
| 1455 | REMUX_MIN_COMPLETENESS = 0.99 | |
| 1456 | ||
| 1457 | ||
| 1458 | def _parse_playlist_duration(body: str) -> Optional[float]: | |
| 1459 | """Total seconds of a media playlist, summed from its #EXTINF tags. | |
| 1460 | ||
| 1461 | None when there are none to sum -- a master playlist, or a body that is not | |
| 1462 | a playlist at all. That has to be distinct from 0.0: zero compares as | |
| 1463 | complete against anything and would wave through the exact failure this | |
| 1464 | exists to catch. | |
| 1465 | """ | |
| 1466 | total = 0.0 | |
| 1467 | found = False | |
| 1468 | for line in body.splitlines(): | |
| 1469 | if not line.startswith("#EXTINF:"): | |
| 1470 | continue | |
| 1471 | try: | |
| 1472 | total += float(line[len("#EXTINF:"):].split(",")[0]) | |
| 1473 | found = True | |
| 1474 | except ValueError: | |
| 1475 | continue | |
| 1476 | return total if found else None | |
| 1477 | ||
| 1478 | ||
| 1479 | def _playlist_duration(variant_url: str) -> Optional[float]: | |
| 1480 | """How long the source says this variant is. None if it cannot be asked.""" | |
| 1481 | try: | |
| 1482 | # Deliberately NOT _http_get, for the same reason as _variants: that | |
| 1483 | # injects a random X-Forwarded-For and these tokens are IP-CIDR locked. | |
| 1484 | r = requests.get(variant_url, timeout=REQUEST_TIMEOUT, | |
| 1485 | headers={"User-Agent": UA, | |
| 1486 | "Referer": "https://vidcore.net/"}) | |
| 1487 | r.raise_for_status() | |
| 1488 | return _parse_playlist_duration(r.text) | |
| 1489 | except Exception as exc: | |
| 1490 | log.warning("remux: could not read source duration (%s)", exc) | |
| 1491 | return None | |
| 1492 | ||
| 1493 | ||
| 1494 | def _media_duration(path: str) -> Optional[float]: | |
| 1495 | """Seconds of playable content in a local file, or None if unreadable.""" | |
| 1496 | try: | |
| 1497 | out = subprocess.run( | |
| 1498 | ["ffprobe", "-v", "error", "-show_entries", "format=duration", | |
| 1499 | "-of", "default=nw=1:nk=1", path], | |
| 1500 | capture_output=True, text=True, timeout=60).stdout.strip() | |
| 1501 | return float(out) if out else None | |
| 1502 | except Exception as exc: | |
| 1503 | log.warning("remux: could not probe %s (%s)", os.path.basename(path), exc) | |
| 1504 | return None | |
| 1505 | ||
| 1506 | ||
| 1440 | 1507 | def _remux_to_mp4(variant_url: str, out_path: str) -> None: |
| 1441 | 1508 | """Stream-copy an HLS variant into a progressive MP4. |
| 1442 | 1509 | |
| 1443 | 1510 | `-movflags +faststart` puts the moov atom at the front so the file can be |
| 1444 | 1511 | seeked over HTTP. Written to `.part` and renamed, so a crashed or killed |
| 1445 | remux can never leave a half file looking complete. | |
| 1512 | remux can never leave a half file looking complete -- and, since ffmpeg's | |
| 1513 | exit code says nothing about how much of the source it got, only renamed | |
| 1514 | once the output has been measured against the playlist it came from. | |
| 1446 | 1515 | """ |
| 1447 | 1516 | tmp = _part_path(out_path) |
| 1448 | 1517 | # The destination's OWN directory, not MP4_DIR: an ingest writes into the |
| 1449 | 1518 | # library, where the show folder and season folder may not exist yet. |
| 1450 | 1519 | os.makedirs(os.path.dirname(out_path), exist_ok=True) |
| 1520 | # Asked BEFORE the remux: these playlists are signed and expire in ~4h, so | |
| 1521 | # after a 45-minute copy the URL may no longer answer. | |
| 1522 | expected = _playlist_duration(variant_url) | |
| 1451 | 1523 | proc = subprocess.run( |
| 1452 | 1524 | ["ffmpeg", "-v", "error", "-y", |
| 1453 | 1525 | "-headers", "Referer: https://vidcore.net/\r\n", |
| … | … | @@ -1460,6 +1532,22 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None: |
| 1460 | 1532 | ) |
| 1461 | 1533 | if proc.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0: |
| 1462 | 1534 | raise RuntimeError(f"ffmpeg exit {proc.returncode}: {(proc.stderr or '')[-300:]}") |
| 1535 | if expected: | |
| 1536 | actual = _media_duration(tmp) | |
| 1537 | # An unmeasurable output is refused rather than waved through: we know | |
| 1538 | # what it should be, so failing to read it back is a broken file. The | |
| 1539 | # reverse -- an unmeasurable SOURCE -- falls back to publishing, because | |
| 1540 | # a check that cannot run must not become a new way for a working | |
| 1541 | # stream to fail (the same rule _cap_variant follows). | |
| 1542 | if actual is None or actual < expected * REMUX_MIN_COMPLETENESS: | |
| 1543 | # The .part is deliberately left behind. Nothing is deleted to | |
| 1544 | # recover; ffmpeg runs with -y and writes the same path, so the | |
| 1545 | # retry overwrites it and the evidence survives until it does. | |
| 1546 | raise RuntimeError( | |
| 1547 | f"incomplete remux: got {actual if actual else 0:.0f}s of " | |
| 1548 | f"{expected:.0f}s ({(actual or 0) / expected * 100:.0f}%) — " | |
| 1549 | f"not published") | |
| 1550 | log.info("remux verified: %.0fs of %.0fs", actual, expected) | |
| 1463 | 1551 | os.replace(tmp, out_path) |
| 1464 | 1552 | |
| 1465 | 1553 |
| … | … | @@ -276,6 +276,10 @@ def fake_ffmpeg(monkeypatch): |
| 276 | 276 | class Fake: |
| 277 | 277 | runs = [] |
| 278 | 278 | fail = False |
| 279 | # What the source says it is, and what ffmpeg actually produced. Equal | |
| 280 | # by default: the interesting cases set them apart. | |
| 281 | playlist_seconds = 1800.0 | |
| 282 | output_seconds = 1800.0 | |
| 279 | 283 | |
| 280 | 284 | class Result: |
| 281 | 285 | returncode = 0 |
| … | … | @@ -290,6 +294,10 @@ def fake_ffmpeg(monkeypatch): |
| 290 | 294 | return Result |
| 291 | 295 | |
| 292 | 296 | monkeypatch.setattr(addon.subprocess, "run", fake_run) |
| 297 | monkeypatch.setattr(addon, "_playlist_duration", | |
| 298 | lambda url: Fake.playlist_seconds) | |
| 299 | monkeypatch.setattr(addon, "_media_duration", | |
| 300 | lambda path: Fake.output_seconds) | |
| 293 | 301 | return Fake |
| 294 | 302 | |
| 295 | 303 | |
| … | … | @@ -321,6 +329,99 @@ def test_a_failed_remux_leaves_no_final_file(fake_ffmpeg, tmp_path): |
| 321 | 329 | assert not os.path.exists(dest) |
| 322 | 330 | |
| 323 | 331 | |
| 332 | # --- the remux is only done when it reached the end ------------------------- | |
| 333 | # | |
| 334 | # 2026-08-26: Strange New Worlds S04E04 and S04E05 were ingested as 10.6 and 9.8 | |
| 335 | # minutes of a 51- and 55-minute episode, and "Furious" S01E07 as 27.7 of 52. | |
| 336 | # ffmpeg exited 0 on all three and the log said `ingest done`. An HLS input that | |
| 337 | # stops early -- an expired token, a 403 on a segment part-way through the | |
| 338 | # ladder -- is an end-of-stream to ffmpeg, not an error: it finalises the moov | |
| 339 | # atom over what it got and reports success. os.replace then published a whole | |
| 340 | # file containing a fifth of an episode, and because _library_has asks only | |
| 341 | # "does a non-empty file exist", every later request answered "done" and no | |
| 342 | # re-fetch was ever attempted. A partial copy is worse than no copy: no copy | |
| 343 | # retries, a partial one is permanent. | |
| 344 | # | |
| 345 | # So an exit code is not evidence of completeness. The playlist states its own | |
| 346 | # length in EXTINF and the output can be measured, so compare them. | |
| 347 | ||
| 348 | def test_a_remux_that_stopped_early_is_not_published(fake_ffmpeg, tmp_path): | |
| 349 | """The SNW S04E04 shape: ffmpeg exit 0, real file, one fifth of the run.""" | |
| 350 | dest = os.path.join(str(tmp_path), "out.mp4") | |
| 351 | fake_ffmpeg.playlist_seconds = 3009.6 | |
| 352 | fake_ffmpeg.output_seconds = 636.7 | |
| 353 | with pytest.raises(RuntimeError, match="incomplete"): | |
| 354 | addon._remux_to_mp4("http://cdn/x.m3u8", dest) | |
| 355 | assert not os.path.exists(dest) | |
| 356 | ||
| 357 | ||
| 358 | def test_the_partial_is_kept_so_the_next_run_overwrites_it(fake_ffmpeg, tmp_path): | |
| 359 | """Nothing is deleted to recover. ffmpeg runs with -y and writes the same | |
| 360 | path, so leaving the .part costs nothing and keeps the evidence.""" | |
| 361 | dest = os.path.join(str(tmp_path), "out.mp4") | |
| 362 | fake_ffmpeg.playlist_seconds = 3009.6 | |
| 363 | fake_ffmpeg.output_seconds = 636.7 | |
| 364 | with pytest.raises(RuntimeError): | |
| 365 | addon._remux_to_mp4("http://cdn/x.m3u8", dest) | |
| 366 | assert os.path.exists(addon._part_path(dest)) | |
| 367 | ||
| 368 | ||
| 369 | def test_a_complete_remux_is_published(fake_ffmpeg, tmp_path): | |
| 370 | dest = os.path.join(str(tmp_path), "out.mp4") | |
| 371 | fake_ffmpeg.playlist_seconds = 3009.6 | |
| 372 | fake_ffmpeg.output_seconds = 3009.5 | |
| 373 | addon._remux_to_mp4("http://cdn/x.m3u8", dest) | |
| 374 | assert os.path.exists(dest) | |
| 375 | ||
| 376 | ||
| 377 | def test_a_dropped_final_segment_is_not_treated_as_truncation(fake_ffmpeg, tmp_path): | |
| 378 | """EXTINF sums and container duration do not have to agree to the frame. | |
| 379 | The tolerance must absorb that drift while still catching a real loss -- | |
| 380 | the smallest real one seen was 53% of the episode.""" | |
| 381 | dest = os.path.join(str(tmp_path), "out.mp4") | |
| 382 | fake_ffmpeg.playlist_seconds = 3009.6 | |
| 383 | fake_ffmpeg.output_seconds = 3009.6 * addon.REMUX_MIN_COMPLETENESS | |
| 384 | addon._remux_to_mp4("http://cdn/x.m3u8", dest) | |
| 385 | assert os.path.exists(dest) | |
| 386 | ||
| 387 | ||
| 388 | def test_an_unmeasurable_playlist_does_not_block_a_good_ingest(fake_ffmpeg, tmp_path, | |
| 389 | monkeypatch): | |
| 390 | """Same rule as _cap_variant: a check that cannot run must not become a new | |
| 391 | way for a working stream to fail. Unverifiable falls back to the old | |
| 392 | behaviour rather than refusing.""" | |
| 393 | dest = os.path.join(str(tmp_path), "out.mp4") | |
| 394 | monkeypatch.setattr(addon, "_playlist_duration", lambda url: None) | |
| 395 | addon._remux_to_mp4("http://cdn/x.m3u8", dest) | |
| 396 | assert os.path.exists(dest) | |
| 397 | ||
| 398 | ||
| 399 | def test_an_unmeasurable_output_is_refused(fake_ffmpeg, tmp_path, monkeypatch): | |
| 400 | """The opposite case is NOT symmetric. If the playlist told us how long the | |
| 401 | episode is and the output cannot be probed at all, that is a broken file, | |
| 402 | not a missing measurement.""" | |
| 403 | dest = os.path.join(str(tmp_path), "out.mp4") | |
| 404 | monkeypatch.setattr(addon, "_media_duration", lambda path: None) | |
| 405 | with pytest.raises(RuntimeError, match="incomplete"): | |
| 406 | addon._remux_to_mp4("http://cdn/x.m3u8", dest) | |
| 407 | assert not os.path.exists(dest) | |
| 408 | ||
| 409 | ||
| 410 | def test_playlist_duration_sums_extinf(): | |
| 411 | body = ("#EXTM3U\n#EXT-X-PLAYLIST-TYPE:VOD\n" | |
| 412 | "#EXTINF:6.006,\nseg0.m4s\n" | |
| 413 | "#EXTINF:6.006,\nseg1.m4s\n" | |
| 414 | "#EXTINF:3.500,\nseg2.m4s\n" | |
| 415 | "#EXT-X-ENDLIST\n") | |
| 416 | assert addon._parse_playlist_duration(body) == pytest.approx(15.512) | |
| 417 | ||
| 418 | ||
| 419 | def test_a_playlist_with_no_segments_has_no_duration(): | |
| 420 | """A master playlist carries no EXTINF. Reporting 0 would make every ingest | |
| 421 | look complete; reporting nothing makes it unverifiable, which is honest.""" | |
| 422 | assert addon._parse_playlist_duration("#EXTM3U\n#EXT-X-STREAM-INF:x\na.m3u8\n") is None | |
| 423 | ||
| 424 | ||
| 324 | 425 | # --- ingest orchestration --------------------------------------------------- |
| 325 | 426 | |
| 326 | 427 | @pytest.fixture |