Files
kaizoku/provider_downloader.py
T

162 lines
6.0 KiB
Python
Executable File

#!/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()