OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 45bf97a

An ingest is only done when it reached the end (0.13.2)
Strange New Worlds S04E04 sat in the library as 10.6 minutes of a 51-minute episode, S04E05 as 9.8 of 55, and Furious S01E07 as 27.7 of 52. All three were logged `ingest done`, and the S04E04 file is not damaged: ftyp/moov/free/mdat, mdat ending exactly at EOF, both streams consistent at 636s. It is a correctly finalised MP4 of a fifth of an episode. ffmpeg does not report an HLS input that stops early -- an expired token, a 403 on a segment part-way down the ladder -- as an error. It is end of stream: the moov atom is written over what arrived and the exit code is 0. _remux_to_mp4 judged completeness by that exit code and a non-zero size, so os.replace published it. That made the damage permanent rather than transient. _library_has asks only whether a non-empty file exists, so every later request answered done and no re-fetch was attempted; and because _local_redirect runs before the resolve, the truncated copy was then served in place of a working upstream stream. No copy retries. A partial copy does not. The playlist states its own length in EXTINF and the output can be probed, so compare them and refuse to publish below REMUX_MIN_COMPLETENESS. The .part is left in place -- nothing is deleted to recover, ffmpeg runs with -y and the retry overwrites it. The tolerance is measured, not guessed: a complete S04E03 is 2874.872s against an upstream playlist of 2874.789s, 100.003%, drifting 0.08s. 0.99 clears that with room and still catches the smallest real loss seen, 53%. An unreadable source falls back to publishing -- a check that cannot run must not become a new way for a working stream to fail, the rule _cap_variant already follows. An unreadable output is refused: we know what it should be, so failing to read it back is a broken file, not a missing measurement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
45bf97ae5062dbdd2971d13e9c3f733dea5602b5
by plankatron <fleeky@gmail.com> · 2026-08-31T14:24:20.000Z

Changed files (2) +191 -2

addon.py +90 -2
……@@ -595,7 +595,7 @@ TV_GENRES = [
595595
596596MANIFEST = {
597597 "id": "org.tabertron.vsembed",
598 "version": "0.13.1",
598 "version": "0.13.2",
599599 "name": "Tabertron VsEmbed",
600600 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
601601 "types": ["movie", "series"],
……@@ -1437,17 +1437,89 @@ def _library_has(kind: str, ident: str, season: int = 0,
14371437 return False
14381438
14391439
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.
1455REMUX_MIN_COMPLETENESS = 0.99
1456
1457
1458def _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
1479def _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
1494def _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
14401507def _remux_to_mp4(variant_url: str, out_path: str) -> None:
14411508 """Stream-copy an HLS variant into a progressive MP4.
14421509
14431510 `-movflags +faststart` puts the moov atom at the front so the file can be
14441511 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.
14461515 """
14471516 tmp = _part_path(out_path)
14481517 # The destination's OWN directory, not MP4_DIR: an ingest writes into the
14491518 # library, where the show folder and season folder may not exist yet.
14501519 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)
14511523 proc = subprocess.run(
14521524 ["ffmpeg", "-v", "error", "-y",
14531525 "-headers", "Referer: https://vidcore.net/\r\n",
……@@ -1460,6 +1532,22 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None:
14601532 )
14611533 if proc.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0:
14621534 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)
14631551 os.replace(tmp, out_path)
14641552
14651553
test_library_ingest.py +101 -0
……@@ -276,6 +276,10 @@ def fake_ffmpeg(monkeypatch):
276276 class Fake:
277277 runs = []
278278 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
279283
280284 class Result:
281285 returncode = 0
……@@ -290,6 +294,10 @@ def fake_ffmpeg(monkeypatch):
290294 return Result
291295
292296 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)
293301 return Fake
294302
295303
……@@ -321,6 +329,99 @@ def test_a_failed_remux_leaves_no_final_file(fake_ffmpeg, tmp_path):
321329 assert not os.path.exists(dest)
322330
323331
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
348def 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
358def 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
369def 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
377def 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
388def 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
399def 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
410def 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
419def 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
324425# --- ingest orchestration ---------------------------------------------------
325426
326427@pytest.fixture