OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 890cb4a

Local MP4 copies: the only path to working seek on this source (0.9.1)
vidcore's HLS is muxed fMP4, and neither player can handle it fully: Kodi's ffmpeg demuxer plays the audio but ignores seeks (request 50%, stay at 1.2%, with no demuxer activity logged at all), while inputstream.adaptive seeks correctly but plays no audio, because it feeds only the first track of a muxed fMP4. ISA is a compiled binary with no substitution point, so neither is fixable from here. So give the player something it can seek: stream-copy the episode into a plain progressive MP4. No re-encode, so no quality loss and almost no CPU. Measured end to end on the Pi against a real 28-minute episode: remux 131s for 1680s of video (12.8x realtime), 642MB at 720p canseek true, totaltime 28:00 seek 50% -> 14:05, 80% -> 22:29 pause speed 0 -> 1 audio AAC-LC stereo, bitrate 130361, language "eng" That audio detail is the contrast worth keeping: under ISA the same track showed bitrate 0 and language "unk" -- declared from the manifest but never fed. The copy is written to `<name>.part` and renamed on success, so a killed remux cannot leave a half file looking complete -- `-movflags +faststart` puts the moov atom at the front, and it is written last. `-f mp4` is required because ffmpeg otherwise picks the muxer from the `.part` extension and fails. Served via send_file(conditional=True) so Range requests work, which is what makes the file seekable over HTTP. The row is listed FIRST but only when the copy is complete, so it can never be a dead click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
890cb4ab451ebd99d14f0844e0586b15db3d4d3c
by plankatron <fleeky@gmail.com> · 2026-08-11T01:05:33.000Z

Changed files (3) +302 -2

.gitignore +1 -0
……@@ -13,3 +13,4 @@ node_helper/node_modules/
1313*.bak-*
1414vidcore_reputation.json
1515source_breaker.json
16mp4-cache/
addon.py +166 -2
……@@ -17,7 +17,7 @@ from typing import Optional
1717from urllib.parse import urlparse, unquote, urljoin
1818
1919import requests
20from flask import Flask, jsonify, request, redirect, abort, Response
20from flask import Flask, jsonify, request, redirect, abort, Response, send_file
2121
2222NODE_HELPER = os.path.join(os.path.dirname(os.path.abspath(__file__)),
2323 "node_helper", "fmovies_resolve.mjs")
……@@ -533,7 +533,7 @@ TV_GENRES = [
533533
534534MANIFEST = {
535535 "id": "org.tabertron.vsembed",
536 "version": "0.8.2",
536 "version": "0.9.1",
537537 "name": "Tabertron VsEmbed",
538538 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
539539 "types": ["movie", "series"],
……@@ -1041,6 +1041,141 @@ def vidcore_series(ident: str):
10411041 return _serve_vidcore(url)
10421042
10431043
1044# --------------------------- offline MP4 copies -----------------------------
1045# The only way to get SEEK on this source. vidcore's HLS is muxed fMP4: Kodi's
1046# ffmpeg demuxer plays it (audio works) but ignores seeks, and
1047# inputstream.adaptive seeks but plays no audio because it feeds only the first
1048# track of a muxed fMP4. Neither is fixable from here -- ISA is a compiled
1049# binary and Kodi's seek never even reaches the demuxer.
1050#
1051# So hand the player something it can seek: a plain progressive MP4. The remux
1052# is a STREAM COPY, so there is no re-encode, no quality loss and almost no CPU
1053# -- measured on stortron at ~5x realtime (120s of video in 23.9s), ~1.33GB per
1054# 28-minute episode at 1080p and ~0.67GB at 720p.
1055MP4_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mp4-cache")
1056MP4_REMUX_TIMEOUT = 45 * 60
1057_mp4_jobs: dict[str, str] = {} # key -> "running" | "done" | "failed: ..."
1058_mp4_jobs_guard = threading.Lock()
1059_mp4_sem = threading.Semaphore(2) # concurrent remuxes
1060
1061
1062def _mp4_key(kind: str, ident: str, season: int, episode: int, height: int) -> str:
1063 return f"{kind}:{ident}:{season}:{episode}:{height}"
1064
1065
1066def _mp4_path(kind: str, ident: str, season: int, episode: int, height: int) -> str:
1067 """Cache path for one remuxed copy.
1068
1069 The ident is slugified rather than interpolated: it arrives from the URL and
1070 contains colons, and must never be able to walk out of the cache directory.
1071 """
1072 slug = re.sub(r"[^A-Za-z0-9]+", "_", f"{kind}-{ident}-{season}-{episode}-{height}")
1073 return os.path.join(MP4_DIR, f"{slug}.mp4")
1074
1075
1076def _mp4_ready(kind: str, ident: str, season: int, episode: int, height: int) -> bool:
1077 """True only for a COMPLETE copy.
1078
1079 An in-progress remux writes to `<name>.part`; the moov atom that makes the
1080 file seekable is written last, so a partial file is both truncated and
1081 unseekable and must never be served.
1082 """
1083 path = _mp4_path(kind, ident, season, episode, height)
1084 try:
1085 return os.path.getsize(path) > 0
1086 except OSError:
1087 return False
1088
1089
1090def _remux_to_mp4(variant_url: str, out_path: str) -> None:
1091 """Stream-copy an HLS variant into a progressive MP4.
1092
1093 `-movflags +faststart` puts the moov atom at the front so the file can be
1094 seeked over HTTP. Written to `.part` and renamed, so a crashed or killed
1095 remux can never leave a half file looking complete.
1096 """
1097 tmp = out_path + ".part"
1098 os.makedirs(MP4_DIR, exist_ok=True)
1099 proc = subprocess.run(
1100 ["ffmpeg", "-v", "error", "-y",
1101 "-headers", "Referer: https://vidcore.net/\r\n",
1102 # -f mp4 is REQUIRED: ffmpeg picks the muxer from the file extension
1103 # and the output is written to `<name>.part`, which it does not
1104 # recognise ("Error initializing the muxer ...: Invalid argument").
1105 "-i", variant_url, "-c", "copy", "-movflags", "+faststart",
1106 "-f", "mp4", tmp],
1107 capture_output=True, text=True, timeout=MP4_REMUX_TIMEOUT,
1108 )
1109 if proc.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0:
1110 raise RuntimeError(f"ffmpeg exit {proc.returncode}: {(proc.stderr or '')[-300:]}")
1111 os.replace(tmp, out_path)
1112
1113
1114def _start_mp4_job(kind: str, ident: str, season: int, episode: int, height: int) -> str:
1115 """Kick off a remux in the background. Returns the job state."""
1116 key = _mp4_key(kind, ident, season, episode, height)
1117 with _mp4_jobs_guard:
1118 state = _mp4_jobs.get(key)
1119 if state == "running":
1120 return state
1121 _mp4_jobs[key] = "running"
1122
1123 def run() -> None:
1124 acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT)
1125 try:
1126 if not acquired:
1127 raise RuntimeError("remux slots busy")
1128 url = _resolve_vidcore(kind, ident, season, episode)
1129 if not url:
1130 raise RuntimeError("could not resolve title")
1131 variant = _cap_variant(url, height)
1132 out = _mp4_path(kind, ident, season, episode, height)
1133 log.info("mp4 remux start: %s -> %s", key, os.path.basename(out))
1134 started = time.time()
1135 _remux_to_mp4(variant, out)
1136 log.info("mp4 remux done: %s in %.0fs (%.2f GB)", key,
1137 time.time() - started, os.path.getsize(out) / 1e9)
1138 with _mp4_jobs_guard:
1139 _mp4_jobs[key] = "done"
1140 except Exception as exc:
1141 log.warning("mp4 remux failed for %s: %s", key, exc)
1142 with _mp4_jobs_guard:
1143 _mp4_jobs[key] = f"failed: {exc}"
1144 finally:
1145 if acquired:
1146 _mp4_sem.release()
1147
1148 threading.Thread(target=run, name=f"mp4-{key}", daemon=True).start()
1149 return "running"
1150
1151
1152def _serve_mp4(kind: str, ident: str, season: int, episode: int):
1153 height = _requested_height()
1154 if _mp4_ready(kind, ident, season, episode, height):
1155 # conditional=True is what answers Range requests, and Range is what
1156 # makes the file seekable in the player.
1157 return send_file(_mp4_path(kind, ident, season, episode, height),
1158 mimetype="video/mp4", conditional=True)
1159 state = _start_mp4_job(kind, ident, season, episode, height)
1160 # 503 rather than 404: the copy is being made, it is not an error.
1161 return Response(f"remux {state}\n", status=503, mimetype="text/plain")
1162
1163
1164@app.route("/mp4/movie/<path:ident>.mp4")
1165def mp4_movie(ident: str):
1166 if not re.fullmatch(VIDCORE_IDENT, ident):
1167 abort(404)
1168 return _serve_mp4("movie", ident, 0, 0)
1169
1170
1171@app.route("/mp4/series/<path:ident>.mp4")
1172def mp4_series(ident: str):
1173 m = re.fullmatch(rf"({VIDCORE_IDENT}):(\d+):(\d+)", ident)
1174 if not m:
1175 abort(404)
1176 return _serve_mp4("series", m.group(1), int(m.group(2)), int(m.group(3)))
1177
1178
10441179def _vidcore_ident_for_fmv(ctype: str, slug: str) -> Optional[str]:
10451180 """fmovies carries no imdb/tmdb id — bridge its slug to TMDB so the (lazy)
10461181 vidcore row can be offered on catalog entries too."""
……@@ -1052,6 +1187,33 @@ def _vidcore_ident_for_fmv(ctype: str, slug: str) -> Optional[str]:
10521187 return f"tmdb:{tmdb}"
10531188
10541189
1190def _append_mp4_row(out: list, kind: str, ident: Optional[str],
1191 season: int = 0, episode: int = 0) -> None:
1192 """Offer a local remuxed copy, FIRST, when one exists.
1193
1194 Only listed when the copy is complete, so the row can never be a dead click:
1195 it is the one row on this source where seek, resume and pause all work, and
1196 it plays from local disk with no CDN token to expire.
1197 """
1198 if not ident:
1199 return
1200 for height in VIDCORE_HEIGHTS:
1201 if not _mp4_ready(kind, ident, season, episode, height):
1202 continue
1203 if kind == "series":
1204 path = f"/mp4/series/{ident}:{season}:{episode}.mp4"
1205 tag = f"S{season:02d}E{episode:02d} "
1206 else:
1207 path = f"/mp4/movie/{ident}.mp4"
1208 tag = ""
1209 out.append(_stream_dict(Stream(
1210 url=f"http://{request.host}{path}?h={height}",
1211 quality=f"{height}p", name="local",
1212 referer="https://vidcore.net/",
1213 title_extra=f"{tag}{height}p seekable\nlocal copy")))
1214 return
1215
1216
10551217def _append_vidcore_row(out: list, kind: str, ident: Optional[str],
10561218 season: int = 0, episode: int = 0) -> None:
10571219 """Additive lazy vidcore rows — one per quality, hidden for known misses.
……@@ -1103,6 +1265,7 @@ def stream_movie(ident: str):
11031265 fxl = re.fullmatch(r"fxl:(\d+)", ident)
11041266 if fxl: # flixlab-native catalog: vidcore only
11051267 out: list = []
1268 _append_mp4_row(out, "movie", f"tmdb:{fxl.group(1)}")
11061269 _append_vidcore_row(out, "movie", f"tmdb:{fxl.group(1)}")
11071270 return jsonify({"streams": out})
11081271 fmv = re.fullmatch(r"fmv:(\d+):([a-z0-9\-]+)", ident)
……@@ -1134,6 +1297,7 @@ def stream_series(ident: str):
11341297 if fxl: # flixlab-native catalog: vidcore only
11351298 tmdb, season, episode = fxl.group(1), int(fxl.group(2)), int(fxl.group(3))
11361299 out: list = []
1300 _append_mp4_row(out, "series", f"tmdb:{tmdb}", season, episode)
11371301 _append_vidcore_row(out, "series", f"tmdb:{tmdb}", season, episode)
11381302 return jsonify({"streams": out})
11391303 fmv = re.fullmatch(r"fmv:(\d+):([a-z0-9\-]+):(\d+):(\d+)", ident)
test_mp4_offline.py +135 -0
……@@ -0,0 +1,135 @@
1"""Remuxed MP4 copies: the only way to get seek on this source.
2
3vidcore's HLS is muxed fMP4. Kodi's ffmpeg demuxer plays it (audio works) but
4refuses to act on a seek; inputstream.adaptive seeks but cannot play the audio,
5because it feeds only the first track of a muxed fMP4. Neither can be fixed from
6our side, so instead we stream-copy the episode into a plain progressive MP4,
7which ffmpeg seeks natively.
8
9Measured 2026-08-10 on stortron: stream-copy runs at ~5x realtime (120s of video
10in 23.9s) and yields h264+aac -- no re-encode, so no quality loss and negligible
11CPU. ~1.33GB per 28-minute episode at 1080p, ~0.67GB at 720p.
12
13Run: .venv/bin/python3 -m pytest test_mp4_offline.py -q
14"""
15
16import os
17
18import pytest
19
20import addon
21
22
23@pytest.fixture(autouse=True)
24def tmp_cache(tmp_path, monkeypatch):
25 monkeypatch.setattr(addon, "MP4_DIR", str(tmp_path))
26 monkeypatch.setattr(addon, "_mp4_jobs", {})
27 yield
28
29
30# --- naming -----------------------------------------------------------------
31
32def test_series_and_movie_paths_are_distinct_and_stable():
33 a = addon._mp4_path("series", "tmdb:138502", 2, 7, 720)
34 b = addon._mp4_path("series", "tmdb:138502", 2, 8, 720)
35 c = addon._mp4_path("movie", "tmdb:138502", 0, 0, 720)
36 assert a != b != c and a != c
37 assert a == addon._mp4_path("series", "tmdb:138502", 2, 7, 720)
38 assert a.endswith(".mp4")
39
40
41def test_height_is_part_of_the_name():
42 """A 720p copy must not be served to a request for 1080p."""
43 assert addon._mp4_path("series", "tmdb:1", 1, 1, 720) != \
44 addon._mp4_path("series", "tmdb:1", 1, 1, 1080)
45
46
47def test_ident_colons_do_not_escape_the_cache_dir():
48 """`tmdb:138502` and any hostile ident must stay inside MP4_DIR."""
49 for ident in ("tmdb:138502", "../../etc/passwd", "a/b", "tt00/../x"):
50 p = os.path.realpath(addon._mp4_path("series", ident, 1, 1, 720))
51 assert p.startswith(os.path.realpath(addon.MP4_DIR) + os.sep)
52
53
54# --- readiness --------------------------------------------------------------
55
56def test_a_partial_download_is_not_ready():
57 """A half-written file must never be served: its moov atom is written last,
58 so it would be unseekable and truncated."""
59 p = addon._mp4_path("series", "tmdb:1", 1, 1, 720)
60 open(p + ".part", "wb").write(b"x" * 1024)
61 assert addon._mp4_ready("series", "tmdb:1", 1, 1, 720) is False
62
63
64def test_a_finished_download_is_ready():
65 p = addon._mp4_path("series", "tmdb:1", 1, 1, 720)
66 open(p, "wb").write(b"x" * 1024)
67 assert addon._mp4_ready("series", "tmdb:1", 1, 1, 720) is True
68
69
70def test_an_empty_file_is_not_ready():
71 p = addon._mp4_path("series", "tmdb:1", 1, 1, 720)
72 open(p, "wb").close()
73 assert addon._mp4_ready("series", "tmdb:1", 1, 1, 720) is False
74
75
76# --- the route --------------------------------------------------------------
77
78@pytest.fixture
79def client(monkeypatch):
80 monkeypatch.setattr(addon, "_start_mp4_job", lambda *a, **k: "started")
81 return addon.app.test_client()
82
83
84def test_ready_file_is_served_with_range_support(client):
85 p = addon._mp4_path("series", "tmdb:138502", 2, 7, 720)
86 with open(p, "wb") as f:
87 f.write(b"0123456789" * 100)
88 r = client.get("/mp4/series/tmdb:138502:2:7.mp4?h=720")
89 assert r.status_code == 200
90 assert r.headers.get("Accept-Ranges") == "bytes"
91
92 r = client.get("/mp4/series/tmdb:138502:2:7.mp4?h=720",
93 headers={"Range": "bytes=10-19"})
94 assert r.status_code == 206 # seeking depends on this
95 assert r.get_data() == b"0123456789"
96
97
98def test_a_missing_file_reports_not_ready_and_starts_the_job(client):
99 started = []
100 addon._start_mp4_job = lambda *a, **k: started.append(a) or "started"
101 r = client.get("/mp4/series/tmdb:138502:2:9.mp4?h=720")
102 assert r.status_code == 503 # not an error -- "come back later"
103 assert started
104
105
106def test_a_bad_ident_is_rejected(client):
107 assert client.get("/mp4/series/not-an-id.mp4?h=720").status_code == 404
108
109
110def test_remux_specifies_the_output_format_explicitly():
111 """The output is written to ; without -f mp4 ffmpeg tries to
112 infer the muxer from that extension and dies with 'Error initializing the
113 muxer ...: Invalid argument'."""
114 import inspect
115 src = inspect.getsource(addon._remux_to_mp4)
116 assert '"-f", "mp4"' in src
117
118
119def test_a_ready_copy_is_offered_first(monkeypatch):
120 monkeypatch.setattr(addon, '_prefetch_vidcore', lambda *a, **k: None)
121 open(addon._mp4_path('series', 'tmdb:1', 2, 7, 1080), 'wb').write(b'x')
122 with addon.app.test_request_context('/stream/series/fxl:1:2:7.json'):
123 out = []
124 addon._append_mp4_row(out, 'series', 'tmdb:1', 2, 7)
125 addon._append_vidcore_row(out, 'series', 'tmdb:1', 2, 7)
126 assert '/mp4/' in out[0]['url'] and 'seekable' in out[0]['title']
127 assert len(out) == 1 + len(addon.VIDCORE_HEIGHTS)
128
129
130def test_no_copy_means_no_row(monkeypatch):
131 monkeypatch.setattr(addon, '_prefetch_vidcore', lambda *a, **k: None)
132 with addon.app.test_request_context('/stream/series/fxl:1:2:9.json'):
133 out = []
134 addon._append_mp4_row(out, 'series', 'tmdb:1', 2, 9)
135 assert out == []