852 lines
30 KiB
Python
Executable File
852 lines
30 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 shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from urllib.parse import urljoin, urlparse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
|
BRIDGE = PROJECT_ROOT / "providers" / "bridge.js"
|
|
PROVIDERS = ("anikoto", "anineko", "pahe")
|
|
RETRYABLE_HTTP_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
|
|
|
|
|
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 normalize_title(value):
|
|
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
|
|
|
|
|
|
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 episode_key(value):
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return ""
|
|
try:
|
|
number = float(text)
|
|
except ValueError:
|
|
return text
|
|
if number.is_integer():
|
|
return str(int(number))
|
|
return str(number).rstrip("0").rstrip(".")
|
|
|
|
|
|
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 emit_progress(**payload):
|
|
clean = {key: value for key, value in payload.items() if value is not None}
|
|
print(f"KAIZOKU_PROGRESS {json.dumps(clean, sort_keys=True)}", flush=True)
|
|
|
|
|
|
def provider_order(primary):
|
|
ordered = []
|
|
primary = str(primary or "").strip().lower()
|
|
if primary in PROVIDERS:
|
|
ordered.append(primary)
|
|
for provider in PROVIDERS:
|
|
if provider not in ordered:
|
|
ordered.append(provider)
|
|
return ordered
|
|
|
|
|
|
def find_provider_show(provider, title, original_provider, original_show_id):
|
|
if provider == original_provider:
|
|
return original_show_id
|
|
query = str(title or "").strip()
|
|
if not query:
|
|
return ""
|
|
payload = bridge("search", provider, query, 1)
|
|
results = payload.get("results") or []
|
|
if not results:
|
|
return ""
|
|
target = normalize_title(query)
|
|
scored = []
|
|
for item in results:
|
|
candidate = normalize_title(item.get("title"))
|
|
if not candidate:
|
|
continue
|
|
if candidate == target:
|
|
score = 100
|
|
elif target and (candidate in target or target in candidate):
|
|
score = 75 - abs(len(candidate) - len(target))
|
|
else:
|
|
shared = set(target.split()) & set(candidate.split())
|
|
score = len(shared) * 5
|
|
scored.append((score, item))
|
|
if not scored:
|
|
return ""
|
|
scored.sort(key=lambda row: row[0], reverse=True)
|
|
if scored[0][0] <= 0:
|
|
return ""
|
|
provider_id = str(scored[0][1].get("provider_id") or scored[0][1].get("id") or "").strip()
|
|
if ":" in provider_id:
|
|
provider_id = provider_id.split(":", 1)[1].strip()
|
|
return provider_id
|
|
|
|
|
|
def episode_values(spec, episodes):
|
|
values = []
|
|
by_number = {episode_key(ep.get("number")): 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(episode_key(part)) if re.match(r"^\d+(?:\.\d+)?$", part) else None
|
|
if item and item not in values:
|
|
values.append(item)
|
|
return values
|
|
|
|
|
|
def requested_episode_numbers(spec, episodes):
|
|
text = str(spec or "").strip()
|
|
if not text:
|
|
return [episode.get("number") for episode in episodes or [] if episode.get("number") not in (None, "")]
|
|
numbers = []
|
|
for part in re.split(r"[\s,]+", text):
|
|
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
|
|
numbers.extend(range(left, right + step, step))
|
|
continue
|
|
if re.match(r"^\d+(?:\.\d+)?$", part):
|
|
numbers.append(float(part) if "." in part else int(part))
|
|
return numbers
|
|
|
|
|
|
def wanted_episodes(spec, episodes):
|
|
by_number = {episode_key(ep.get("number")): ep for ep in episodes or []}
|
|
wanted = []
|
|
for number in requested_episode_numbers(spec, episodes):
|
|
key = episode_key(number)
|
|
wanted.append(by_number.get(key) or {"number": number, "id": ""})
|
|
return wanted
|
|
|
|
|
|
def episode_by_number(number, episodes):
|
|
wanted = episode_key(number)
|
|
for episode in episodes or []:
|
|
current = episode_key(episode.get("number"))
|
|
if current == wanted:
|
|
return episode
|
|
return None
|
|
|
|
|
|
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 request_headers(headers=None, referer=None):
|
|
merged = {
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) "
|
|
"Gecko/20100101 Firefox/121.0"
|
|
),
|
|
"Accept": "*/*",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
"Cache-Control": "no-cache",
|
|
"Pragma": "no-cache",
|
|
"Sec-Fetch-Dest": "video",
|
|
"Sec-Fetch-Mode": "no-cors",
|
|
"Sec-Fetch-Site": "cross-site",
|
|
}
|
|
merged.update(headers or {})
|
|
if referer and not (merged.get("Referer") or merged.get("referer")):
|
|
merged["Referer"] = referer
|
|
return merged
|
|
|
|
|
|
def fetch_bytes(url, headers=None, timeout=30):
|
|
request = urllib.request.Request(url, headers=request_headers(headers))
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return response.read()
|
|
|
|
|
|
def int_env(name, default, minimum=0):
|
|
try:
|
|
value = int(str(os.environ.get(name, default)).strip())
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return max(minimum, value)
|
|
|
|
|
|
def float_env(name, default, minimum=0.0):
|
|
try:
|
|
value = float(str(os.environ.get(name, default)).strip())
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return max(minimum, value)
|
|
|
|
|
|
def retry_after_seconds(exc):
|
|
try:
|
|
value = exc.headers.get("Retry-After")
|
|
except Exception:
|
|
return None
|
|
if not value:
|
|
return None
|
|
text = str(value).strip()
|
|
try:
|
|
return max(0.0, float(text))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def fetch_bytes_with_retries(url, headers=None, timeout=30, retries=None, retry_label="request"):
|
|
attempts = int_env("KAIZOKU_SEGMENT_RETRIES", 8, minimum=0) if retries is None else max(0, int(retries))
|
|
base_delay = float_env("KAIZOKU_SEGMENT_RETRY_DELAY", 1.25, minimum=0.0)
|
|
max_delay = float_env("KAIZOKU_SEGMENT_RETRY_MAX_DELAY", 15.0, minimum=0.0)
|
|
for attempt in range(attempts + 1):
|
|
try:
|
|
return fetch_bytes(url, headers=headers, timeout=timeout)
|
|
except urllib.error.HTTPError as exc:
|
|
retryable = exc.code in RETRYABLE_HTTP_STATUS
|
|
if not retryable or attempt >= attempts:
|
|
raise
|
|
header_delay = retry_after_seconds(exc)
|
|
delay = header_delay if header_delay is not None else base_delay * (2 ** attempt)
|
|
if max_delay:
|
|
delay = min(delay, max_delay)
|
|
print(
|
|
f"{retry_label} got HTTP {exc.code}; retrying in {delay:.1f}s "
|
|
f"({attempt + 1}/{attempts}).",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
if delay:
|
|
time.sleep(delay)
|
|
|
|
|
|
def segment_download_delay_seconds():
|
|
return float_env("KAIZOKU_SEGMENT_DOWNLOAD_DELAY", 0.0, minimum=0.0)
|
|
|
|
|
|
def fetch_text(url, headers=None, timeout=30):
|
|
return fetch_bytes(url, headers=headers, timeout=timeout).decode("utf-8", errors="replace")
|
|
|
|
|
|
def best_hls_variant_url(master_url, playlist_text):
|
|
variants = []
|
|
pending_bandwidth = 0
|
|
for raw_line in str(playlist_text or "").splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
if line.startswith("#EXT-X-STREAM-INF"):
|
|
match = re.search(r"BANDWIDTH=(\d+)", line)
|
|
pending_bandwidth = int(match.group(1)) if match else 0
|
|
continue
|
|
if pending_bandwidth and not line.startswith("#"):
|
|
variants.append((pending_bandwidth, urljoin(master_url, line)))
|
|
pending_bandwidth = 0
|
|
if not variants:
|
|
return master_url
|
|
variants.sort(key=lambda item: item[0], reverse=True)
|
|
return variants[0][1]
|
|
|
|
|
|
def resolve_hls_input_url(stream):
|
|
url = stream.get("url")
|
|
if not stream.get("isM3U8") and ".m3u8" not in str(url):
|
|
return url
|
|
data = fetch_text(url, headers=stream.get("headers") or {})
|
|
if "#EXT-X-STREAM-INF" not in data:
|
|
return url
|
|
return best_hls_variant_url(url, data)
|
|
|
|
|
|
def hls_media_playlist(stream, input_url):
|
|
playlist = fetch_text(input_url, headers=stream.get("headers") or {})
|
|
if "#EXT-X-STREAM-INF" in playlist:
|
|
input_url = best_hls_variant_url(input_url, playlist)
|
|
playlist = fetch_text(input_url, headers=stream.get("headers") or {})
|
|
return input_url, playlist
|
|
|
|
|
|
def ffmpeg_base_command(stream, input_url):
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"info",
|
|
"-y",
|
|
"-protocol_whitelist",
|
|
"file,http,https,tcp,tls,crypto",
|
|
"-allowed_extensions",
|
|
"ALL",
|
|
"-allowed_segment_extensions",
|
|
"ALL",
|
|
]
|
|
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", input_url])
|
|
return cmd
|
|
|
|
|
|
def strip_png_header(data):
|
|
png_header = b"\x89PNG\r\n\x1a\n"
|
|
if not data.startswith(png_header):
|
|
return data
|
|
iend_offset = data.find(b"IEND")
|
|
if iend_offset != -1 and iend_offset < 1024:
|
|
return data[iend_offset + 8:]
|
|
return data
|
|
|
|
|
|
def parse_hls_segments(playlist_url, playlist_text):
|
|
segments = []
|
|
key_url = None
|
|
iv = None
|
|
media_sequence = 1
|
|
segment_index = 0
|
|
for raw_line in str(playlist_text or "").splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
if line.startswith("#EXT-X-MEDIA-SEQUENCE:"):
|
|
try:
|
|
media_sequence = int(line.split(":", 1)[1])
|
|
except ValueError:
|
|
media_sequence = 1
|
|
continue
|
|
if line.startswith("#EXT-X-KEY:"):
|
|
attrs = {
|
|
match.group(1): match.group(2) if match.group(2) is not None else match.group(3)
|
|
for match in re.finditer(r'([A-Z0-9_-]+)=(?:"([^"]*)"|([^,]*))', line.split(":", 1)[1])
|
|
}
|
|
if str(attrs.get("METHOD") or "").upper() == "AES-128":
|
|
key_url = urljoin(playlist_url, attrs.get("URI") or "")
|
|
iv = attrs.get("IV")
|
|
else:
|
|
key_url = None
|
|
iv = None
|
|
continue
|
|
if line.startswith("#"):
|
|
continue
|
|
segment = {"url": urljoin(playlist_url, line)}
|
|
if key_url:
|
|
segment["key_url"] = key_url
|
|
segment["iv"] = iv or str(media_sequence + segment_index)
|
|
segments.append(segment)
|
|
segment_index += 1
|
|
return segments
|
|
|
|
|
|
def hls_segments_need_native_download(segments):
|
|
disguised_segment_extensions = {".jpg", ".jpeg", ".png", ".webp", ".ico", ".css", ".js", ".html", ".txt"}
|
|
for segment in segments or []:
|
|
parsed = urlparse(segment.get("url") or "")
|
|
path = parsed.path
|
|
name = path.rsplit("/", 1)[-1]
|
|
suffix = Path(name).suffix.lower()
|
|
if "." not in name:
|
|
return True
|
|
if "/ad-site-i18n/" in path:
|
|
return True
|
|
if parsed.hostname and parsed.hostname.endswith("snapcdn.top"):
|
|
return True
|
|
if suffix in disguised_segment_extensions:
|
|
return True
|
|
return False
|
|
|
|
|
|
def decrypt_aes128_segment(data, key, iv_value):
|
|
openssl = shutil.which("openssl")
|
|
if not openssl:
|
|
raise RuntimeError("Encrypted HLS segment requires openssl, but openssl is not available.")
|
|
iv = bytearray(16)
|
|
text = str(iv_value or "").strip()
|
|
if text.lower().startswith("0x"):
|
|
raw = bytes.fromhex(text[2:])
|
|
iv[:len(raw[:16])] = raw[:16]
|
|
else:
|
|
iv[-4:] = int(text or "0").to_bytes(4, "big")
|
|
proc = subprocess.run(
|
|
[openssl, "enc", "-d", "-aes-128-cbc", "-K", key.hex(), "-iv", bytes(iv).hex()],
|
|
input=data,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError((proc.stderr or b"openssl AES-128 decrypt failed").decode("utf-8", errors="replace").strip())
|
|
return proc.stdout
|
|
|
|
|
|
def download_hls_segments(
|
|
stream,
|
|
input_url,
|
|
target,
|
|
partial,
|
|
episode_number=None,
|
|
episode_index=None,
|
|
episode_total=None,
|
|
playlist_text=None,
|
|
):
|
|
if playlist_text is None:
|
|
input_url, playlist = hls_media_playlist(stream, input_url)
|
|
else:
|
|
playlist = playlist_text
|
|
segments = parse_hls_segments(input_url, playlist)
|
|
if not segments:
|
|
return 1
|
|
ts_file = target.with_suffix(target.suffix + ".ts.part")
|
|
segment_dir = target.parent / f".segments_{target.stem}"
|
|
if segment_dir.exists():
|
|
shutil.rmtree(segment_dir)
|
|
segment_dir.mkdir(parents=True, exist_ok=True)
|
|
key_cache = {}
|
|
try:
|
|
message = f"Downloading {len(segments)} HLS playlist segments directly..."
|
|
print(message, flush=True)
|
|
emit_progress(
|
|
phase="segments",
|
|
message=message,
|
|
episode=episode_number,
|
|
episode_index=episode_index,
|
|
episode_total=episode_total,
|
|
segment=0,
|
|
segment_total=len(segments),
|
|
percent=0,
|
|
)
|
|
last_percent = -1
|
|
segment_delay = segment_download_delay_seconds()
|
|
with ts_file.open("wb") as joined:
|
|
for index, segment in enumerate(segments, start=1):
|
|
if segment_delay and index > 1:
|
|
time.sleep(segment_delay)
|
|
data = fetch_bytes_with_retries(
|
|
segment["url"],
|
|
headers=stream.get("headers") or {},
|
|
timeout=60,
|
|
retry_label=f"Episode {episode_number} segment {index}/{len(segments)}",
|
|
)
|
|
data = strip_png_header(data)
|
|
key_url = segment.get("key_url")
|
|
if key_url:
|
|
if key_url not in key_cache:
|
|
key_cache[key_url] = fetch_bytes_with_retries(
|
|
key_url,
|
|
headers=stream.get("headers") or {},
|
|
timeout=30,
|
|
retry_label=f"Episode {episode_number} segment key",
|
|
)
|
|
data = decrypt_aes128_segment(data, key_cache[key_url], segment.get("iv"))
|
|
if not data:
|
|
raise RuntimeError(f"Segment {index} was empty.")
|
|
joined.write(data)
|
|
percent = int(index * 100 / len(segments))
|
|
if index == 1 or index == len(segments) or percent >= last_percent + 2:
|
|
last_percent = percent
|
|
message = f"Episode {episode_number}: downloaded segment {index}/{len(segments)} ({percent}%)."
|
|
print(message, flush=True)
|
|
emit_progress(
|
|
phase="segments",
|
|
message=message,
|
|
episode=episode_number,
|
|
episode_index=episode_index,
|
|
episode_total=episode_total,
|
|
segment=index,
|
|
segment_total=len(segments),
|
|
percent=percent,
|
|
)
|
|
emit_progress(
|
|
phase="remux",
|
|
message=f"Episode {episode_number}: remuxing downloaded segments.",
|
|
episode=episode_number,
|
|
episode_index=episode_index,
|
|
episode_total=episode_total,
|
|
segment=len(segments),
|
|
segment_total=len(segments),
|
|
percent=99,
|
|
)
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"info",
|
|
"-y",
|
|
"-f",
|
|
"mpegts",
|
|
"-i",
|
|
str(ts_file),
|
|
"-c",
|
|
"copy",
|
|
"-bsf:a",
|
|
"aac_adtstoasc",
|
|
"-movflags",
|
|
"+faststart",
|
|
"-f",
|
|
"mp4",
|
|
str(partial),
|
|
]
|
|
code = run_ffmpeg(cmd, timeout=300)
|
|
if code == 0 and partial.exists() and partial.stat().st_size > 0:
|
|
partial.replace(target)
|
|
return 0
|
|
return code
|
|
finally:
|
|
try:
|
|
ts_file.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
shutil.rmtree(segment_dir, ignore_errors=True)
|
|
|
|
|
|
def run_ffmpeg(cmd, timeout=180):
|
|
try:
|
|
return subprocess.run(cmd, check=False, timeout=timeout).returncode
|
|
except subprocess.TimeoutExpired:
|
|
print(f"ffmpeg timed out after {timeout}s; trying fallback.", file=sys.stderr)
|
|
return 124
|
|
|
|
|
|
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=request_headers(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, episode_number=None, episode_index=None, episode_total=None):
|
|
input_url = resolve_hls_input_url(stream)
|
|
partial = target.with_suffix(target.suffix + ".part")
|
|
for path in (target, partial):
|
|
try:
|
|
path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
if stream.get("isM3U8") or ".m3u8" in str(input_url):
|
|
try:
|
|
media_url, playlist = hls_media_playlist(stream, input_url)
|
|
segments = parse_hls_segments(media_url, playlist)
|
|
except Exception as exc:
|
|
print(f"Native HLS preflight failed: {exc}", file=sys.stderr)
|
|
try:
|
|
code = download_hls_segments(
|
|
stream,
|
|
input_url,
|
|
target,
|
|
partial,
|
|
episode_number=episode_number,
|
|
episode_index=episode_index,
|
|
episode_total=episode_total,
|
|
)
|
|
if code == 0:
|
|
return 0
|
|
return code
|
|
except Exception as segment_exc:
|
|
print(f"Segment downloader failed after HLS preflight error: {segment_exc}", file=sys.stderr)
|
|
return 1
|
|
if hls_segments_need_native_download(segments):
|
|
message = "HLS playlist uses provider segments that need native download; skipping direct ffmpeg."
|
|
print(message, flush=True)
|
|
emit_progress(
|
|
phase="preflight",
|
|
message=message,
|
|
episode=episode_number,
|
|
episode_index=episode_index,
|
|
episode_total=episode_total,
|
|
percent=0,
|
|
)
|
|
try:
|
|
code = download_hls_segments(
|
|
stream,
|
|
media_url,
|
|
target,
|
|
partial,
|
|
episode_number=episode_number,
|
|
episode_index=episode_index,
|
|
episode_total=episode_total,
|
|
playlist_text=playlist,
|
|
)
|
|
if code == 0:
|
|
return 0
|
|
return code
|
|
except Exception as segment_exc:
|
|
print(f"Segment downloader failed: {segment_exc}", file=sys.stderr)
|
|
return 1
|
|
attempts = [
|
|
[
|
|
*ffmpeg_base_command(stream, input_url),
|
|
"-map",
|
|
"0:v:0?",
|
|
"-map",
|
|
"0:a:0?",
|
|
"-c",
|
|
"copy",
|
|
"-bsf:a",
|
|
"aac_adtstoasc",
|
|
"-movflags",
|
|
"+faststart",
|
|
"-f",
|
|
"mp4",
|
|
str(partial),
|
|
],
|
|
[
|
|
*ffmpeg_base_command(stream, input_url),
|
|
"-map",
|
|
"0",
|
|
"-c",
|
|
"copy",
|
|
"-bsf:a",
|
|
"aac_adtstoasc",
|
|
"-movflags",
|
|
"+faststart",
|
|
"-f",
|
|
"mp4",
|
|
str(partial),
|
|
],
|
|
]
|
|
code = 1
|
|
for cmd in attempts:
|
|
try:
|
|
partial.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
code = run_ffmpeg(cmd)
|
|
if code == 0 and partial.exists() and partial.stat().st_size > 0:
|
|
partial.replace(target)
|
|
return 0
|
|
if stream.get("isM3U8") or ".m3u8" in str(input_url):
|
|
try:
|
|
code = download_hls_segments(
|
|
stream,
|
|
input_url,
|
|
target,
|
|
partial,
|
|
episode_number=episode_number,
|
|
episode_index=episode_index,
|
|
episode_total=episode_total,
|
|
)
|
|
if code == 0:
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"Segment downloader failed: {exc}", file=sys.stderr)
|
|
code = 1
|
|
try:
|
|
partial.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
return code
|
|
|
|
|
|
def provider_episode_candidates(primary_provider, primary_show_id, title, wanted_episode):
|
|
number = wanted_episode.get("number")
|
|
for provider in provider_order(primary_provider):
|
|
try:
|
|
provider_show_id = find_provider_show(provider, title, primary_provider, primary_show_id)
|
|
if not provider_show_id:
|
|
print(f"Fallback skipped {provider}: no matching title.")
|
|
continue
|
|
episodes = bridge("episodes", provider, provider_show_id).get("episodes") or []
|
|
episode = episode_by_number(number, episodes)
|
|
if not episode:
|
|
print(f"Fallback skipped {provider}: episode {number} is not listed.")
|
|
continue
|
|
yield provider, provider_show_id, episode
|
|
except Exception as exc:
|
|
print(f"Fallback skipped {provider}: {exc}")
|
|
|
|
|
|
def provider_stream_candidates(provider, episode_id, mode, quality):
|
|
payload = bridge("resolve-all", provider, episode_id, mode, quality)
|
|
sources = payload.get("sources") or []
|
|
if isinstance(sources, list):
|
|
return [source for source in sources if isinstance(source, dict) and source.get("url")]
|
|
return []
|
|
|
|
|
|
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 = wanted_episodes(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 episode_index, ep in enumerate(wanted, start=1):
|
|
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"
|
|
last_error = None
|
|
downloaded = False
|
|
emit_progress(
|
|
phase="episode",
|
|
message=f"Starting episode {number} ({episode_index}/{len(wanted)}).",
|
|
episode=number,
|
|
episode_index=episode_index,
|
|
episode_total=len(wanted),
|
|
percent=0,
|
|
)
|
|
for active_provider, _active_show_id, active_ep in provider_episode_candidates(provider, provider_show_id, args.title or info.get("title"), ep):
|
|
try:
|
|
active_ep_id = active_ep.get("id")
|
|
emit_progress(
|
|
phase="resolve",
|
|
message=f"Resolving episode {number} on {active_provider} ({args.mode}, {args.quality}).",
|
|
episode=number,
|
|
episode_index=episode_index,
|
|
episode_total=len(wanted),
|
|
provider=active_provider,
|
|
percent=0,
|
|
)
|
|
print(f"Resolving episode {number} on {active_provider} ({args.mode}, {args.quality})...", flush=True)
|
|
streams = provider_stream_candidates(active_provider, active_ep_id, args.mode, args.quality)
|
|
if not streams:
|
|
last_error = RuntimeError(f"No playable {args.mode} sources on {active_provider}")
|
|
print(str(last_error), file=sys.stderr)
|
|
continue
|
|
for stream_index, stream in enumerate(streams, start=1):
|
|
source_name = stream.get("server") or stream.get("quality") or f"source {stream_index}"
|
|
emit_progress(
|
|
phase="download",
|
|
message=(
|
|
f"Downloading episode {number} from {active_provider} "
|
|
f"{source_name} ({stream_index}/{len(streams)})."
|
|
),
|
|
episode=number,
|
|
episode_index=episode_index,
|
|
episode_total=len(wanted),
|
|
provider=active_provider,
|
|
quality=stream.get("quality") or "auto",
|
|
percent=0,
|
|
)
|
|
print(
|
|
f"Downloading episode {number} from {active_provider} "
|
|
f"{source_name} ({stream_index}/{len(streams)})...",
|
|
flush=True,
|
|
)
|
|
code = download_episode(
|
|
stream,
|
|
target,
|
|
episode_number=number,
|
|
episode_index=episode_index,
|
|
episode_total=len(wanted),
|
|
)
|
|
if code != 0:
|
|
last_error = RuntimeError(
|
|
f"Source {source_name} on {active_provider} failed with exit code {code}"
|
|
)
|
|
print(str(last_error), file=sys.stderr)
|
|
continue
|
|
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}")
|
|
emit_progress(
|
|
phase="done",
|
|
message=f"Episode {number} saved.",
|
|
episode=number,
|
|
episode_index=episode_index,
|
|
episode_total=len(wanted),
|
|
percent=100,
|
|
)
|
|
print(f"Saved: {target.name}", flush=True)
|
|
downloaded = True
|
|
break
|
|
if downloaded:
|
|
break
|
|
except Exception as exc:
|
|
last_error = exc
|
|
print(f"Provider {active_provider} failed for episode {number}: {exc}", file=sys.stderr)
|
|
if not downloaded:
|
|
print(f"Episode {number} failed on all providers: {last_error}", file=sys.stderr)
|
|
exit_code = 1
|
|
break
|
|
raise SystemExit(exit_code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|