diff --git a/processes/README.md b/processes/README.md index 09c09ef..0889454 100644 --- a/processes/README.md +++ b/processes/README.md @@ -21,4 +21,4 @@ amendment for a full sweep. | Process | Status | Last touched | |---|---|---| -| [`subtitles/`](subtitles/) | v1 — partial pass on American Dad (S01 only); broke on S02 | 2026-05-09 | +| [`subtitles/`](subtitles/) | v2 — direct OpenSubtitles REST. AD 19/58 eps subbed (S01 + S02E01–E12); S02E13–S04 awaiting next quota window | 2026-05-09 | diff --git a/processes/subtitles/CHANGELOG.md b/processes/subtitles/CHANGELOG.md index 22332a2..7e53605 100644 --- a/processes/subtitles/CHANGELOG.md +++ b/processes/subtitles/CHANGELOG.md @@ -28,21 +28,38 @@ Each library episode has its own correct per-episode IMDB id (e.g. `tt0511631` for "Bullocks to Stan") which would resolve directly via OS REST `imdb_id=` parameter, but the plugin doesn't expose that path. -### v2 — pending design +## v2 — 2026-05-09 -Two paths under consideration: +Approach **A** chosen: direct OpenSubtitles REST API, per-episode `imdb_id` +lookup, bypass the Jellyfin plugin entirely. New helper at +`lib/sub-rest-fetch.py`. -- **A. Direct OpenSubtitles REST** — bypass plugin for fetch, use per-episode - IMDB id lookup. Requires registering a free API key at - `opensubtitles.com/consumers`. Process becomes a Python script (or extends - the existing helper) that logs in with `Caveman5` creds and uses the API - key for searches. Survives any season-numbering mismatch. +- API key file: `~/.config/arrflix-opensubtitles-api.txt` (mode 600) +- Account: `Caveman5` (free tier, 20 downloads/day) +- Saves sidecars directly to nullstone media folder via `ssh ... cat >` +- No more docker-cp from `/config/metadata/library` cache (plugin path) -- **B. Library re-numbering** — re-scan AD with metadata indexer using Fox - airing order so library aligns with OpenSubtitles. Risk: re-orders existing - files and breaks user's mental model of the library. Doesn't help if the - next show has its own numbering quirk. +Recipe upgrade: +- Step 4 swaps `lib/sub-fetch.sh` → `lib/sub-rest-fetch.py` for shows with + non-standard season ordering. +- Picker logic identical: filter HI/MT/AI/Forced (renamed + `foreign_parts_only` in OS REST), prefer 23.976fps, sort by + `download_count` desc. -Recommendation: **A**. It's the more general fix; the next show with weird -numbering won't break it. It also unblocks higher-quality manual pick (filter -by `feature_id`, `imdb_id`, hash) which the plugin filters out today. +### v2 known quirks + +- **OpenSubtitles `/download` endpoint rejects urllib** — consistent HTTP 503 + via Python `urllib.request`, HTTP 200 via `curl` with same headers/body. + `_curl()` shim added; all OS API calls go through it. **Each 503 still + consumes 1 download-quota slot**, so this had to be fixed before retrying + large batches. +- `download_count` of `0` and `fps` of `0.0` appear on some catalogue + entries; treat as informational, not exclusionary. +- Some hits have `file_name` mismatching the `imdb_id` searched (OS metadata + drift). Recipe Step 6 visual-sync check is the catch. + +### v2 known limits + +- Free-tier 20/day still in force (REST and plugin share the counter). +- Recipe Step 6 (sync verification) is still manual — no automated check + that the picked .srt actually aligns with audio. diff --git a/processes/subtitles/README.md b/processes/subtitles/README.md index aa0b055..20650c1 100644 --- a/processes/subtitles/README.md +++ b/processes/subtitles/README.md @@ -1,7 +1,7 @@ # Subtitle acquisition process — v1 Last updated: 2026-05-09 -Status: **v1, partial** — passed American Dad S01 (7/7 eps), broke on S02E01 due to season-numbering mismatch. v2 design pending. +Status: **v2** — direct REST API. American Dad S01–S02 (19/58 eps) subbed. S02E13–S04 awaiting next quota window. This recipe is written for Claude Code to execute. Each step lists the exact command, what to verify, and what to do on failure. Background reference for @@ -71,62 +71,47 @@ ssh user@192.168.0.100 "docker exec jellyfin curl -s -H 'X-Emby-Token: $TOK' \ --- -## Step 3 — Validate season numbering against OpenSubtitles +## Step 3 — Pick fetch path -> ⚠️ **Critical, added in v2** (currently provisional — see CHANGELOG): some shows -> are catalogued differently across services. American Dad is the canonical -> example: Hulu/DSP carriers split the original Fox 23-ep S1 into Hulu S1 (7 -> eps) + S2 (16 eps). OpenSubtitles indexes by Fox airing order. The plugin -> queries by `(parent_imdb_id, season, episode)` so library-side Hulu numbering -> returns 0 results past the first 7 episodes. +Two paths, differ in robustness vs simplicity: -How to check: +| Path | When to use | Tool | +|---|---|---| +| **v1 (plugin)** | Library season/episode numbering matches OpenSubtitles indexing AND every episode has good IMDB ProviderId | `lib/sub-fetch.sh` | +| **v2 (REST)** | Default. Survives Hulu/Fox numbering mismatches and shows with weird ordering | `lib/sub-rest-fetch.py` | + +Quick check whether v1 will work: 1. Pick the first episode of season 2 in the library. -2. Run a `RemoteSearch/Subtitles/eng` against it (Step 4 below, but read-only). -3. If results > 0 — numbering matches OpenSubtitles. Proceed. -4. If results == 0 but the show exists on opensubtitles.com — numbering mismatch. **Stop**. Fix metadata first or use the v2 direct-API path (TBD). +2. Run `curl -s -H 'X-Emby-Token: $TOK' 'http://localhost:8096/Items/$EP/RemoteSearch/Subtitles/eng'` (read-only). +3. If results > 0 — v1 works. v2 also works. +4. If results == 0 but the show exists on opensubtitles.com — numbering mismatch (e.g. American Dad: library uses Hulu S1=7 eps; OS uses Fox S1=23). Use **v2**. + +When in doubt, use v2. --- ## Step 4 — Fetch subs per episode -Per-episode loop. Helper script lives at `processes/subtitles/lib/sub-fetch.sh` -(promoted from `/tmp` once stable; see CHANGELOG v0→v1). +Use `lib/sub-rest-fetch.py` (v2). It logs in to OpenSubtitles, looks each +episode up by its per-episode IMDB id, picks the best English match, and +writes the sidecar straight to nullstone. ```bash -TOK= -EP= -MEDIA_DIR='/home/user/media/tv//Season XX' -MEDIA_BASE=' - SxxExx - ' - -# 1. search -RAW=$(ssh user@192.168.0.100 "docker exec jellyfin curl -s -H 'X-Emby-Token: $TOK' \ - 'http://localhost:8096/Items/$EP/RemoteSearch/Subtitles/eng'") - -# 2. pick best non-HI/non-MT/non-AI/non-Forced match, prefer 23.976fps, then highest DownloadCount -SUBID=$(printf '%s' "$RAW" | python3 -c " -import json,sys -subs=json.load(sys.stdin) -clean=[s for s in subs if not (s.get('HearingImpaired') or s.get('MachineTranslated') or s.get('AiTranslated') or s.get('Forced'))] -if not clean: clean=subs -fps2398=[s for s in clean if abs(s.get('FrameRate',0)-23.976)<0.01] -pool=fps2398 if fps2398 else clean -pool.sort(key=lambda s: -s.get('DownloadCount',0)) -print(pool[0]['Id'] if pool else '')") - -# 3. download (returns 204) -ssh user@192.168.0.100 "docker exec jellyfin curl -s -X POST -H 'X-Emby-Token: $TOK' \ - 'http://localhost:8096/Items/$EP/RemoteSearch/Subtitles/$SUBID' -w 'HTTP %{http_code}\n'" - -# 4. plugin saves to /config/metadata/library/<shard>/<itemId>/<base>.eng.srt -# NOT next to media (manual-pick path ignores SaveSubtitlesWithMedia). -# Move it into place: -SHARD="${EP:0:2}" -ssh user@192.168.0.100 "docker cp \"jellyfin:/config/metadata/library/$SHARD/$EP/$MEDIA_BASE.eng.srt\" \ - \"$MEDIA_DIR/\"" +JELLYFIN_TOKEN=<admin-token> \ +OPENSUBTITLES_API_KEY=$HOME/.config/arrflix-opensubtitles-api.txt \ +OPENSUBTITLES_USER=Caveman5 \ +OPENSUBTITLES_PASS=<password> \ +processes/subtitles/lib/sub-rest-fetch.py <series-id> --season N [--start E] [--end E] ``` +Pre-flight with `DRY_RUN=1` to see picks without consuming quota. + +The legacy v1 path (Jellyfin plugin RemoteSearch + docker cp) lives at +`lib/sub-fetch.sh` and is kept for shows where library numbering matches +OpenSubtitles' indexing — slightly less general but doesn't depend on the +external OS REST API or our 20/day account quota. + Verify after each batch: ```bash @@ -135,15 +120,18 @@ ssh user@192.168.0.100 'ls "<media-dir>/" | grep -c eng.srt' --- -## Step 5 — Clean up duplicates + library scan +## Step 5 — Library scan + de-dup (v1 only) -The metadata-cache copy and the media-folder sidecar both register as -subtitle streams in Jellyfin (counted twice). Delete the cache copies: +If you used the v1 plugin path, the metadata-cache copy and the media-folder +sidecar both register as subtitle streams in Jellyfin (counted twice). +Delete the cache copies: ```bash ssh user@192.168.0.100 'docker exec jellyfin bash -c "find /config/metadata/library -path \"*<show-name>*S0[1-9]E*.eng.srt\" -delete -print"' ``` +v2 writes directly to the media folder so there is no cache copy to clean. + Trigger a validation-only refresh so Jellyfin sees the new sidecars: ```bash diff --git a/processes/subtitles/lib/sub-rest-fetch.py b/processes/subtitles/lib/sub-rest-fetch.py new file mode 100755 index 0000000..846a18a --- /dev/null +++ b/processes/subtitles/lib/sub-rest-fetch.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Subtitle fetcher v2 — direct OpenSubtitles REST API. + +Bypasses the Jellyfin OpenSubtitles plugin to dodge season/episode numbering +mismatches. Looks each library episode up by its per-episode IMDB id, picks +the best English match, downloads via the REST endpoint, and writes the +sidecar straight onto nullstone next to the media file (via SSH). + +Why v2 exists: see ../CHANGELOG.md "Known break" — American Dad library +uses Hulu season numbering, OS catalogues by Fox airing order; the plugin +queries by (parent_imdb_id, season, episode) so library S02E01 → OS S01E08 +returned 0 hits even though the per-episode IMDB id (tt0511631) is real. + +Picker: highest download_count among non-HI, non-MT, non-AI, non-Forced +candidates; 23.976fps preferred. Falls back to all candidates if every match +is HI/MT/AI/Forced. + +Usage: + sub-rest-fetch.py <series-id> --season <N> [--start <ep>] [--end <ep>] + sub-rest-fetch.py <series-id> --all + +Env (required): + JELLYFIN_TOKEN X-Emby-Token for nullstone Jellyfin + OPENSUBTITLES_API_KEY Path to file holding the API key + OPENSUBTITLES_USER OS account username + OPENSUBTITLES_PASS OS account password + +Env (optional): + NULLSTONE SSH target, default user@192.168.0.100 + DRY_RUN=1 search + pick only, no download +""" +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +import time +import urllib.parse + +OS_BASE = "https://api.opensubtitles.com/api/v1" +USER_AGENT = "arrflix v1.0.0" +JF_BASE = "http://localhost:8096" +NULLSTONE = os.environ.get("NULLSTONE", "user@192.168.0.100") + + +def die(msg: str, code: int = 1) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + sys.exit(code) + + +def env_or_die(name: str) -> str: + v = os.environ.get(name) + if not v: + die(f"{name} not set") + return v + + +def load_api_key() -> str: + path = env_or_die("OPENSUBTITLES_API_KEY") + with open(path) as f: + return f.read().strip() + + +def _curl(url: str, method: str = "GET", headers: dict | None = None, + body: dict | None = None, binary: bool = False) -> bytes: + """OpenSubtitles' frontend rejects urllib (consistent 503 on /download). + curl works against the same endpoint and headers. Use curl uniformly.""" + cmd = ["curl", "-sSf", "-X", method, url] + for k, v in (headers or {}).items(): + cmd += ["-H", f"{k}: {v}"] + if body is not None: + cmd += ["--data", json.dumps(body)] + return subprocess.check_output(cmd) + + +def http_json(url: str, method: str = "GET", headers: dict | None = None, + body: dict | None = None) -> dict: + raw = _curl(url, method, headers, body) + return json.loads(raw.decode()) + + +def http_get_bytes(url: str) -> bytes: + return _curl(url, "GET", headers={"User-Agent": USER_AGENT}) + + +def jellyfin(path: str, params: dict | None = None) -> dict: + """Run Jellyfin API call inside the container on nullstone via SSH.""" + tok = env_or_die("JELLYFIN_TOKEN") + qs = "" + if params: + qs = "?" + urllib.parse.urlencode(params, safe=",") + url = JF_BASE + path + qs + cmd = ["ssh", NULLSTONE, + f"docker exec jellyfin curl -s -H 'X-Emby-Token: {tok}' {shlex.quote(url)}"] + out = subprocess.check_output(cmd, text=True) + return json.loads(out) + + +def list_episodes(series_id: str) -> list[dict]: + d = jellyfin(f"/Items", { + "ParentId": series_id, + "IncludeItemTypes": "Episode", + "Recursive": "true", + "Fields": "Path,ParentIndexNumber,IndexNumber,ProviderIds", + "SortBy": "ParentIndexNumber,IndexNumber", + }) + return d["Items"] + + +def os_login(api_key: str, user: str, password: str) -> str: + res = http_json(f"{OS_BASE}/login", "POST", headers={ + "Api-Key": api_key, + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + }, body={"username": user, "password": password}) + return res["token"] + + +def os_user_info(api_key: str, bearer: str) -> dict: + return http_json(f"{OS_BASE}/infos/user", headers={ + "Api-Key": api_key, + "Authorization": f"Bearer {bearer}", + "User-Agent": USER_AGENT, + })["data"] + + +def os_search(api_key: str, imdb_id: str) -> list[dict]: + """imdb_id without the 'tt' prefix per OS convention.""" + res = http_json( + f"{OS_BASE}/subtitles?imdb_id={imdb_id}&languages=en", + headers={"Api-Key": api_key, "User-Agent": USER_AGENT}) + return res.get("data", []) + + +def pick_best(hits: list[dict]) -> dict | None: + """Filter HI/MT/AI/Forced, prefer 23.976fps, sort by download_count desc.""" + def attr(h, k): + return h["attributes"].get(k) + + clean = [h for h in hits + if not attr(h, "hearing_impaired") + and not attr(h, "machine_translated") + and not attr(h, "ai_translated") + and not attr(h, "foreign_parts_only")] + if not clean: + clean = hits + fps2398 = [h for h in clean if abs((attr(h, "fps") or 0) - 23.976) < 0.01] + pool = fps2398 if fps2398 else clean + pool.sort(key=lambda h: -(attr(h, "download_count") or 0)) + return pool[0] if pool else None + + +def os_download(api_key: str, bearer: str, file_id: int) -> dict: + return http_json(f"{OS_BASE}/download", "POST", headers={ + "Api-Key": api_key, + "Authorization": f"Bearer {bearer}", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + }, body={"file_id": file_id}) + + +def write_sidecar_remote(content: bytes, remote_path: str) -> None: + """ssh redirect file content to nullstone.""" + cmd = ["ssh", NULLSTONE, f"cat > {shlex.quote(remote_path)}"] + p = subprocess.Popen(cmd, stdin=subprocess.PIPE) + p.communicate(content) + if p.returncode != 0: + die(f"failed writing {remote_path}") + + +def imdb_strip(s: str | None) -> str | None: + if not s: + return None + return s[2:] if s.startswith("tt") else s + + +def episode_to_paths(ep: dict) -> tuple[str, str]: + """Return (remote_dir, base_filename) for sidecar placement.""" + container_path = ep["Path"] # /media/tv/Show/Season XX/Show - SxxExx - Title.mkv + host_path = container_path.replace("/media/", "/home/user/media/") + remote_dir = os.path.dirname(host_path) + base = os.path.splitext(os.path.basename(host_path))[0] + return remote_dir, base + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("series_id") + ap.add_argument("--season", type=int, default=None) + ap.add_argument("--start", type=int, default=1) + ap.add_argument("--end", type=int, default=10**6) + ap.add_argument("--all", action="store_true") + args = ap.parse_args() + + if args.season is None and not args.all: + die("pass --season N or --all") + + api_key = load_api_key() + user = env_or_die("OPENSUBTITLES_USER") + pw = env_or_die("OPENSUBTITLES_PASS") + dry = os.environ.get("DRY_RUN") == "1" + + bearer = os_login(api_key, user, pw) + info = os_user_info(api_key, bearer) + print(f"[quota] remaining={info['remaining_downloads']}/{info['allowed_downloads']}, " + f"resets in {info['reset_time']}", file=sys.stderr) + + eps = list_episodes(args.series_id) + work = [] + for ep in eps: + s = ep["ParentIndexNumber"] + n = ep["IndexNumber"] + if not args.all and s != args.season: + continue + if not (args.start <= n <= args.end): + continue + work.append(ep) + if not work: + die("no episodes selected") + + print(f"[plan] {len(work)} episodes selected", file=sys.stderr) + if not dry and len(work) > info["remaining_downloads"]: + print(f"[warn] {len(work)} > quota {info['remaining_downloads']}; " + f"will halt mid-run", file=sys.stderr) + + ok = 0 + fail = [] + for ep in work: + s, n = ep["ParentIndexNumber"], ep["IndexNumber"] + label = f"S{s:02}E{n:02} {ep['Name']}" + imdb = imdb_strip(ep.get("ProviderIds", {}).get("Imdb")) + if not imdb: + print(f"[skip] {label} — no IMDB id", file=sys.stderr) + fail.append((label, "no-imdb")) + continue + + hits = os_search(api_key, imdb) + pick = pick_best(hits) + if not pick: + print(f"[skip] {label} — 0 hits for imdb={imdb}", file=sys.stderr) + fail.append((label, "no-hits")) + continue + + a = pick["attributes"] + f = a["files"][0] + print(f"[pick] {label} imdb={imdb} fid={f['file_id']} dl={a.get('download_count')} " + f"fps={a.get('fps')} fname={f.get('file_name')}", file=sys.stderr) + + if dry: + ok += 1 + continue + + try: + dl = os_download(api_key, bearer, f["file_id"]) + except subprocess.CalledProcessError as e: + print(f"[fail] {label} download (curl exit {e.returncode})", file=sys.stderr) + fail.append((label, f"dl-curl-{e.returncode}")) + break # may be quota; stop run + + link = dl.get("link") + if not link: + print(f"[fail] {label} no download link in response: {dl}", file=sys.stderr) + fail.append((label, "no-link")) + break + + content = http_get_bytes(link) + remote_dir, base = episode_to_paths(ep) + dest = f"{remote_dir}/{base}.eng.srt" + write_sidecar_remote(content, dest) + print(f"[ok] {label} -> {dest} (remaining={dl.get('remaining')})", + file=sys.stderr) + ok += 1 + time.sleep(0.5) # be polite + + print(f"\n[done] ok={ok}/{len(work)} failures={len(fail)}", file=sys.stderr) + for lab, why in fail: + print(f" - {lab}: {why}", file=sys.stderr) + return 0 if ok else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/processes/subtitles/runs/american-dad.md b/processes/subtitles/runs/american-dad.md index e5c19fb..94d9675 100644 --- a/processes/subtitles/runs/american-dad.md +++ b/processes/subtitles/runs/american-dad.md @@ -1,9 +1,9 @@ # Subtitle run — `American Dad! (2005)` -Recipe version: v1 +Recipe version: v1 (S01) → v2 (S02 partial) Run date: 2026-05-09 Operator: Claude Code @ onyx session, ai-lab cwd -Quota at start / end: 20 / 13 (7 downloads, all S01) +Quota usage: 20 → 1 (19 downloads: S01=7, S02=12; 2 lost to urllib-503 bug, recovered manually) ## Source @@ -29,12 +29,12 @@ Library uses Hulu/DSP season ordering (S1=7 eps). Original Fox order has S1=23 e | Season | Eps | Subs fetched | Quality sample | Notes | |---|---|---|---|---| -| S01 | 7 | 7 / 7 | not yet visually verified by playback (TODO) | All from `OMiCRON DVDRip` release group, fps 23.976 except S01E07 (24 fps), no SDH | -| S02 | 16 | 0 / 16 | n/a | Plugin RemoteSearch returns 0 for E01/E02/E08/E13 — broke recipe | -| S03 | 19 | 0 / 19 | n/a | Untested, expected same failure | -| S04 | 16 | 0 / 16 | n/a | Untested, expected same failure | +| S01 | 7 | 7 / 7 | not yet visually verified by playback (TODO) | v1 path. All from `OMiCRON DVDRip` release group, fps 23.976 except S01E07 (24 fps), no SDH | +| S02 | 16 | 12 / 16 | not yet visually verified | v2 path (REST). E01-E12 done. E13-E16 deferred — daily quota = 1 left, resets 23:59 UTC | +| S03 | 19 | 0 / 19 | n/a | Awaiting next quota window | +| S04 | 16 | 0 / 16 | n/a | Awaiting next quota window | -Net: **7 / 58 (12 %)**. +Net: **19 / 58 (33 %)**. ## Picks (S01) @@ -78,8 +78,30 @@ so there's no flag we can flip to make this work. `CHANGELOG.md` v2 design choice between direct OS REST (recommended) and library re-numbering. +## v2 picks (S02E01–E12) + +| Episode | Sub release | DLs | FPS | HI | +|---|---|---|---|---| +| S02E01 Bullocks to Stan | `american.dad.s01e08.dvdrip.xvid-omicron` | 25 846 | 23.976 | no | +| S02E02 A Smith in the Hand | `American Dad S01E09 A Smith in the Hand.DVDRip.NonHI.cc.en.20FOX` | 75 | 29.97 | no | +| S02E03 All About Steve | `American Dad S01E10 All About Steve.DVDRip.NonHI.cc.en.20FOX` | 2 600 | 29.97 | no | +| S02E04 Con Heir | `American Dad S01E11 Con Heir.DVDRip.NonHI.cc.en.20FOX` | 140 | 29.97 | no | +| S02E05 Stan of Arabia 1 | `American Dad S01E12 Stan of Arabia Part 1.DVDRip.NonHI.cc.en.20FOX` | 110 | 29.97 | no | +| S02E06 Stan of Arabia 2 | `American Dad S01E13 Stan of Arabia Part 2.DVDRip.NonHI.cc.en.20FOX` | 86 | 29.97 | no | +| S02E07 Stannie Get Your Gun | `American Dad S01E14 Stannie Get Your Gun.DVDRip.NonHI.cc.en.20FOX` | 99 | 29.97 | no | +| S02E08 Star Trek | `American Dad [2.15]` | 18 | 0.0 | no | +| S02E09 Not Particularly Desperate USER-Gwives | `American Dad [2.16]` | 24 | 0.0 | no | +| S02E10 Rough Trade | `American Dad S01E17 Rough Trade.DVDRip.NonHI.cc.en.20FOX` | 40 | 29.97 | no | +| S02E11 Finances With Wolves | `American Dad [1.18] Finances with Wolves-eng` | 7 730 | 23.976 | no | +| S02E12 It's Good to be the Queen | `American Dad - 1x19 - Its Good to be the Queen.en` | 13 228 | 23.976 | no | + +Note: 8 picks are 29.97 fps. SRT timestamps are absolute time, so this should +not desync on a 23.976 fps source provided NTSC durations match. Confirm via +recipe Step 6 sync sample on at least one 29.97-pick episode. + ## Followups -- [ ] visually verify a sample S01 sub plays in sync (one ep per recipe rule §6) -- [ ] decide v2 path (REST vs renumber) -- [ ] sub S02–S04 (51 eps) once v2 lands +- [ ] visually verify sample S01 sub plays in sync (recipe §6) +- [ ] visually verify sample S02 29.97-fps pick plays in sync (e.g. S02E03) +- [ ] tomorrow: sub S02E13–E16 (4 eps) + start S03 (19 eps total today + tomorrow) +- [ ] day after: finish S03 + S04 (16 eps)