#!/usr/bin/env python3 """Download episodes from Kaizoku's StrawVerse-compatible provider bridge.""" import argparse import json import os import re import subprocess import sys import urllib.request from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent BRIDGE = PROJECT_ROOT / "providers" / "bridge.js" PROVIDERS = ("anikoto", "anineko", "pahe") def clean_component(value, default="Anime"): text = re.sub(r"[\\/:*?\"<>|]+", " ", str(value or "")).strip() text = re.sub(r"\s+", " ", text) return text.strip(". ") or default def parse_show_id(value, default_provider="anikoto"): text = str(value or "").strip() if ":" in text: provider, show_id = text.split(":", 1) provider = provider.strip().lower() if provider in PROVIDERS and show_id.strip(): return provider, show_id.strip() return default_provider, text def bridge(command, provider, *args): cmd = ["node", str(BRIDGE), command, provider, *[str(arg) for arg in args]] proc = subprocess.run(cmd, cwd=PROJECT_ROOT, text=True, capture_output=True, check=False) if proc.returncode != 0: raise RuntimeError((proc.stderr or proc.stdout or f"{command} failed").strip()) try: return json.loads(proc.stdout or "{}") except json.JSONDecodeError as exc: raise RuntimeError(f"Provider bridge returned invalid JSON: {exc}") from exc def episode_values(spec, episodes): values = [] by_number = {str(ep.get("number")).rstrip("0").rstrip("."): ep for ep in episodes} if not str(spec or "").strip(): return episodes for part in re.split(r"[\s,]+", str(spec).strip()): if not part: continue if "-" in part: start, end = part.split("-", 1) try: left = int(float(start)) right = int(float(end)) except ValueError: continue step = 1 if right >= left else -1 for number in range(left, right + step, step): item = by_number.get(str(number)) if item and item not in values: values.append(item) continue item = by_number.get(str(float(part)).rstrip("0").rstrip(".")) if re.match(r"^\d+(?:\.\d+)?$", part) else None if item and item not in values: values.append(item) return values def ffmpeg_headers(headers): pairs = [] for key, value in (headers or {}).items(): if value: pairs.append(f"{key}: {value}") return "\r\n".join(pairs) + ("\r\n" if pairs else "") def download_subtitles(subtitles, output_base): saved = [] english = [ sub for sub in subtitles or [] if sub.get("url") and re.search(r"eng|english", str(sub.get("lang") or ""), re.I) ] or [sub for sub in subtitles or [] if sub.get("url")] for index, sub in enumerate(english[:1], start=1): url = sub["url"] suffix = ".srt" if ".srt" in url.lower() else ".vtt" target = output_base.with_suffix(f".eng{suffix}" if index == 1 else f".eng-{index}{suffix}") request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0", "Referer": sub.get("referer") or ""}) with urllib.request.urlopen(request, timeout=30) as response: target.write_bytes(response.read()) saved.append(target) return saved def download_episode(stream, target): cmd = ["ffmpeg", "-hide_banner", "-loglevel", "info", "-y"] headers = ffmpeg_headers(stream.get("headers") or {}) if headers: cmd.extend(["-headers", headers]) referer = (stream.get("headers") or {}).get("Referer") if referer: cmd.extend(["-referer", referer]) cmd.extend(["-i", stream["url"], "-c", "copy", "-bsf:a", "aac_adtstoasc", str(target)]) return subprocess.call(cmd) def main(): parser = argparse.ArgumentParser() parser.add_argument("--provider", default="anikoto") parser.add_argument("--show-id", required=True) parser.add_argument("--title", required=True) parser.add_argument("--episodes", required=True) parser.add_argument("--mode", choices=("sub", "dub"), default="sub") parser.add_argument("--quality", default="best") parser.add_argument("--output-dir", required=True) args = parser.parse_args() provider, provider_show_id = parse_show_id(args.show_id, args.provider) info = bridge("info", provider, provider_show_id) episodes = bridge("episodes", provider, provider_show_id).get("episodes") or [] wanted = episode_values(args.episodes, episodes) if not wanted: raise SystemExit(f"No matching episodes for spec {args.episodes!r}.") series_title = clean_component(args.title or info.get("title")) output_dir = Path(args.output_dir).expanduser() output_dir.mkdir(parents=True, exist_ok=True) print(f"Provider: {provider}") print(f"Title: {series_title}") print(f"Episodes: {', '.join(str(ep.get('number')) for ep in wanted)}") exit_code = 0 for ep in wanted: number = ep.get("number") ep_id = ep.get("id") padded = f"{int(float(number)):02d}" if str(number).replace(".", "", 1).isdigit() else str(number) basename = f"{series_title} - S01E{padded}" target = output_dir / f"{basename}.mp4" print(f"Resolving episode {number} ({args.mode}, {args.quality})...") stream = bridge("resolve", provider, ep_id, args.mode, args.quality) print(f"Downloading episode {number} from {stream.get('quality') or 'auto'}...") code = download_episode(stream, target) if code != 0: print(f"ffmpeg failed for episode {number} with exit code {code}", file=sys.stderr) exit_code = code break try: for subtitle_path in download_subtitles(stream.get("subtitles") or [], target): print(f"Saved subtitle: {subtitle_path.name}") except Exception as exc: print(f"Subtitle download skipped: {exc}") print(f"Saved: {target.name}") raise SystemExit(exit_code) if __name__ == "__main__": main()