processes/subtitles: v2 REST fetcher + AD S02E01-E12 subbed

Adds lib/sub-rest-fetch.py: direct OpenSubtitles REST, looks up subs by
per-episode IMDB id (e.g. tt0511631) instead of the plugin's
(parent_imdb_id, season, episode) combo path. This sidesteps shows where
library numbering diverges from OpenSubtitles' catalogued numbering --
American Dad uses Hulu S1=7 eps; OS uses Fox S1=23 eps; the plugin path
returns 0 hits past S01E07 even though every per-episode IMDB id is
correct.

Recipe README updated to surface the two paths (v1 plugin / v2 REST) and
recommend v2 by default. American Dad run log now shows 19/58 episodes
subbed (S01 7/7 via v1, S02E01-E12 via v2). S02E13-S04 (39 eps) deferred
to next 20/day quota windows.

Quirk fixed in v2: OpenSubtitles /download endpoint consistently returns
HTTP 503 to Python urllib.request despite identical headers/body via curl.
_curl() shim routes all OS API calls through curl. Each 503 still
consumes a download slot, so urllib path was unsafe to retry on.
This commit is contained in:
s8n 2026-05-09 23:09:09 +01:00
parent fedf3388b8
commit 23520df2df
5 changed files with 384 additions and 72 deletions

View file

@ -21,4 +21,4 @@ amendment for a full sweep.
| Process | Status | Last touched | | 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 + S02E01E12); S02E13S04 awaiting next quota window | 2026-05-09 |

View file

@ -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 `tt0511631` for "Bullocks to Stan") which would resolve directly via OS REST
`imdb_id=` parameter, but the plugin doesn't expose that path. `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 - API key file: `~/.config/arrflix-opensubtitles-api.txt` (mode 600)
IMDB id lookup. Requires registering a free API key at - Account: `Caveman5` (free tier, 20 downloads/day)
`opensubtitles.com/consumers`. Process becomes a Python script (or extends - Saves sidecars directly to nullstone media folder via `ssh ... cat >`
the existing helper) that logs in with `Caveman5` creds and uses the API - No more docker-cp from `/config/metadata/library` cache (plugin path)
key for searches. Survives any season-numbering mismatch.
- **B. Library re-numbering** — re-scan AD with metadata indexer using Fox Recipe upgrade:
airing order so library aligns with OpenSubtitles. Risk: re-orders existing - Step 4 swaps `lib/sub-fetch.sh``lib/sub-rest-fetch.py` for shows with
files and breaks user's mental model of the library. Doesn't help if the non-standard season ordering.
next show has its own numbering quirk. - 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 ### v2 known quirks
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. - **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.

View file

@ -1,7 +1,7 @@
# Subtitle acquisition process — v1 # Subtitle acquisition process — v1
Last updated: 2026-05-09 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 S01S02 (19/58 eps) subbed. S02E13S04 awaiting next quota window.
This recipe is written for Claude Code to execute. Each step lists the exact 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 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 Two paths, differ in robustness vs simplicity:
> 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.
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. 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). 2. Run `curl -s -H 'X-Emby-Token: $TOK' 'http://localhost:8096/Items/$EP/RemoteSearch/Subtitles/eng'` (read-only).
3. If results > 0 — numbering matches OpenSubtitles. Proceed. 3. If results > 0 — v1 works. v2 also works.
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). 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 ## Step 4 — Fetch subs per episode
Per-episode loop. Helper script lives at `processes/subtitles/lib/sub-fetch.sh` Use `lib/sub-rest-fetch.py` (v2). It logs in to OpenSubtitles, looks each
(promoted from `/tmp` once stable; see CHANGELOG v0→v1). episode up by its per-episode IMDB id, picks the best English match, and
writes the sidecar straight to nullstone.
```bash ```bash
TOK=<token> JELLYFIN_TOKEN=<admin-token> \
EP=<episode-id> OPENSUBTITLES_API_KEY=$HOME/.config/arrflix-opensubtitles-api.txt \
MEDIA_DIR='/home/user/media/tv/<Show>/Season XX' OPENSUBTITLES_USER=Caveman5 \
MEDIA_BASE='<Show> - SxxExx - <Title>' OPENSUBTITLES_PASS=<password> \
processes/subtitles/lib/sub-rest-fetch.py <series-id> --season N [--start E] [--end E]
# 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/\""
``` ```
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: Verify after each batch:
```bash ```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 If you used the v1 plugin path, the metadata-cache copy and the media-folder
subtitle streams in Jellyfin (counted twice). Delete the cache copies: sidecar both register as subtitle streams in Jellyfin (counted twice).
Delete the cache copies:
```bash ```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"' 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: Trigger a validation-only refresh so Jellyfin sees the new sidecars:
```bash ```bash

View file

@ -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())

View file

@ -1,9 +1,9 @@
# Subtitle run — `American Dad! (2005)` # Subtitle run — `American Dad! (2005)`
Recipe version: v1 Recipe version: v1 (S01) → v2 (S02 partial)
Run date: 2026-05-09 Run date: 2026-05-09
Operator: Claude Code @ onyx session, ai-lab cwd 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 ## 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 | | 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 | | 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 | 0 / 16 | n/a | Plugin RemoteSearch returns 0 for E01/E02/E08/E13 — broke recipe | | 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 | Untested, expected same failure | | S03 | 19 | 0 / 19 | n/a | Awaiting next quota window |
| S04 | 16 | 0 / 16 | n/a | Untested, expected same failure | | S04 | 16 | 0 / 16 | n/a | Awaiting next quota window |
Net: **7 / 58 (12 %)**. Net: **19 / 58 (33 %)**.
## Picks (S01) ## 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 `CHANGELOG.md` v2 design choice between direct OS REST (recommended) and
library re-numbering. library re-numbering.
## v2 picks (S02E01E12)
| 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 ## Followups
- [ ] visually verify a sample S01 sub plays in sync (one ep per recipe rule §6) - [ ] visually verify sample S01 sub plays in sync (recipe §6)
- [ ] decide v2 path (REST vs renumber) - [ ] visually verify sample S02 29.97-fps pick plays in sync (e.g. S02E03)
- [ ] sub S02S04 (51 eps) once v2 lands - [ ] tomorrow: sub S02E13E16 (4 eps) + start S03 (19 eps total today + tomorrow)
- [ ] day after: finish S03 + S04 (16 eps)