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:
parent
fedf3388b8
commit
23520df2df
5 changed files with 384 additions and 72 deletions
|
|
@ -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 + S02E01–E12); S02E13–S04 awaiting next quota window | 2026-05-09 |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
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
|
||||||
|
|
|
||||||
285
processes/subtitles/lib/sub-rest-fetch.py
Executable file
285
processes/subtitles/lib/sub-rest-fetch.py
Executable 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())
|
||||||
|
|
@ -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 (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
|
## 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 S02–S04 (51 eps) once v2 lands
|
- [ ] tomorrow: sub S02E13–E16 (4 eps) + start S03 (19 eps total today + tomorrow)
|
||||||
|
- [ ] day after: finish S03 + S04 (16 eps)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue