#!/usr/bin/env python3 """ARRFLIX subtitle coverage audit — read-only. Queries Jellyfin live (via SSH+curl into the nullstone container), classifies every TV episode and movie by the source of its English subtitle (sidecar / embedded / none), and renders a Markdown report. Designed to be regenerated on demand and committed alongside the recipe so the repo always has a current view of what's subbed and what isn't. Usage: JELLYFIN_TOKEN= \\ playbooks/subtitles/lib/audit-coverage.py [--out PATH] Default output path: playbooks/subtitles/COVERAGE.md (relative to repo root). With --stdout, prints to stdout instead of writing the file. Env (required): JELLYFIN_TOKEN X-Emby-Token for nullstone Jellyfin Env (optional): NULLSTONE SSH target, default user@192.168.0.100 Classification (per episode): █ eng sidecar STYLE.md happy path ▒ eng embedded only playable but doesn't satisfy "1 .eng.srt per ep" ▓ other-lang embedded no English at all, only foreign subs muxed · none nothing — fetch needed """ from __future__ import annotations import argparse import collections import datetime as _dt import json import os import shlex import subprocess import sys import urllib.parse NULLSTONE = os.environ.get("NULLSTONE", "user@192.168.0.100") JF_BASE = "http://localhost:8096" REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) DEFAULT_OUT = os.path.join(REPO_ROOT, "playbooks", "subtitles", "COVERAGE.md") def die(msg: str, code: int = 1) -> None: print(f"ERROR: {msg}", file=sys.stderr) sys.exit(code) def jellyfin(path: str, params: dict | None = None) -> dict: tok = os.environ.get("JELLYFIN_TOKEN") or die("JELLYFIN_TOKEN not set") qs = "?" + urllib.parse.urlencode(params, safe=",") if params else "" url = JF_BASE + path + qs cmd = ["ssh", NULLSTONE, f"docker exec jellyfin curl -s -H 'X-Emby-Token: {tok}' {shlex.quote(url)}"] return json.loads(subprocess.check_output(cmd, text=True)) def stream_summary(item: dict) -> dict: out = {"eng_sidecar": 0, "eng_embed": 0, "other_sidecar": 0, "other_embed": 0, "embedded_any": 0, "sub_total": 0} for st in item.get("MediaStreams", []) or []: if st.get("Type") != "Subtitle": continue out["sub_total"] += 1 lang = (st.get("Language") or "").lower() if st.get("IsExternal"): if lang in ("eng", "en"): out["eng_sidecar"] += 1 else: out["other_sidecar"] += 1 else: out["embedded_any"] += 1 if lang in ("eng", "en"): out["eng_embed"] += 1 else: out["other_embed"] += 1 return out def ep_status_char(s: dict) -> str: if s["eng_sidecar"]: return "█" if s["eng_embed"]: return "▒" if s["embedded_any"]: return "▓" if s["sub_total"] == 0: return "·" return "?" def render_show_block(name: str, eps: list[dict]) -> tuple[str, dict]: eps.sort(key=lambda e: (e.get("ParentIndexNumber", 0), e.get("IndexNumber", 0))) counts = {"eng_sc": 0, "eng_emb": 0, "embed_other": 0, "none": 0} bar = [] for e in eps: sm = stream_summary(e) if sm["eng_sidecar"]: counts["eng_sc"] += 1 elif sm["eng_embed"]: counts["eng_emb"] += 1 elif sm["embedded_any"]: counts["embed_other"] += 1 else: counts["none"] += 1 bar.append(ep_status_char(sm)) n = len(eps) pct = counts["eng_sc"] * 100 // n if n else 0 if counts["eng_sc"] == n: status = f"OK ({pct}%)" elif counts["eng_sc"] + counts["eng_emb"] == n: status = "OK-EMBED (no sidecars)" elif counts["none"] == n: status = "NEEDS SUBS" else: status = f"PARTIAL ({pct}%)" line = (f"{name:<42} {n:>4} {counts['eng_sc']:>6} " f"{counts['eng_emb']:>7} {counts['none']:>4} {status}") bar_lines = [] for i in range(0, len(bar), 50): bar_lines.append(" " + "".join(bar[i:i+50])) return line + "\n" + "\n".join(bar_lines), counts def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--out", default=DEFAULT_OUT) ap.add_argument("--stdout", action="store_true") args = ap.parse_args() print("[audit] querying Jellyfin…", file=sys.stderr) series = jellyfin("/Items", { "IncludeItemTypes": "Series", "Recursive": "true", "Fields": "Path", "SortBy": "SortName", })["Items"] eps = jellyfin("/Items", { "IncludeItemTypes": "Episode", "Recursive": "true", "Fields": "Path,MediaStreams,SeriesName,ParentIndexNumber,IndexNumber", })["Items"] movies = jellyfin("/Items", { "IncludeItemTypes": "Movie", "Recursive": "true", "Fields": "Path,MediaStreams", "SortBy": "SortName", })["Items"] by_series = collections.defaultdict(list) for e in eps: by_series[e.get("SeriesId") or e.get("SeasonId", "???")].append(e) now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") out = [] out.append("# ARRFLIX subtitle coverage") out.append("") out.append(f"_Generated {now} by `playbooks/subtitles/lib/audit-coverage.py`._") out.append(f"_Re-run: `JELLYFIN_TOKEN= playbooks/subtitles/lib/audit-coverage.py`._") out.append("") out.append("Legend: `█` eng sidecar · `▒` eng embedded only · " "`▓` other-lang embedded · `·` none") out.append("") out.append("## TV shows") out.append("") out.append("```") out.append(f"{'Show':<42} {'Eps':>4} {'sc':>6} {'emb':>7} {'none':>4} Status") out.append("─" * 78) agg = {"eng_sc": 0, "eng_emb": 0, "embed_other": 0, "none": 0, "total": 0} for s in sorted(series, key=lambda x: x["Name"].lower()): sid = s["Id"] block, counts = render_show_block(s["Name"], by_series.get(sid, [])) out.append(block) out.append("") for k in agg: if k == "total": continue agg[k] += counts[k] agg["total"] += sum(counts.values()) out.append("```") out.append("") out.append("## Movies") out.append("") out.append("```") out.append(f"{'Title':<58} {'sc':>6} {'emb':>7} Status") out.append("─" * 78) m_eng = 0 for m in sorted(movies, key=lambda x: x["Name"].lower()): sm = stream_summary(m) if sm["eng_sidecar"]: status = "OK (sidecar)" elif sm["eng_embed"]: status = "OK (embedded)" elif sm["embedded_any"]: status = "OTHER-LANG ONLY" elif sm["sub_total"] == 0: status = "NEEDS SUBS" else: status = "?" if sm["eng_sidecar"] or sm["eng_embed"]: m_eng += 1 name = m["Name"] if len(name) > 56: name = name[:55] + "…" out.append(f"{name:<58} {sm['eng_sidecar']:>6} {sm['eng_embed']:>7} {status}") out.append("```") out.append("") out.append("## Aggregate") out.append("") n = agg["total"] or 1 out.append("| Metric | Count | % |") out.append("|---|---:|---:|") out.append(f"| Episodes total | {agg['total']} | — |") out.append(f"| eng sidecar | {agg['eng_sc']} | {agg['eng_sc']*100//n}% |") out.append(f"| eng embedded only | {agg['eng_emb']} | {agg['eng_emb']*100//n}% |") out.append(f"| other-lang embedded only | {agg['embed_other']} | {agg['embed_other']*100//n}% |") out.append(f"| no subs anywhere | {agg['none']} | {agg['none']*100//n}% |") out.append(f"| Movies total | {len(movies)} | — |") out.append(f"| Movies with any eng sub | {m_eng} | " f"{m_eng*100//max(len(movies),1)}% |") out.append("") out.append("## Status meanings") out.append("") out.append("- **OK** — every episode has an external `.eng.srt` sidecar (STYLE.md happy path)") out.append("- **OK-EMBED** — all eps playable in English but no sidecars; `SaveSubtitlesWithMedia` won't trigger fetch since Jellyfin sees an eng track already") out.append("- **PARTIAL (X %)** — some sidecars, some gaps") out.append("- **NEEDS SUBS** — zero subs of any language; v3 / v3.5 / v4 fetch required") out.append("- **OTHER-LANG ONLY** (movies) — embedded subs exist but none in English") rendered = "\n".join(out) + "\n" if args.stdout: sys.stdout.write(rendered) else: with open(args.out, "w") as f: f.write(rendered) print(f"[audit] wrote {args.out}", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main())