#!/usr/bin/env python3 """Ingest one browser-confirmed, authorized embedded HLS video. The browser-facing agent writes a private resource map after it has verified that playback is authorized. This script never receives signed URLs on the command line, never prints them, and propagates a playlist's authorization query only to same-origin HLS resources. It rejects master playlists and DRM methods so the ordinary yt-dlp route remains the preferred path. """ from __future__ import annotations import argparse import json import os import re import tempfile import urllib.parse import urllib.request from pathlib import Path from typing import Any, Sequence import fetch_video as fetch RESOURCE_MAP_SCHEMA = 1 ALLOWED_SUBTITLE_KINDS = {"manual", "automatic"} _URI_ATTRIBUTE = re.compile(r'URI="([^"]+)"') _EXTENSION = re.compile(r"^[A-Za-z0-9]{1,8}$") class IngestError(fetch.FetchError): """An embedded-resource ingest failed safely.""" def _read_private_resource_map(path: Path) -> dict[str, Any]: path = path.expanduser().resolve() if not path.is_file(): raise IngestError(f"Resource map does not exist or is not a file: {path}") if path.stat().st_mode & 0o077: raise IngestError("Resource map must be private (chmod 600) because it may contain signed URLs") try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise IngestError("Could not read the private resource map") from exc if not isinstance(value, dict) or value.get("schema_version") != RESOURCE_MAP_SCHEMA: raise IngestError(f"Resource map must be a schema_version={RESOURCE_MAP_SCHEMA} object") return value def _prepare_output_dir( output_dir: Path, resource_map_path: Path, *, resume: bool, cleanup_resource_map: bool, ) -> Path: if output_dir.exists() and not output_dir.is_dir(): raise IngestError(f"Output path is not a directory: {output_dir}") output_dir.mkdir(parents=True, exist_ok=True) if resume: return output_dir entries = list(output_dir.iterdir()) allowed_map = ( cleanup_resource_map and resource_map_path.parent == output_dir and resource_map_path in entries ) unexpected = [path for path in entries if not (allowed_map and path == resource_map_path)] if unexpected or (entries and not allowed_map): raise IngestError( f"Output directory is not empty: {output_dir}. Choose a new directory or pass --resume explicitly." ) return output_dir def _required_text(value: Any, field: str) -> str: if not isinstance(value, str) or not value.strip(): raise IngestError(f"Resource map field {field!r} must be a non-empty string") return value.strip() def _optional_number(value: Any, field: str) -> float | int | None: if value is None: return None if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0: raise IngestError(f"Resource map field {field!r} must be a positive number") return value def _validated_url(value: Any, field: str) -> str: try: return fetch.validate_url(_required_text(value, field)) except fetch.FetchError as exc: raise IngestError(f"Invalid resource map URL field {field!r}") from exc def _same_origin(left: str, right: str) -> bool: a = urllib.parse.urlsplit(left) b = urllib.parse.urlsplit(right) return (a.scheme.lower(), a.hostname, a.port) == (b.scheme.lower(), b.hostname, b.port) def _authorized_resource_url(resource_url: str, playlist_url: str) -> str: """Add missing root-playlist query fields to same-origin resources only.""" absolute = urllib.parse.urljoin(playlist_url, resource_url) if not _same_origin(absolute, playlist_url): return absolute root = urllib.parse.urlsplit(playlist_url) target = urllib.parse.urlsplit(absolute) root_query = urllib.parse.parse_qsl(root.query, keep_blank_values=True) target_query = urllib.parse.parse_qsl(target.query, keep_blank_values=True) existing = {key for key, _ in target_query} merged = target_query + [(key, value) for key, value in root_query if key not in existing] return urllib.parse.urlunsplit( (target.scheme, target.netloc, target.path, urllib.parse.urlencode(merged), "") ) def _validate_hls_protection(playlist: str) -> None: for line in playlist.splitlines(): if not line.startswith("#EXT-X-KEY:"): continue attributes = line.split(":", 1)[1] method = re.search(r"(?:^|,)METHOD=([^,]+)", attributes) method_value = method.group(1).strip().upper() if method else "" keyformat = re.search(r'(?:^|,)KEYFORMAT="?([^,"]+)', attributes) keyformat_value = keyformat.group(1).strip().lower() if keyformat else "identity" if method_value not in {"NONE", "AES-128"} or keyformat_value != "identity": raise IngestError( "Embedded HLS uses an unsupported protection method; MaterialSub will not bypass DRM" ) def rewrite_media_playlist(playlist: str, playlist_url: str) -> str: """Return a signed, absolute media playlist without logging its secrets.""" if not playlist.lstrip().startswith("#EXTM3U"): raise IngestError("The selected resource is not an HLS playlist") if "#EXT-X-STREAM-INF" in playlist or "#EXT-X-MEDIA:" in playlist: raise IngestError( "The resource is an HLS master playlist; use the browser-observed media playlist selected during playback" ) _validate_hls_protection(playlist) rewritten: list[str] = [] for line in playlist.splitlines(): if line.startswith("#"): line = _URI_ATTRIBUTE.sub( lambda match: f'URI="{_authorized_resource_url(match.group(1), playlist_url)}"', line, ) elif line.strip(): line = _authorized_resource_url(line.strip(), playlist_url) rewritten.append(line) return "\n".join(rewritten) + "\n" def _request_bytes(url: str, *, playlist_url: str | None = None) -> bytes: resolved = _authorized_resource_url(url, playlist_url) if playlist_url else url request = urllib.request.Request(resolved, headers={"User-Agent": "Mozilla/5.0 MaterialSub/1"}) try: with urllib.request.urlopen(request, timeout=45) as response: return response.read() except Exception as exc: raise IngestError(f"Failed to download an authorized resource from {fetch.display_url(url)}") from exc def _atomic_download(url: str, destination: Path, *, playlist_url: str | None = None) -> Path: if destination.exists(): raise IngestError(f"Refusing to replace existing artifact without --resume: {destination}") payload = _request_bytes(url, playlist_url=playlist_url) if not payload: raise IngestError(f"Downloaded resource is empty: {fetch.display_url(url)}") fd, temporary_name = tempfile.mkstemp( prefix=f".{destination.stem}.", suffix=destination.suffix, dir=destination.parent ) try: with os.fdopen(fd, "wb") as handle: handle.write(payload) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_name, destination) finally: Path(temporary_name).unlink(missing_ok=True) return destination def _download_cover( url: str | None, destination: Path, *, playlist_url: str, ffmpeg: str, resume: bool, ) -> Path | None: if not url: return None if destination.exists() and resume: return destination with tempfile.TemporaryDirectory(prefix="materialsub-cover-") as directory: raw = Path(directory) / "cover.input" raw.write_bytes(_request_bytes(url, playlist_url=playlist_url)) if not raw.stat().st_size: raise IngestError("Downloaded cover is empty") temporary = Path(directory) / "cover.jpg" result = fetch._run( [ ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", str(raw), "-frames:v", "1", str(temporary), ], "converting the embedded cover to JPEG", secrets=(url, str(raw), str(temporary)), check=False, ) if result.returncode or not temporary.is_file() or not temporary.stat().st_size: raise IngestError("Failed to convert the embedded cover to JPEG") os.replace(temporary, destination) return destination def _subtitle_choice(resource_map: dict[str, Any], target_language: str) -> tuple[fetch.SubtitleChoice | None, dict[str, Any] | None]: subtitle = resource_map.get("subtitle") if subtitle is None: return None, None if not isinstance(subtitle, dict): raise IngestError("Resource map subtitle must be an object or null") url = _validated_url(subtitle.get("url"), "subtitle.url") language = _required_text(subtitle.get("language"), "subtitle.language") kind = _required_text(subtitle.get("kind"), "subtitle.kind").lower() if kind not in ALLOWED_SUBTITLE_KINDS: raise IngestError("subtitle.kind must be 'manual' or 'automatic'") original_format = str(subtitle.get("format") or Path(urllib.parse.urlsplit(url).path).suffix.lstrip(".") or "vtt").lower() if not _EXTENSION.fullmatch(original_format): raise IngestError("subtitle.format must be a short filename extension") if fetch._excluded_language(language, fetch._target_language_bases(target_language)): raise IngestError("Selected subtitle is not a foreign-language dialogue track for the requested target language") return fetch.SubtitleChoice(language, kind, original_format, (original_format,)), subtitle def _download_hls( playlist_url: str, output_dir: Path, base: str, *, yt_dlp: str, concurrent_fragments: int, ) -> tuple[Path, Any]: source = _request_bytes(playlist_url).decode("utf-8-sig") rewritten = rewrite_media_playlist(source, playlist_url) with tempfile.TemporaryDirectory(prefix="materialsub-authorized-hls-") as directory: playlist_path = Path(directory) / "media.m3u8" playlist_path.write_text(rewritten, encoding="utf-8") os.chmod(playlist_path, 0o600) result = fetch._run( [ yt_dlp, "--ignore-config", "--no-playlist", "--no-write-playlist-metafiles", "--no-progress", "--enable-file-urls", "--concurrent-fragments", str(concurrent_fragments), "-P", str(output_dir), "-f", fetch.FORMAT_SELECTOR, "--merge-output-format", "mkv", "--remux-video", "mkv", "--no-overwrites", "--no-post-overwrites", "-o", f"{base}.intermediate.%(ext)s", playlist_path.as_uri(), ], "downloading the authorized embedded HLS media playlist", secrets=(playlist_url, urllib.parse.urlsplit(playlist_url).query, rewritten), ) intermediate = fetch._artifact(output_dir, f"{base}.intermediate.") if intermediate is None: raise IngestError("yt-dlp completed but no embedded HLS intermediate was written") return intermediate, result def _build_manifest( resource_map: dict[str, Any], output_dir: Path, *, target_language: str, deliverable: str, choice: fetch.SubtitleChoice | None, ) -> dict[str, Any]: title = _required_text(resource_map.get("title"), "title") video_id = _required_text(resource_map.get("id"), "id") page_url = _validated_url(resource_map.get("page_url"), "page_url") info = { "title": title, "id": video_id, "webpage_url": page_url, "extractor_key": "AuthorizedEmbeddedHLS", "duration": _optional_number(resource_map.get("duration_seconds"), "duration_seconds"), "width": _optional_number(resource_map.get("width"), "width"), "height": _optional_number(resource_map.get("height"), "height"), "language": choice.language if choice else resource_map.get("declared_language"), } manifest = fetch._manifest_base( info=info, url=page_url, output_dir=output_dir, browser_cookies="browser-session", allow_remote_ejs=False, choice=choice, deliverable=deliverable, target_language=target_language, ) manifest["authentication"]["mode"] = "browser-confirmed-resource-map" manifest["execution"] = { "resume": False, "embedded_hls": True, "authorization_query_logged": False, "temporary_playlist_cleaned": True, } return manifest def execute(args: argparse.Namespace) -> int: resource_map_path = args.resource_map.expanduser().resolve() resource_map = _read_private_resource_map(resource_map_path) target_language = fetch._validated_target_language(args.target_lang) playlist_url = _validated_url(resource_map.get("playlist_url"), "playlist_url") choice, subtitle_config = _subtitle_choice(resource_map, target_language) if args.deliver in fetch.SUBTITLE_ONLY_DELIVERABLES and choice is None: raise IngestError("A subtitle-only delivery requires one selected source subtitle") yt_dlp = fetch._require_executable("yt-dlp", "Install the current official yt-dlp release.") ffmpeg = fetch._require_executable("ffmpeg", "Install FFmpeg.") ffprobe = fetch._require_executable("ffprobe", "Install FFmpeg with ffprobe.") output_dir = _prepare_output_dir( args.output_dir.expanduser().resolve(), resource_map_path, resume=args.resume, cleanup_resource_map=args.cleanup_resource_map, ) manifest = _build_manifest( resource_map, output_dir, target_language=target_language, deliverable=args.deliver, choice=choice, ) manifest["execution"]["resume"] = bool(args.resume) title = manifest["source"]["title"] video_id = manifest["source"]["id"] base = fetch.safe_stem(title, video_id) names = manifest["delivery_names"] subtitles_only = args.deliver in fetch.SUBTITLE_ONLY_DELIVERABLES warnings: list[str] = [] completed: list[Any] = [] intermediate: Path | None = None media_probe: dict[str, Any] = {} master: Path | None = None fallback: Path | None = None fallback_error: str | None = None if not subtitles_only: print("Downloading authorized embedded HLS at maximum observed quality…", file=os.sys.stderr) intermediate, completed_result = _download_hls( playlist_url, output_dir, base, yt_dlp=yt_dlp, concurrent_fragments=args.concurrent_fragments, ) completed.append(completed_result) media_probe = fetch._ffprobe(intermediate, ffprobe) if not any( isinstance(stream, dict) and stream.get("codec_type") == "video" for stream in media_probe.get("streams", []) ): raise IngestError("Embedded HLS intermediate has no video stream") master, remux_error = fetch._try_lossless_mp4( intermediate, output_dir / f"{base}.master.mp4", ffmpeg, replace_existing=args.resume, ) if remux_error: warnings.append(f"Lossless MP4 remux unavailable: {remux_error}") if master is None and args.mp4_fallback: fallback, fallback_error = fetch._create_fallback_mp4( intermediate, output_dir / f"{base}.fallback.mp4", ffmpeg, replace_existing=args.resume, ) if fallback_error: raise IngestError(f"Requested MP4 fallback failed: {fallback_error}") cover = _download_cover( resource_map.get("cover_url"), output_dir / names["cover"], playlist_url=playlist_url, ffmpeg=ffmpeg, resume=args.resume, ) if not subtitles_only and cover is None: warnings.append("The embedded player resource map did not declare a cover") original_subtitle: Path | None = None source_srt: Path | None = None conversion_method: str | None = None parent_hash: str | None = None if choice and subtitle_config: language_label = fetch._subtitle_language_label(choice.language) original_subtitle = output_dir / ( f"{base}.source-original.{language_label}.{choice.original_format}" ) if not (args.resume and original_subtitle.is_file()): _atomic_download( _validated_url(subtitle_config.get("url"), "subtitle.url"), original_subtitle, playlist_url=playlist_url, ) source_srt, conversion_method, parent_hash = fetch._derive_source_srt( original_subtitle, output_dir / f"{base}.source-srt.{language_label}.srt", ffmpeg=ffmpeg, replace_existing=args.resume, ) original_record = fetch._file_record(original_subtitle, output_dir, checksum=True) source_srt_record = fetch._file_record(source_srt, output_dir, checksum=True) if original_record and source_srt_record: if original_record.get("sha256") != parent_hash: raise IngestError("Original subtitle changed during SRT derivation") original_record["content_role"] = "immutable-parent" source_srt_record["conversion_method"] = conversion_method source_srt_record["derived_from"] = { "path": original_record["path"], "sha256": parent_hash, } manifest["status"] = "downloaded" manifest["artifacts"] = { "intermediate": fetch._file_record(intermediate, output_dir), "media_streams": media_probe.get("streams", []), "lossless_mp4_master": fetch._file_record(master, output_dir), "lossy_mp4_fallback": { "requested": bool(args.mp4_fallback), "created": fetch._file_record(fallback, output_dir), "reason_not_created": ( "lossless_master_available" if args.mp4_fallback and master is not None else (fallback_error if args.mp4_fallback and fallback is None else None) ), "video_encoding": "libx264 preset=slow crf=18" if fallback else None, "audio_encoding": "aac 256k" if fallback else None, }, "cover": fetch._file_record(cover, output_dir, checksum=True), "subtitle": { "language": choice.language, "kind": choice.kind, "label": subtitle_config.get("label"), "original": original_record, "source_srt": source_srt_record, "original_is_never_modified_by_this_script": True, } if choice and subtitle_config else None, } manifest["warnings"].extend(warnings) manifest["warnings"].extend( warning for warning in fetch._warning_lines( completed, (playlist_url, urllib.parse.urlsplit(playlist_url).query) ) if warning not in manifest["warnings"] ) destination = fetch._write_manifest(output_dir, manifest) exit_code = fetch._advance_bilingual_stage(destination) if args.cleanup_resource_map: resource_map_path.unlink() return exit_code def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Ingest one browser-confirmed authorized embedded HLS media playlist." ) parser.add_argument("--resource-map", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--target-lang", default=fetch.DEFAULT_TARGET_LANGUAGE) parser.add_argument("--deliver", choices=fetch.DELIVERABLES, default="full") parser.add_argument("--concurrent-fragments", type=int, default=8) parser.add_argument("--mp4-fallback", action="store_true") parser.add_argument("--resume", action="store_true") parser.add_argument( "--cleanup-resource-map", action="store_true", help="delete the exact private resource-map file after a successful manifest write", ) return parser def main(argv: Sequence[str] | None = None) -> int: parser = _parser() args = parser.parse_args(argv) if not 1 <= args.concurrent_fragments <= 32: parser.error("--concurrent-fragments must be between 1 and 32") try: return execute(args) except (IngestError, fetch.FetchError) as exc: print(f"error: {fetch.sanitize_diagnostic(str(exc))}", file=os.sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())