| … | … | @@ -619,7 +619,7 @@ TV_GENRES = [ |
| 619 | 619 | |
| 620 | 620 | MANIFEST = { |
| 621 | 621 | "id": "org.tabertron.vsembed", |
| 622 | | "version": "0.22.0", |
| 622 | "version": "0.23.0", |
| 623 | 623 | "name": "Tabertron VsEmbed", |
| 624 | 624 | "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub", |
| 625 | 625 | "types": ["movie", "series"], |
| … | … | @@ -4067,10 +4067,11 @@ def _web_state(kind: str, tmdb: str, season: int = 0, |
| 4067 | 4067 | return {"status": "none"} |
| 4068 | 4068 | |
| 4069 | 4069 | |
| 4070 | | def _web_state_html(state: dict) -> str: |
| 4070 | def _web_state_html(state: dict, watch: Optional[str] = None) -> str: |
| 4071 | 4071 | s = state["status"] |
| 4072 | 4072 | 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 "")) |
| 4074 | 4075 | if s == "running": |
| 4075 | 4076 | n = state.get("bytes") |
| 4076 | 4077 | got = f" · {n / 1e6:.0f} MB" if n else " · starting" |
| … | … | @@ -4162,7 +4163,8 @@ def _web_movie(tmdb: str) -> Response: |
| 4162 | 4163 | action = ("" if state["status"] in ("held", "running") |
| 4163 | 4164 | else _web_download_form("movie", tmdb)) |
| 4164 | 4165 | 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>") |
| 4166 | 4168 | return _web_page(film.get("title") or tmdb, body, |
| 4167 | 4169 | refresh=state["status"] == "running") |
| 4168 | 4170 | |
| … | … | @@ -4207,7 +4209,9 @@ def _web_series(tmdb: str) -> Response: |
| 4207 | 4209 | else _web_download_form("series", tmdb, season, num)) |
| 4208 | 4210 | rows.append(f"<tr><td class=n>{num}</td><td>{name}<br>" |
| 4209 | 4211 | 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>") |
| 4211 | 4215 | season_action = ( |
| 4212 | 4216 | f"<p>{_web_download_form('series', tmdb, season, 0, 'season', f'Get all {aired_missing} missing')}</p>" |
| 4213 | 4217 | if aired_missing > 1 else "") |
| … | … | @@ -4397,6 +4401,190 @@ def web_cancel(): |
| 4397 | 4401 | return redirect("/web/downloads", code=303) |
| 4398 | 4402 | |
| 4399 | 4403 | |
| 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. |
| 4415 | BROWSER_SILENT_AUDIO = ("ac3", "eac3", "dts", "truehd", "mlp") |
| 4416 | _quality_cache: dict[tuple, Optional[dict]] = {} |
| 4417 | |
| 4418 | |
| 4419 | def _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 | |
| 4434 | def _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 | |
| 4448 | def _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 | |
| 4456 | def _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 | |
| 4463 | def _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 | |
| 4473 | def _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 | |
| 4486 | def _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") |
| 4492 | def 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") |
| 4497 | def 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>") |
| 4502 | def web_file_other(kind: str, rest: str): |
| 4503 | abort(404) |
| 4504 | |
| 4505 | |
| 4506 | def _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 | |
| 4552 | def _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 | |
| 4574 | def _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>") |
| 4579 | def web_watch_movie(tmdb: str): |
| 4580 | return _web_watch("movie", tmdb) |
| 4581 | |
| 4582 | |
| 4583 | @app.route("/web/watch/series/<tmdb>/<int:season>/<int:episode>") |
| 4584 | def web_watch_series(tmdb: str, season: int, episode: int): |
| 4585 | return _web_watch("series", tmdb, season, episode) |
| 4586 | |
| 4587 | |
| 4400 | 4588 | # The sources this addon knows about. Declared here, at the end, so each entry |
| 4401 | 4589 | # can point at the resolver it uses. Dead and unusable sources are RECORDED |
| 4402 | 4590 | # rather than deleted: the next person to notice that vidlink serves progressive |