| … | … | @@ -619,7 +619,7 @@ TV_GENRES = [ |
| 619 | 619 | |
| 620 | 620 | MANIFEST = { |
| 621 | 621 | "id": "org.tabertron.vsembed", |
| 622 | | "version": "0.27.1", |
| 622 | "version": "0.28.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"], |
| … | … | @@ -2202,6 +2202,18 @@ SUBS_ORIGINAL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), |
| 2202 | 2202 | SUBTITLE_SYNC_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), |
| 2203 | 2203 | "subtitle_sync.json") |
| 2204 | 2204 | _subsync_guard = threading.Lock() |
| 2205 | # Only these subtitle languages are measured and repaired ("focus on english", |
| 2206 | # 2026-10-04): it is what gets watched, and syncing all ~32 wyzie tracks took |
| 2207 | # ~5 min per film where English alone takes ~1.5. Others stay as fetched. |
| 2208 | # None = every language (the mechanics tests use that). |
| 2209 | SUBSYNC_LANGUAGES: Optional[tuple] = ("en",) |
| 2210 | |
| 2211 | |
| 2212 | def _sidecar_lang(dest: str, path: str) -> str: |
| 2213 | """`<stem>.<code>[.<n>].srt` -> code.""" |
| 2214 | stem = os.path.splitext(os.path.basename(dest))[0] + "." |
| 2215 | middle = os.path.basename(path)[len(stem):-len(".srt")] |
| 2216 | return re.sub(r"\.\d+$", "", middle) |
| 2205 | 2217 | |
| 2206 | 2218 | |
| 2207 | 2219 | def _subsync_available() -> bool: |
| … | … | @@ -2315,7 +2327,9 @@ def _sync_subtitles(dest: str) -> dict: |
| 2315 | 2327 | except Exception: |
| 2316 | 2328 | return result # unreadable record: touch nothing |
| 2317 | 2329 | todo = [p for p in _sidecars_of(dest) |
| 2318 | | if (records.get(os.path.abspath(p)) or {}).get("identity") |
| 2330 | if (SUBSYNC_LANGUAGES is None |
| 2331 | or _sidecar_lang(dest, p) in SUBSYNC_LANGUAGES) |
| 2332 | and (records.get(os.path.abspath(p)) or {}).get("identity") |
| 2319 | 2333 | != _file_identity(p)] |
| 2320 | 2334 | result["tracks"] = len(todo) |
| 2321 | 2335 | if not todo: |
| … | … | @@ -2406,6 +2420,8 @@ def _subtitle_summary(dest: str) -> str: |
| 2406 | 2420 | except Exception: |
| 2407 | 2421 | records = {} |
| 2408 | 2422 | stem = os.path.splitext(os.path.abspath(dest))[0] + "." |
| 2423 | if SUBSYNC_LANGUAGES is not None: |
| 2424 | return _subtitle_summary_focused(dest, present, records, stem) |
| 2409 | 2425 | counts = {"corrected": 0, "in_sync": 0, "unchecked": 0} |
| 2410 | 2426 | for p in present: |
| 2411 | 2427 | rec = records.get(os.path.abspath(p)) or {} |
| … | … | @@ -2423,6 +2439,39 @@ def _subtitle_summary(dest: str) -> str: |
| 2423 | 2439 | return " · ".join(parts) |
| 2424 | 2440 | |
| 2425 | 2441 | |
| 2442 | def _subtitle_summary_focused(dest: str, present: list, records: dict, |
| 2443 | stem: str) -> str: |
| 2444 | """English first: what the sync did to it, then how many other languages |
| 2445 | sit beside the copy unchecked.""" |
| 2446 | focus = [p for p in present if _sidecar_lang(dest, p) in SUBSYNC_LANGUAGES] |
| 2447 | others = len(present) - len(focus) |
| 2448 | removed = [k for k, v in records.items() |
| 2449 | if k.startswith(stem) and v.get("status") == "unfixable" |
| 2450 | and _sidecar_lang(dest, k) in SUBSYNC_LANGUAGES] |
| 2451 | |
| 2452 | def describe(p: str) -> str: |
| 2453 | rec = records.get(os.path.abspath(p)) or {} |
| 2454 | if rec.get("identity") != _file_identity(p): |
| 2455 | return "not yet checked" |
| 2456 | if rec.get("status") == "corrected": |
| 2457 | return (f"corrected (was {rec['offset']:+.2f} s, " |
| 2458 | f"speed {rec['scale']:.3f})").replace("+", "") |
| 2459 | return {"in_sync": "in sync"}.get(rec.get("status"), "not yet checked") |
| 2460 | |
| 2461 | if len(focus) == 1: |
| 2462 | head = f"English: {describe(focus[0])}" |
| 2463 | elif focus: |
| 2464 | states = [describe(p).split(" (")[0] for p in focus] |
| 2465 | head = f"English ({len(focus)}): " + " · ".join( |
| 2466 | f"{states.count(x)} {x}" for x in dict.fromkeys(states)) |
| 2467 | else: |
| 2468 | head = ("English: removed (out of sync)" if removed else "no English") |
| 2469 | if others: |
| 2470 | head += (f" · {others} other language{'s' if others != 1 else ''} " |
| 2471 | "not checked") |
| 2472 | return head |
| 2473 | |
| 2474 | |
| 2426 | 2475 | def _fetch_subtitles_for(dest: str, meta: dict, season: int, episode: int) -> None: |
| 2427 | 2476 | """Put whatever subtitles exist beside a published copy. Never raises. |
| 2428 | 2477 | |
| … | … | @@ -4679,10 +4728,10 @@ def web_cancel(): |
| 4679 | 4728 | # ---------------------------------- watch ----------------------------------- |
| 4680 | 4729 | # Play a library copy in the browser to judge what it actually is. |
| 4681 | 4730 | # |
| 4682 | | # Audio a browser cannot decode plays as SILENCE, and it is the FIRST audio |
| 4683 | | # track that decides it. Measured 2026-10-04 in Chromium: canPlayType for ec-3 |
| 4684 | | # and ac-3 is "", an eac3-only file decoded 0 audio bytes, and an eac3 + aac |
| 4685 | | # file ALSO decoded 0 -- no fallback to the later track it could have played. |
| 4731 | # Audio a browser cannot decode plays as SILENCE, and it is the DEFAULT-flagged |
| 4732 | # track that decides it (see _browser_audio). Measured 2026-10-04 in Chromium: |
| 4733 | # canPlayType for ec-3 and ac-3 is "", an eac3-only file decoded 0 audio bytes, |
| 4734 | # and the browser never switches to another track it could have played. |
| 4686 | 4735 | # Jellyfin gets sound out of these by re-encoding the audio into an HLS stream |
| 4687 | 4736 | # per viewer (labs#13 has the trace); that machinery is deliberately not |
| 4688 | 4737 | # rebuilt here. Picture-only is enough to judge picture quality, and the page |
| … | … | @@ -4697,7 +4746,7 @@ def _ffprobe_file(path: str) -> Optional[dict]: |
| 4697 | 4746 | ["ffprobe", "-v", "error", "-show_entries", |
| 4698 | 4747 | "stream=index,codec_type,codec_name,profile,width,height," |
| 4699 | 4748 | "avg_frame_rate,bit_rate,channels,pix_fmt:stream_tags=language" |
| 4700 | | ":format=duration,size,bit_rate", |
| 4749 | ":stream_disposition=default:format=duration,size,bit_rate", |
| 4701 | 4750 | "-of", "json", path], |
| 4702 | 4751 | capture_output=True, text=True, timeout=60) |
| 4703 | 4752 | return json.loads(out.stdout) if out.returncode == 0 else None |
| … | … | @@ -4720,6 +4769,17 @@ def _quality(path: str) -> Optional[dict]: |
| 4720 | 4769 | return _quality_cache[key] |
| 4721 | 4770 | |
| 4722 | 4771 | |
| 4772 | def _browser_audio(audio: list[dict]) -> Optional[dict]: |
| 4773 | """The track a browser plays: the DEFAULT-flagged one, not the first. |
| 4774 | Measured 2026-10-04 in Chromium: aac first + not default decoded 0 audio |
| 4775 | bytes; eac3 first + aac default played the aac. With no flag recorded |
| 4776 | (HdHub's probe does not ask for it), the first track is the best guess.""" |
| 4777 | for a in audio: |
| 4778 | if (a.get("disposition") or {}).get("default"): |
| 4779 | return a |
| 4780 | return audio[0] if audio else None |
| 4781 | |
| 4782 | |
| 4723 | 4783 | def _fps(rate: Optional[str]) -> Optional[float]: |
| 4724 | 4784 | try: |
| 4725 | 4785 | num, den = (rate or "").split("/") |
| … | … | @@ -4815,11 +4875,12 @@ def _quality_html(path: str, meta: Optional[dict]) -> str: |
| 4815 | 4875 | rows.append(("Source", _source_of(path) or "not recorded")) |
| 4816 | 4876 | rows.append(("Subtitles", _subtitle_summary(path))) |
| 4817 | 4877 | warn = "" |
| 4818 | | if audio and audio[0].get("codec_name") in BROWSER_SILENT_AUDIO: |
| 4819 | | warn = ("<p class=warn>This copy plays without sound here: its first " |
| 4820 | | f"audio track is {html.escape(audio[0].get('codec_name'))}, " |
| 4821 | | "which browsers cannot decode (and they do not fall back to a " |
| 4822 | | "later track). Kodi and Jellyfin play it with sound.</p>") |
| 4878 | played = _browser_audio(audio) |
| 4879 | if played and played.get("codec_name") in BROWSER_SILENT_AUDIO: |
| 4880 | warn = ("<p class=warn>This copy plays without sound here: its default " |
| 4881 | f"audio track is {html.escape(played.get('codec_name'))}, " |
| 4882 | "which browsers cannot decode (and they do not switch to " |
| 4883 | "another track). Kodi and Jellyfin play it with sound.</p>") |
| 4823 | 4884 | return warn + "<table>" + "".join( |
| 4824 | 4885 | f"<tr><td class=mute>{k}</td><td>{html.escape(str(v))}</td></tr>" |
| 4825 | 4886 | for k, v in rows) + "</table>" |
| … | … | @@ -5096,9 +5157,10 @@ def _preview_quality_html(pv: dict) -> str: |
| 5096 | 5157 | lang = (a.get("tags") or {}).get("language") or "und" |
| 5097 | 5158 | rows.append((f"Audio {i}", f"{a.get('codec_name')} · {lang}")) |
| 5098 | 5159 | warn = "" |
| 5099 | | if audio and audio[0].get("codec_name") in BROWSER_SILENT_AUDIO: |
| 5100 | | warn = ("<p class=warn>This preview plays without sound: the first " |
| 5101 | | f"audio track is {html.escape(audio[0].get('codec_name'))}, " |
| 5160 | played = _browser_audio(audio) |
| 5161 | if played and played.get("codec_name") in BROWSER_SILENT_AUDIO: |
| 5162 | warn = ("<p class=warn>This preview plays without sound: the default " |
| 5163 | f"audio track is {html.escape(played.get('codec_name'))}, " |
| 5102 | 5164 | "which browsers cannot decode. The copy keeps it.</p>") |
| 5103 | 5165 | return warn + "<table>" + "".join( |
| 5104 | 5166 | f"<tr><td class=mute>{k}</td><td>{html.escape(str(v))}</td></tr>" |