OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 626dad0

Watch a library copy in the browser, with what it actually is (0.23.0)
labs#13 part 3. /web/watch/{movie/<id>|series/<id>/<s>/<e>} plays the copy in a <video> with native seek, from a NEW read-only route /web/file/... (send_file conditional=True from _playable_path). Not /mp4/: that queues the next episode and starts an ingest when no copy exists; watching must never fetch. A copy the completeness gate rejects is neither served nor offered. Quality panel (ffprobe, cached on file identity): real WxH (1920x800 is not "800p"), codec/profile, fps, video bitrate, every audio track, length vs TMDB, size, and the source from history. Audio: browsers play the FIRST audio track and do not fall back. Measured in Chromium: ec-3/ac-3 canPlayType "", eac3-only and eac3+aac files both decode 0 audio bytes. Such copies are flagged "plays without sound here" rather than left to look broken. Jellyfin's per-viewer HLS audio re-encode (traced in labs#13) is deliberately not rebuilt. Title pages link held copies to Watch. 16 tests. Suite 419. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
626dad0f5398630853496d8d0372e61e521fb590
by plankatron <fleeky@gmail.com> · 2026-10-05T00:47:08.000Z

Changed files (2) +401 -5

addon.py +193 -5
……@@ -619,7 +619,7 @@ TV_GENRES = [
619619
620620MANIFEST = {
621621 "id": "org.tabertron.vsembed",
622 "version": "0.22.0",
622 "version": "0.23.0",
623623 "name": "Tabertron VsEmbed",
624624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
625625 "types": ["movie", "series"],
……@@ -4067,10 +4067,11 @@ def _web_state(kind: str, tmdb: str, season: int = 0,
40674067 return {"status": "none"}
40684068
40694069
4070def _web_state_html(state: dict) -> str:
4070def _web_state_html(state: dict, watch: Optional[str] = None) -> str:
40714071 s = state["status"]
40724072 if s == "held":
4073 return "<span class='st held'>in library</span>"
4073 return ("<span class='st held'>in library</span>"
4074 + (f" <a class=watch href='{watch}'>Watch</a>" if watch else ""))
40744075 if s == "running":
40754076 n = state.get("bytes")
40764077 got = f" · {n / 1e6:.0f} MB" if n else " · starting"
……@@ -4162,7 +4163,8 @@ def _web_movie(tmdb: str) -> Response:
41624163 action = ("" if state["status"] in ("held", "running")
41634164 else _web_download_form("movie", tmdb))
41644165 body = (_web_header(film) + _web_low_space()
4165 + f"<p>{_web_state_html(state)} {action}</p></div></div>")
4166 + f"<p>{_web_state_html(state, f'/web/watch/movie/{tmdb}')} "
4167 f"{action}</p></div></div>")
41664168 return _web_page(film.get("title") or tmdb, body,
41674169 refresh=state["status"] == "running")
41684170
……@@ -4207,7 +4209,9 @@ def _web_series(tmdb: str) -> Response:
42074209 else _web_download_form("series", tmdb, season, num))
42084210 rows.append(f"<tr><td class=n>{num}</td><td>{name}<br>"
42094211 f"<span class=mute>{air}</span></td>"
4210 f"<td class=act>{_web_state_html(state)} {action}</td></tr>")
4212 f"<td class=act>"
4213 f"{_web_state_html(state, f'/web/watch/series/{tmdb}/{season}/{num}')}"
4214 f" {action}</td></tr>")
42114215 season_action = (
42124216 f"<p>{_web_download_form('series', tmdb, season, 0, 'season', f'Get all {aired_missing} missing')}</p>"
42134217 if aired_missing > 1 else "")
……@@ -4397,6 +4401,190 @@ def web_cancel():
43974401 return redirect("/web/downloads", code=303)
43984402
43994403
4404# ---------------------------------- watch -----------------------------------
4405# Play a library copy in the browser to judge what it actually is.
4406#
4407# Audio a browser cannot decode plays as SILENCE, and it is the FIRST audio
4408# track that decides it. Measured 2026-10-04 in Chromium: canPlayType for ec-3
4409# and ac-3 is "", an eac3-only file decoded 0 audio bytes, and an eac3 + aac
4410# file ALSO decoded 0 -- no fallback to the later track it could have played.
4411# Jellyfin gets sound out of these by re-encoding the audio into an HLS stream
4412# per viewer (labs#13 has the trace); that machinery is deliberately not
4413# rebuilt here. Picture-only is enough to judge picture quality, and the page
4414# says so rather than leaving the silence to be mistaken for a broken copy.
4415BROWSER_SILENT_AUDIO = ("ac3", "eac3", "dts", "truehd", "mlp")
4416_quality_cache: dict[tuple, Optional[dict]] = {}
4417
4418
4419def _ffprobe_file(path: str) -> Optional[dict]:
4420 try:
4421 out = subprocess.run(
4422 ["ffprobe", "-v", "error", "-show_entries",
4423 "stream=index,codec_type,codec_name,profile,width,height,"
4424 "avg_frame_rate,bit_rate,channels,pix_fmt:stream_tags=language"
4425 ":format=duration,size,bit_rate",
4426 "-of", "json", path],
4427 capture_output=True, text=True, timeout=60)
4428 return json.loads(out.stdout) if out.returncode == 0 else None
4429 except Exception as exc:
4430 log.warning("watch: could not probe %s (%s)", os.path.basename(path), exc)
4431 return None
4432
4433
4434def _quality(path: str) -> Optional[dict]:
4435 """ffprobe's view of a local file, keyed on its identity like
4436 _cached_media_duration, so a page view does not spawn ffprobe each time
4437 and a re-ingest invalidates it for free."""
4438 try:
4439 st = os.stat(path)
4440 except OSError:
4441 return None
4442 key = (os.path.abspath(path), st.st_size, int(st.st_mtime))
4443 if key not in _quality_cache:
4444 _quality_cache[key] = _ffprobe_file(path)
4445 return _quality_cache[key]
4446
4447
4448def _fps(rate: Optional[str]) -> Optional[float]:
4449 try:
4450 num, den = (rate or "").split("/")
4451 return float(num) / float(den) if float(den) else None
4452 except ValueError:
4453 return None
4454
4455
4456def _mbps(bits: Optional[str]) -> Optional[str]:
4457 try:
4458 return f"{int(bits) / 1e6:.1f} Mbps"
4459 except (TypeError, ValueError):
4460 return None
4461
4462
4463def _source_of(path: str) -> Optional[str]:
4464 """Where a copy came from, if this addon's history recorded it."""
4465 target = os.path.abspath(path)
4466 for h in _read_history(2000):
4467 if (h.get("outcome") == "done" and h.get("dest")
4468 and os.path.abspath(h["dest"]) == target and h.get("source")):
4469 return h["source"]
4470 return None
4471
4472
4473def _web_target(kind: str, tmdb: str, season: int, episode: int):
4474 """(path, meta) of a held copy, or abort(404). Never fetches anything."""
4475 if kind not in ("movie", "series") or not tmdb.isdigit():
4476 abort(404)
4477 if kind == "movie":
4478 season = episode = 0
4479 ident = f"tmdb:{tmdb}"
4480 path = _playable_path(kind, ident, season, episode)
4481 if not path:
4482 abort(404)
4483 return path, _library_meta(kind, ident, season, episode)
4484
4485
4486def _serve_web_file(kind: str, tmdb: str, season: int = 0, episode: int = 0):
4487 path, _meta = _web_target(kind, tmdb, season, episode)
4488 return send_file(path, mimetype="video/mp4", conditional=True)
4489
4490
4491@app.route("/web/file/movie/<tmdb>.mp4")
4492def web_file_movie(tmdb: str):
4493 return _serve_web_file("movie", tmdb)
4494
4495
4496@app.route("/web/file/series/<tmdb>/<int:season>/<int:episode>.mp4")
4497def web_file_series(tmdb: str, season: int, episode: int):
4498 return _serve_web_file("series", tmdb, season, episode)
4499
4500
4501@app.route("/web/file/<kind>/<path:rest>")
4502def web_file_other(kind: str, rest: str):
4503 abort(404)
4504
4505
4506def _quality_html(path: str, meta: Optional[dict]) -> str:
4507 q = _quality(path) or {}
4508 streams = q.get("streams") or []
4509 video = next((s for s in streams if s.get("codec_type") == "video"), None)
4510 audio = [s for s in streams if s.get("codec_type") == "audio"]
4511 fmt = q.get("format") or {}
4512 rows = []
4513 if video:
4514 fps = _fps(video.get("avg_frame_rate"))
4515 rows.append(("Picture",
4516 f"{video.get('width')}×{video.get('height')} · "
4517 f"{video.get('codec_name')} {video.get('profile') or ''}"
4518 + (f" · {fps:.2f} fps" if fps else "")))
4519 rate = _mbps(video.get("bit_rate"))
4520 rows.append(("Video bitrate", rate or
4521 f"{_mbps(fmt.get('bit_rate')) or '—'} (whole file)"))
4522 for i, a in enumerate(audio, 1):
4523 lang = (a.get("tags") or {}).get("language") or "und"
4524 rows.append((f"Audio {i}",
4525 f"{a.get('codec_name')} · {a.get('channels') or '?'} ch · {lang}"
4526 + (f" · {_mbps(a.get('bit_rate'))}" if a.get("bit_rate") else "")))
4527 try:
4528 seconds = float(fmt.get("duration"))
4529 except (TypeError, ValueError):
4530 seconds = None
4531 try:
4532 expected = float((meta or {}).get("runtime")) * 60
4533 except (TypeError, ValueError):
4534 expected = None
4535 length = _human_secs(seconds)
4536 if seconds and expected:
4537 length += f" · {seconds / expected * 100:.0f}% of TMDB's {expected / 60:.0f} min"
4538 rows.append(("Length", length))
4539 rows.append(("Size", _human_bytes(os.path.getsize(path))))
4540 rows.append(("Source", _source_of(path) or "not recorded"))
4541 warn = ""
4542 if audio and audio[0].get("codec_name") in BROWSER_SILENT_AUDIO:
4543 warn = ("<p class=warn>This copy plays without sound here: its first "
4544 f"audio track is {html.escape(audio[0].get('codec_name'))}, "
4545 "which browsers cannot decode (and they do not fall back to a "
4546 "later track). Kodi and Jellyfin play it with sound.</p>")
4547 return warn + "<table>" + "".join(
4548 f"<tr><td class=mute>{k}</td><td>{html.escape(str(v))}</td></tr>"
4549 for k, v in rows) + "</table>"
4550
4551
4552def _web_watch(kind: str, tmdb: str, season: int = 0, episode: int = 0):
4553 path, meta = _web_target(kind, tmdb, season, episode)
4554 title = (meta or {}).get("title") or tmdb
4555 if kind == "series":
4556 file_url = f"/web/file/series/{tmdb}/{season}/{episode}.mp4"
4557 back = f"/web/title/series/{tmdb}?s={season}"
4558 sub = f"S{season:02d}E{episode:02d}"
4559 if (meta or {}).get("episode_title"):
4560 sub += f" · {meta['episode_title']}"
4561 else:
4562 file_url = f"/web/file/movie/{tmdb}.mp4"
4563 back = f"/web/title/movie/{tmdb}"
4564 sub = (meta or {}).get("year") or ""
4565 body = (f"<p><a href='{back}'>← {html.escape(title)}</a></p>"
4566 f"<h1>{html.escape(title)} <span class=mute>{html.escape(sub)}</span></h1>"
4567 f"<video controls preload=metadata playsinline src='{file_url}'"
4568 " style='width:100%;max-height:70vh;background:#000'></video>"
4569 "<h2>What this copy is</h2>" + _quality_html(path, meta)
4570 + _web_delete_form(kind, tmdb, season, episode))
4571 return _web_page(f"{title} {sub} — watch", body)
4572
4573
4574def _web_delete_form(kind: str, tmdb: str, season: int, episode: int) -> str:
4575 return "" # labs#13 part 4
4576
4577
4578@app.route("/web/watch/movie/<tmdb>")
4579def web_watch_movie(tmdb: str):
4580 return _web_watch("movie", tmdb)
4581
4582
4583@app.route("/web/watch/series/<tmdb>/<int:season>/<int:episode>")
4584def web_watch_series(tmdb: str, season: int, episode: int):
4585 return _web_watch("series", tmdb, season, episode)
4586
4587
44004588# The sources this addon knows about. Declared here, at the end, so each entry
44014589# can point at the resolver it uses. Dead and unusable sources are RECORDED
44024590# rather than deleted: the next person to notice that vidlink serves progressive
test_web_watch.py +208 -0
……@@ -0,0 +1,208 @@
1"""Watch a library copy in the browser, with what it actually is (labs#13).
2
3The point is judging quality, so the page states the real numbers: width x
4height (1920x800 is not "800p"), codec, frame rate, video bitrate, and every
5audio track -- flagging the ones a browser plays as silence.
6
7That flag is MEASURED, not assumed (Chromium, 2026-10-04): canPlayType for
8ec-3 and ac-3 is "", an eac3-only file decodes 0 audio bytes, and an eac3 +
9aac file ALSO decodes 0 -- the browser plays the first audio track and does
10not fall back to a later one it could decode.
11
12/web/file/ is a separate route from /mp4/ on purpose: /mp4/ queues the next
13episode and starts an ingest when there is no copy. Watching here must never
14fetch anything.
15
16Run: .venv/bin/python3 -m pytest test_web_watch.py -q
17"""
18
19import json
20import os
21
22import pytest
23
24import addon
25
26
27TMDB = {
28 "tv/1": {"name": "Show", "first_air_date": "2020-01-01", "seasons": []},
29 "tv/1/season/2/episode/6": {"name": "Six", "runtime": 30},
30 "tv/1/season/2/episode/7": {"name": "Seven", "runtime": 30},
31 "movie/2": {"title": "Film", "release_date": "2021-05-05", "runtime": 30},
32 "tv/1/season/2": {"episodes": [
33 {"episode_number": 6, "name": "Six", "air_date": "2020-01-01"}]},
34}
35
36PROBE_AAC = {
37 "format": {"duration": "1800.0", "size": "1432636396", "bit_rate": "6370000"},
38 "streams": [
39 {"index": 0, "codec_type": "video", "codec_name": "h264", "profile": "High",
40 "width": 1920, "height": 800, "avg_frame_rate": "24000/1001",
41 "bit_rate": "6100000", "pix_fmt": "yuv420p"},
42 {"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2,
43 "bit_rate": "128000", "tags": {"language": "eng"}}]}
44
45PROBE_EAC3_FIRST = {
46 "format": {"duration": "1800.0", "size": "1", "bit_rate": "10800000"},
47 "streams": [
48 {"index": 0, "codec_type": "video", "codec_name": "h264", "profile": "High",
49 "width": 1920, "height": 802, "avg_frame_rate": "24/1", "bit_rate": "10000000"},
50 {"index": 1, "codec_type": "audio", "codec_name": "eac3", "channels": 6,
51 "tags": {"language": "eng"}},
52 {"index": 2, "codec_type": "audio", "codec_name": "aac", "channels": 2,
53 "tags": {"language": "hin"}}]}
54
55
56@pytest.fixture(autouse=True)
57def isolated(tmp_path, monkeypatch):
58 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path))
59 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "queue.json"))
60 monkeypatch.setattr(addon, "INGEST_HISTORY_FILE", str(tmp_path / "history.jsonl"))
61 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE", str(tmp_path / "verified.json"))
62 monkeypatch.setattr(addon, "MP4_DIR", str(tmp_path / "mp4-cache"))
63 monkeypatch.setattr(addon, "_duration_cache", {})
64 monkeypatch.setattr(addon, "_quality_cache", {})
65 monkeypatch.setattr(addon, "_library_meta_cache", {})
66 monkeypatch.setattr(addon, "_mp4_jobs", {})
67 monkeypatch.setattr(addon, "_enough_space", lambda: True)
68 monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: TMDB[path])
69 monkeypatch.setattr(addon, "_media_duration", lambda p: 1800.0)
70 probe = {"value": PROBE_AAC}
71 monkeypatch.setattr(addon, "_ffprobe_file", lambda p: probe["value"])
72
73 def refuse(*a, **k):
74 raise AssertionError("watching started a download")
75 monkeypatch.setattr(addon, "_start_mp4_job", refuse)
76 monkeypatch.setattr(addon, "_queue_next_episode", refuse)
77 monkeypatch.setattr(addon, "_ensure_ingest", refuse)
78 yield probe
79
80
81@pytest.fixture
82def client():
83 return addon.app.test_client()
84
85
86def _hold(kind, season=0, episode=0, tmdb="1", body=b"0123456789" * 100):
87 meta = addon._library_meta(kind, f"tmdb:{tmdb}", season, episode)
88 path = addon._library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
89 season, episode, meta["episode_title"])
90 os.makedirs(os.path.dirname(path), exist_ok=True)
91 with open(path, "wb") as f:
92 f.write(body)
93 return path
94
95
96# --- the file route ---------------------------------------------------------
97
98def test_the_file_route_serves_a_held_episode(client):
99 _hold("series", 2, 6)
100 r = client.get("/web/file/series/1/2/6.mp4")
101 assert r.status_code == 200
102 assert r.mimetype == "video/mp4"
103 assert r.get_data() == b"0123456789" * 100
104
105
106def test_the_file_route_answers_range_requests(client):
107 """Range is what makes the browser player seekable."""
108 _hold("series", 2, 6)
109 r = client.get("/web/file/series/1/2/6.mp4", headers={"Range": "bytes=10-19"})
110 assert r.status_code == 206
111 assert r.get_data() == b"0123456789"
112
113
114def test_the_file_route_serves_a_movie(client):
115 _hold("movie", tmdb="2")
116 assert client.get("/web/file/movie/2.mp4").status_code == 200
117
118
119def test_the_file_route_404s_without_fetching_when_not_held(client):
120 assert client.get("/web/file/series/1/2/7.mp4").status_code == 404
121
122
123def test_the_file_route_rejects_bad_ids(client):
124 assert client.get("/web/file/series/x/2/6.mp4").status_code == 404
125 assert client.get("/web/file/anime/1.mp4").status_code == 404
126
127
128# --- the watch page ---------------------------------------------------------
129
130def test_watch_page_plays_the_file(client):
131 _hold("series", 2, 6)
132 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
133 assert "<video" in body
134 assert "src='/web/file/series/1/2/6.mp4'" in body
135
136
137def test_watch_page_states_the_real_numbers(client):
138 _hold("series", 2, 6)
139 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
140 assert "1920×800" in body
141 assert "h264" in body and "High" in body
142 assert "23.98 fps" in body
143 assert "6.1 Mbps" in body
144 assert "aac" in body and "eng" in body
145
146
147def test_watch_page_reports_length_against_tmdb(client):
148 _hold("series", 2, 6)
149 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
150 assert "30m 00s" in body and "100%" in body
151
152
153def test_an_eac3_first_track_is_flagged_silent(client, isolated):
154 isolated["value"] = PROBE_EAC3_FIRST
155 _hold("series", 2, 6)
156 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
157 assert "without sound" in body
158 assert "eac3" in body
159
160
161def test_an_aac_copy_is_not_flagged(client):
162 _hold("series", 2, 6)
163 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
164 assert "without sound" not in body
165
166
167def test_the_source_comes_from_history(client):
168 path = _hold("series", 2, 6)
169 with open(addon.INGEST_HISTORY_FILE, "w") as f:
170 f.write(json.dumps({"ts": 1, "outcome": "done", "source": "hdhub",
171 "dest": path}) + "\n")
172 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
173 assert "hdhub" in body
174
175
176def test_watching_a_title_not_held_is_404(client):
177 r = client.get("/web/watch/series/1/2/7")
178 assert r.status_code == 404
179
180
181def test_a_held_title_links_to_watch(client):
182 _hold("movie", tmdb="2")
183 body = client.get("/web/title/movie/2").get_data(as_text=True)
184 assert "href='/web/watch/movie/2'" in body
185
186
187def test_a_held_episode_links_to_watch(client):
188 _hold("series", 2, 6)
189 body = client.get("/web/title/series/1?s=2").get_data(as_text=True)
190 assert "href='/web/watch/series/1/2/6'" in body
191
192
193def test_quality_probe_is_cached_by_file_identity(client, monkeypatch):
194 path = _hold("series", 2, 6)
195 calls = []
196 monkeypatch.setattr(addon, "_ffprobe_file", lambda p: calls.append(p) or PROBE_AAC)
197 addon._quality(path)
198 addon._quality(path)
199 assert len(calls) == 1
200
201
202def test_a_partial_copy_is_neither_served_nor_offered(client, monkeypatch):
203 """The completeness gate applies here too: 30 min of a 100 min film is the
204 2026-08-31 truncation shape, and must not be judged as 'the copy'."""
205 monkeypatch.setitem(TMDB, "movie/2", dict(TMDB["movie/2"], runtime=100))
206 _hold("movie", tmdb="2")
207 assert client.get("/web/file/movie/2.mp4").status_code == 404
208 assert client.get("/web/watch/movie/2").status_code == 404