Files
claude-code-skills-reference/youtube-downloader/scripts/download_video.py
daymade 35872b9e77 Release v1.24.0: Add claude-skills-troubleshooting
- Add claude-skills-troubleshooting v1.0.0
  - Plugin installation and enablement debugging
  - Diagnostic scripts for installed vs enabled mismatch
  - Known GitHub issues tracking (#17832, #19696, #17089, #13543, #16260)
  - Skills vs Commands architecture documentation
  - Batch enable script for missing plugins

- Add i18n-expert v1.0.0
  - Complete i18n/l10n setup for React/Next.js/Vue
  - Key architecture and locale file organization
  - Translation generation strategy (AI, professional, manual)
  - Key parity validation between en-US and zh-CN
  - Bundled i18n_audit.py script

- Update marketplace to v1.24.0 (32 skills)
- Update README.md badges (skills count, version)
- Update README.zh-CN.md badges (skills count, version)
- Update CLAUDE.md skills count and Available Skills list
- Update youtube-downloader with PO token enhancements

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 22:05:14 +08:00

1089 lines
38 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
YouTube video downloader using yt-dlp with robust error handling.
This script handles common issues like nsig extraction failures and network problems,
especially useful for users behind proxies or in regions with YouTube access issues.
Requirements:
- yt-dlp: Install via `brew install yt-dlp` (macOS) or `pip install yt-dlp` (cross-platform)
- For high-quality downloads (1080p+): Install PO token provider
See ../references/po-token-setup.md for setup instructions
Usage:
scripts/download_video.py "https://youtu.be/VIDEO_ID"
scripts/download_video.py "https://youtu.be/VIDEO_ID" --audio-only
scripts/download_video.py "https://youtu.be/VIDEO_ID" --quality 1080p
scripts/download_video.py "https://youtu.be/VIDEO_ID" -o ~/Downloads
Note:
This script auto-starts a PO Token provider for high-quality downloads.
If PO tokens are disabled, it can fall back to the Android client (360p only).
"""
import argparse
import json
import subprocess
import sys
import shutil
import time
import os
from pathlib import Path
from typing import Iterable, Optional
from urllib.parse import quote, urlparse, urlunparse
from urllib.request import urlopen
from urllib.error import URLError
PYPI_MIRROR = "https://pypi.tuna.tsinghua.edu.cn/simple"
QUALITY_PRESETS = {
"best": "bestvideo+bestaudio/best",
"1080p": "bestvideo[height<=1080]+bestaudio/best",
"720p": "bestvideo[height<=720]+bestaudio/best",
"480p": "bestvideo[height<=480]+bestaudio/best",
"360p": "bestvideo[height<=360]+bestaudio/best",
"worst": "worstvideo+worstaudio/worst",
}
def build_output_template(output_dir: str, template: Optional[str]) -> str:
if template:
template_path = Path(template)
if template_path.is_absolute():
return template
return str(Path(output_dir).expanduser().resolve() / template)
return str(Path(output_dir).expanduser().resolve() / "%(title)s.%(ext)s")
def list_files(root: Path) -> set:
return {path for path in root.rglob("*") if path.is_file()}
def human_size(num_bytes: int) -> str:
if num_bytes < 1024:
return f"{num_bytes} B"
for unit in ["KB", "MB", "GB", "TB"]:
num_bytes /= 1024.0
if num_bytes < 1024:
return f"{num_bytes:.1f} {unit}"
return f"{num_bytes:.1f} PB"
def pick_primary_file(files: Iterable[Path], audio_only: bool) -> Optional[Path]:
video_exts = {".mp4", ".webm", ".mkv", ".mov", ".m4v"}
audio_exts = {".mp3", ".m4a", ".opus", ".aac", ".flac", ".wav"}
candidates = []
for path in files:
if path.suffix.lower() in {".part", ".ytdl", ".tmp"}:
continue
if audio_only:
if path.suffix.lower() in audio_exts:
candidates.append(path)
else:
if path.suffix.lower() in video_exts:
candidates.append(path)
if not candidates:
candidates = [path for path in files if path.suffix.lower() not in {".part", ".ytdl", ".tmp"}]
if not candidates:
return None
return max(candidates, key=lambda p: p.stat().st_size)
def get_video_resolution(path: Path) -> Optional[str]:
check = subprocess.run(["which", "ffprobe"], capture_output=True, text=True)
if check.returncode != 0:
return None
cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
str(path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return None
value = result.stdout.strip()
return value or None
def filter_cookie_lines(text: str) -> str:
if not text:
return ""
filtered = []
for line in text.splitlines():
lowered = line.lower()
if "extracting cookies" in lowered:
continue
if "extracted" in lowered and "cookies" in lowered:
continue
filtered.append(line)
return "\n".join(filtered)
def run_yt_dlp(cmd: list, hide_cookie_logs: bool = False) -> subprocess.CompletedProcess:
if not hide_cookie_logs:
return subprocess.run(cmd)
result = subprocess.run(cmd, capture_output=True, text=True)
stdout = filter_cookie_lines(result.stdout)
stderr = filter_cookie_lines(result.stderr)
if stdout:
print(stdout)
if stderr:
print(stderr, file=sys.stderr)
return result
def has_403_error(result: subprocess.CompletedProcess) -> bool:
text = ""
if hasattr(result, "stdout") and result.stdout:
text += result.stdout
if hasattr(result, "stderr") and result.stderr:
text += result.stderr
text = text.lower()
return "http error 403" in text or "403: forbidden" in text or "fragment 1 not found" in text
def has_pot_error(result: subprocess.CompletedProcess) -> bool:
text = ""
if hasattr(result, "stdout") and result.stdout:
text += result.stdout
if hasattr(result, "stderr") and result.stderr:
text += result.stderr
text = text.lower()
return "pot" in text and "error" in text
def has_wpc_error(result: subprocess.CompletedProcess) -> bool:
text = ""
if hasattr(result, "stdout") and result.stdout:
text += result.stdout
if hasattr(result, "stderr") and result.stderr:
text += result.stderr
text = text.lower()
return "pot:wpc" in text or "webpoclient" in text
def with_player_client(cmd: list, client: str) -> list:
rebuilt = []
skip_next = False
for token in cmd:
if skip_next:
skip_next = False
continue
if token == "--extractor-args":
skip_next = True
continue
rebuilt.append(token)
rebuilt.extend(["--extractor-args", f"youtube:player_client={client}"])
return rebuilt
def get_proxy_settings(proxy_arg: Optional[str]) -> tuple[Optional[str], Optional[str]]:
if proxy_arg:
proxy = proxy_arg
else:
proxy = (
os.environ.get("ALL_PROXY")
or os.environ.get("all_proxy")
or os.environ.get("HTTPS_PROXY")
or os.environ.get("https_proxy")
or os.environ.get("HTTP_PROXY")
or os.environ.get("http_proxy")
)
no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy")
return proxy, no_proxy
def normalize_proxy_for_docker(proxy_url: str) -> str:
parsed = urlparse(proxy_url)
if parsed.hostname in {"127.0.0.1", "localhost"}:
host = "host.docker.internal"
netloc = ""
if parsed.username or parsed.password:
userinfo = parsed.username or ""
if parsed.password:
userinfo += f":{parsed.password}"
netloc = f"{userinfo}@"
if parsed.port:
netloc += f"{host}:{parsed.port}"
else:
netloc += host
parsed = parsed._replace(netloc=netloc)
return urlunparse(parsed)
return proxy_url
def is_localhost_proxy(proxy_url: str) -> bool:
parsed = urlparse(proxy_url)
return parsed.hostname in {"127.0.0.1", "localhost"}
def find_chrome_path() -> Optional[str]:
candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
for candidate in candidates:
if Path(candidate).exists():
return candidate
for name in ["google-chrome", "chromium", "chromium-browser", "chrome"]:
path = shutil.which(name)
if path:
return path
return None
def with_wpc_browser(cmd: list, browser_path: Optional[str]) -> list:
if not browser_path:
return cmd
return cmd + ["--extractor-args", f"youtubepot-wpc:browser_path={browser_path}"]
def provider_ping(url: str = "http://127.0.0.1:4416/ping") -> bool:
try:
with urlopen(url, timeout=3) as response:
return response.status == 200
except (URLError, ConnectionResetError, TimeoutError):
return False
def docker_available() -> bool:
result = subprocess.run(["docker", "--version"], capture_output=True, text=True)
return result.returncode == 0
def docker_daemon_ready() -> bool:
result = subprocess.run(["docker", "info"], capture_output=True, text=True)
return result.returncode == 0
def wait_for_provider(timeout: int = 10) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if provider_ping():
return True
time.sleep(1)
return False
def container_exists(name: str) -> bool:
result = subprocess.run(
["docker", "ps", "-a", "--filter", f"name={name}", "--format", "{{.Names}}"],
capture_output=True,
text=True,
)
return result.returncode == 0 and name in result.stdout.split()
def parse_yt_dlp_version() -> Optional[str]:
result = subprocess.run(["yt-dlp", "--version"], capture_output=True, text=True)
if result.returncode != 0:
return None
return result.stdout.strip()
def version_at_least(version: str, minimum: str) -> bool:
def parse(value: str) -> list:
return [int(part) for part in value.split(".") if part.isdigit()]
current = parse(version)
required = parse(minimum)
if not current or not required:
return False
while len(current) < len(required):
current.append(0)
while len(required) < len(current):
required.append(0)
return current >= required
def yt_dlp_python() -> Optional[str]:
yt_dlp_path = shutil.which("yt-dlp")
if not yt_dlp_path:
return None
try:
with open(yt_dlp_path, "r", encoding="utf-8") as handle:
first = handle.readline().strip()
except OSError:
return None
if not first.startswith("#!"):
return None
shebang = first[2:].strip()
if shebang.endswith("env python3") or shebang.endswith("env python"):
return "python3"
return shebang
def ensure_pot_plugin_installed(proxy_url: Optional[str]) -> bool:
version = parse_yt_dlp_version()
if not version or not version_at_least(version, "2025.05.22"):
print("⚠️ yt-dlp needs to be updated before enabling PO Token provider.")
return False
python_bin = yt_dlp_python()
if not python_bin:
print("⚠️ Unable to locate yt-dlp's Python interpreter for plugin install.")
return False
check = subprocess.run(
[python_bin, "-m", "pip", "show", "bgutil-ytdlp-pot-provider"],
capture_output=True,
text=True,
)
if check.returncode == 0:
return True
print("⚠️ Installing PO Token provider plugin (one-time setup)...")
install_cmd = [python_bin, "-m", "pip", "install", "bgutil-ytdlp-pot-provider", "-i", PYPI_MIRROR]
if proxy_url:
install_cmd.extend(["--proxy", proxy_url])
install = subprocess.run(install_cmd, capture_output=True, text=True)
return install.returncode == 0
def ensure_wpc_provider(proxy_url: Optional[str]) -> bool:
version = parse_yt_dlp_version()
if not version or not version_at_least(version, "2025.09.26"):
print("⚠️ yt-dlp needs to be updated before enabling the WPC PO Token provider.")
return False
python_bin = yt_dlp_python()
if not python_bin:
print("⚠️ Unable to locate yt-dlp's Python interpreter for WPC provider install.")
return False
check = subprocess.run(
[python_bin, "-m", "pip", "show", "yt-dlp-getpot-wpc"],
capture_output=True,
text=True,
)
if check.returncode == 0:
return True
print("⚠️ Installing WPC PO Token provider (one-time setup)...")
install_cmd = [python_bin, "-m", "pip", "install", "-U", "yt-dlp-getpot-wpc", "-i", PYPI_MIRROR]
if proxy_url:
install_cmd.extend(["--proxy", proxy_url])
install = subprocess.run(install_cmd, capture_output=True, text=True)
return install.returncode == 0
def ensure_po_token_provider(proxy_url: Optional[str], no_proxy: Optional[str]) -> Optional[str]:
if not ensure_pot_plugin_installed(proxy_url):
return "wpc" if ensure_wpc_provider(proxy_url) else None
if provider_ping():
return "bgutil"
if not docker_available():
print("⚠️ Docker is not available. Switching to browser-based PO Token provider...")
return "wpc" if ensure_wpc_provider(proxy_url) else None
if not docker_daemon_ready():
print("⚠️ Docker daemon is not running. Switching to browser-based PO Token provider...")
return "wpc" if ensure_wpc_provider(proxy_url) else None
name = "bgutil-pot-provider"
if container_exists(name):
start = subprocess.run(["docker", "start", name], capture_output=True, text=True)
if start.returncode != 0:
print("⚠️ Docker container failed to start. Switching to browser-based PO Token provider...")
return "wpc" if ensure_wpc_provider(proxy_url) else None
else:
env_args = []
use_host_network = False
docker_proxy = None
if proxy_url:
use_host_network = is_localhost_proxy(proxy_url)
docker_proxy = proxy_url if use_host_network else normalize_proxy_for_docker(proxy_url)
env_args.extend(
[
"-e",
f"HTTP_PROXY={docker_proxy}",
"-e",
f"HTTPS_PROXY={docker_proxy}",
"-e",
f"ALL_PROXY={docker_proxy}",
"-e",
f"http_proxy={docker_proxy}",
"-e",
f"https_proxy={docker_proxy}",
"-e",
f"all_proxy={docker_proxy}",
]
)
if no_proxy:
env_args.extend(
[
"-e",
f"NO_PROXY={no_proxy}",
"-e",
f"no_proxy={no_proxy}",
]
)
run_cmd = ["docker", "run", "-d", "--name", name]
if use_host_network:
run_cmd.extend(["--network", "host"])
run_cmd.extend(
[
"-p",
"4416:4416",
*env_args,
"--init",
"brainicism/bgutil-ytdlp-pot-provider",
]
)
run = subprocess.run(run_cmd, capture_output=True, text=True)
if run.returncode != 0:
if use_host_network and proxy_url:
# Retry without host network using host.docker.internal proxy
docker_proxy = normalize_proxy_for_docker(proxy_url)
env_args = [
"-e",
f"HTTP_PROXY={docker_proxy}",
"-e",
f"HTTPS_PROXY={docker_proxy}",
"-e",
f"ALL_PROXY={docker_proxy}",
"-e",
f"http_proxy={docker_proxy}",
"-e",
f"https_proxy={docker_proxy}",
"-e",
f"all_proxy={docker_proxy}",
]
if no_proxy:
env_args.extend(
[
"-e",
f"NO_PROXY={no_proxy}",
"-e",
f"no_proxy={no_proxy}",
]
)
retry = subprocess.run(
[
"docker",
"run",
"-d",
"--name",
name,
"-p",
"4416:4416",
*env_args,
"--init",
"brainicism/bgutil-ytdlp-pot-provider",
],
capture_output=True,
text=True,
)
if retry.returncode == 0:
run = retry
else:
print("⚠️ Docker provider failed to start. Switching to browser-based PO Token provider...")
return "wpc" if ensure_wpc_provider(proxy_url) else None
else:
print("⚠️ Docker provider failed to start. Switching to browser-based PO Token provider...")
return "wpc" if ensure_wpc_provider(proxy_url) else None
if wait_for_provider():
print("✓ PO Token provider is running.")
return "bgutil"
# If container started but not responding, recreate with proxy settings
if restart_po_token_provider(proxy_url, no_proxy) and provider_ping():
print("✓ PO Token provider is running.")
return "bgutil"
print("⚠️ Docker-based provider failed. Switching to browser-based PO Token provider...")
return "wpc" if ensure_wpc_provider(proxy_url) else None
def restart_po_token_provider(proxy_url: Optional[str], no_proxy: Optional[str]) -> bool:
name = "bgutil-pot-provider"
if not docker_available() or not docker_daemon_ready():
return False
if container_exists(name):
subprocess.run(["docker", "rm", "-f", name], capture_output=True, text=True)
env_args = []
use_host_network = False
docker_proxy = None
if proxy_url:
use_host_network = is_localhost_proxy(proxy_url)
docker_proxy = proxy_url if use_host_network else normalize_proxy_for_docker(proxy_url)
env_args.extend(
[
"-e",
f"HTTP_PROXY={docker_proxy}",
"-e",
f"HTTPS_PROXY={docker_proxy}",
"-e",
f"ALL_PROXY={docker_proxy}",
"-e",
f"http_proxy={docker_proxy}",
"-e",
f"https_proxy={docker_proxy}",
"-e",
f"all_proxy={docker_proxy}",
]
)
if no_proxy:
env_args.extend(
[
"-e",
f"NO_PROXY={no_proxy}",
"-e",
f"no_proxy={no_proxy}",
]
)
run_cmd = ["docker", "run", "-d", "--name", name]
if use_host_network:
run_cmd.extend(["--network", "host"])
run_cmd.extend(
[
"-p",
"4416:4416",
*env_args,
"--init",
"brainicism/bgutil-ytdlp-pot-provider",
]
)
run = subprocess.run(run_cmd, capture_output=True, text=True)
if run.returncode != 0 and use_host_network:
docker_proxy = normalize_proxy_for_docker(proxy_url)
env_args = [
"-e",
f"HTTP_PROXY={docker_proxy}",
"-e",
f"HTTPS_PROXY={docker_proxy}",
"-e",
f"ALL_PROXY={docker_proxy}",
"-e",
f"http_proxy={docker_proxy}",
"-e",
f"https_proxy={docker_proxy}",
"-e",
f"all_proxy={docker_proxy}",
]
if no_proxy:
env_args.extend(
[
"-e",
f"NO_PROXY={no_proxy}",
"-e",
f"no_proxy={no_proxy}",
]
)
subprocess.run(
[
"docker",
"run",
"-d",
"--name",
name,
"-p",
"4416:4416",
*env_args,
"--init",
"brainicism/bgutil-ytdlp-pot-provider",
],
capture_output=True,
text=True,
)
return wait_for_provider()
def fallback_format_for_quality(quality: Optional[str]) -> str:
if not quality or quality == "best":
return "best[protocol!*=m3u8][ext=mp4]/best[protocol!*=m3u8]/best"
if quality.endswith("p") and quality[:-1].isdigit():
height = quality[:-1]
return (
f"best[height<={height}][protocol!*=m3u8][ext=mp4]/"
f"best[height<={height}][protocol!*=m3u8]/best[protocol!*=m3u8]"
)
return "best[protocol!*=m3u8][ext=mp4]/best[protocol!*=m3u8]/best"
def print_video_info(cmd_base: list, url: str, hide_cookie_logs: bool = False) -> int:
info_cmd = cmd_base + ["--skip-download", "--dump-json", "--no-playlist", url]
result = subprocess.run(info_cmd, capture_output=True, text=True)
if result.returncode != 0:
print("✗ Failed to fetch video metadata")
if result.stderr:
print(filter_cookie_lines(result.stderr).strip())
fallback = fetch_oembed_info(url)
if fallback:
print("\n✓ Retrieved metadata via YouTube oEmbed (limited fields).")
render_oembed_info(fallback)
return 0
return result.returncode
first_line = result.stdout.strip().splitlines()[0] if result.stdout else ""
if not first_line:
print("✗ No metadata returned")
return 1
try:
info = json.loads(first_line)
title = info.get("title", "Unknown title")
uploader = info.get("uploader") or info.get("channel") or "Unknown uploader"
duration = info.get("duration")
duration_text = f"{duration}s" if isinstance(duration, int) else "Unknown"
thumbnail = info.get("thumbnail")
print(f"Title: {title}")
print(f"Uploader: {uploader}")
print(f"Duration: {duration_text}")
if thumbnail:
print(f"Thumbnail: {thumbnail}")
except json.JSONDecodeError:
print(first_line)
return 0
def fetch_oembed_info(url: str) -> Optional[dict]:
oembed_url = f"https://www.youtube.com/oembed?url={quote(url, safe='')}&format=json"
try:
with urlopen(oembed_url, timeout=15) as response:
payload = response.read().decode("utf-8")
return json.loads(payload)
except Exception:
return None
def render_oembed_info(info: dict) -> None:
title = info.get("title", "Unknown title")
uploader = info.get("author_name", "Unknown uploader")
thumbnail = info.get("thumbnail_url")
print(f"Title: {title}")
print(f"Uploader: {uploader}")
print("Duration: Unknown")
if thumbnail:
print(f"Thumbnail: {thumbnail}")
def download_video(
url: str,
output_dir: str = ".",
format_spec: str = None,
quality: str = None,
output_template: str = None,
merge_format: str = "mp4",
subtitles: bool = False,
subtitle_lang: str = "en",
cookies_from_browser: str = None,
cookies_file: str = None,
player_client: str = None,
auto_po_token: bool = True,
proxy: str = None,
wpc_browser_path: str = None,
allow_playlist: bool = False,
use_android_client: bool = True,
audio_only: bool = False,
list_formats: bool = False,
info_only: bool = False,
) -> int:
"""
Download a YouTube video using yt-dlp.
Args:
url: YouTube video URL
output_dir: Directory to save the downloaded file
format_spec: Format specification (e.g., "bestvideo+bestaudio/best")
quality: Quality preset (best, 1080p, 720p, 480p, 360p, worst)
output_template: Output filename template (yt-dlp template syntax)
merge_format: Merge output container format (e.g., mp4, mkv)
subtitles: Download subtitles if available
subtitle_lang: Subtitle languages (comma-separated)
cookies_from_browser: Load cookies from browser (e.g., chrome, firefox)
cookies_file: Load cookies from a cookies.txt file
player_client: Use a specific YouTube player client (e.g., web_safari)
auto_po_token: Attempt to auto-start PO Token provider on 403 errors
proxy: Proxy URL for yt-dlp and PO Token provider
wpc_browser_path: Browser path for WPC PO Token provider
allow_playlist: Allow playlist downloads (default: False)
use_android_client: Use Android client to avoid nsig extraction issues
audio_only: Download audio only
list_formats: List available formats instead of downloading
info_only: Print video info before exiting
Returns:
Exit code (0 for success, non-zero for failure)
"""
# Check if yt-dlp is installed
check_result = subprocess.run(
["which", "yt-dlp"], capture_output=True, text=True
)
if check_result.returncode != 0:
print("✗ Error: yt-dlp is not installed")
print(" Install via: brew install yt-dlp # or: pip install yt-dlp")
return 1
# Build yt-dlp command
cmd = ["yt-dlp"]
if cookies_from_browser and cookies_file:
print("✗ Error: Use either --cookies-from-browser or --cookies-file, not both.")
return 2
proxy_value, no_proxy = get_proxy_settings(proxy)
use_po_token = auto_po_token and not info_only
provider_type = None
wpc_available = False
wpc_retried = False
if use_po_token:
provider_type = ensure_po_token_provider(proxy_value, no_proxy)
if not provider_type:
print("✗ PO Token provider could not be started. Aborting download.")
return 2
wpc_available = provider_type == "wpc"
if not wpc_browser_path and use_po_token:
wpc_browser_path = find_chrome_path()
# Use Android client by default only when PO tokens are disabled and no custom client/cookies
use_android = use_android_client and not (
use_po_token or cookies_from_browser or cookies_file or player_client
)
if use_android_client and not use_android:
if use_po_token:
print(" Note: Disabling Android client because PO Token provider is enabled.")
else:
print(" Note: Disabling Android client because cookies or player client are in use.")
if use_android:
cmd.extend(["--extractor-args", "youtube:player_client=android"])
if cookies_from_browser:
cmd.extend(["--cookies-from-browser", cookies_from_browser])
elif cookies_file:
cmd.extend(["--cookies", cookies_file])
if proxy_value:
cmd.extend(["--proxy", proxy_value])
po_token_client = None
if use_po_token:
po_token_client = "mweb"
if cookies_from_browser or cookies_file:
po_token_client = "web_safari"
if player_client:
cmd.extend(["--extractor-args", f"youtube:player_client={player_client}"])
elif po_token_client:
cmd.extend(["--extractor-args", f"youtube:player_client={po_token_client}"])
if not allow_playlist:
cmd.append("--no-playlist")
# List formats if requested
if list_formats:
cmd.extend(["-F", url])
result = run_yt_dlp(cmd, hide_cookie_logs=bool(cookies_from_browser))
# Check if PO token provider might be needed
if result.returncode == 0 and use_android_client:
print("\n💡 Tip: Using Android client (360p only).")
print(" For 1080p/4K, install PO token provider:")
print(" See ../references/po-token-setup.md for instructions")
return result.returncode
if info_only:
return print_video_info(cmd, url, hide_cookie_logs=bool(cookies_from_browser))
if format_spec and quality:
print("✗ Error: Use either --format or --quality, not both.")
return 2
if not format_spec and not quality and not audio_only:
quality = "best"
format_from_quality = False
if quality:
format_spec = QUALITY_PRESETS.get(quality)
if not format_spec:
print(f"✗ Error: Unsupported quality preset: {quality}")
return 2
format_from_quality = True
# Set output directory
output_root = Path(output_dir).expanduser().resolve()
output_root.mkdir(parents=True, exist_ok=True)
output_template_final = build_output_template(str(output_root), output_template)
cmd.extend(["-o", output_template_final])
# Handle audio-only downloads
if audio_only:
cmd.extend(["-x", "--audio-format", "mp3"])
elif format_spec:
cmd.extend(["-f", format_spec])
if subtitles:
cmd.extend(["--write-subs", "--write-auto-subs", "--sub-lang", subtitle_lang])
if merge_format:
cmd.extend(["--merge-output-format", merge_format])
# Add URL
cmd.append(url)
def finalize_download(before_snapshot: set) -> None:
after_files = list_files(output_root)
new_files = sorted(after_files - before_snapshot)
primary = pick_primary_file(new_files, audio_only=audio_only)
if primary:
size = human_size(primary.stat().st_size)
print(f"\n✓ Download completed successfully!")
print(f" File: {primary}")
print(f" Size: {size}")
if audio_only:
print(" Resolution: N/A (audio-only)")
else:
resolution = get_video_resolution(primary)
if resolution:
print(f" Resolution: {resolution}")
else:
print(" Resolution: Not available")
else:
print(f"\n✓ Download completed successfully!")
print(f" Location: {output_root}")
retry_client = player_client or po_token_client
if wpc_available and wpc_browser_path:
cmd = with_wpc_browser(cmd, wpc_browser_path)
# Execute download
before_files = list_files(output_root)
print(f"Executing: {' '.join(cmd)}")
result = run_yt_dlp(cmd, hide_cookie_logs=bool(cookies_from_browser))
if result.returncode == 0:
finalize_download(before_files)
else:
if use_po_token and provider_type == "bgutil" and has_pot_error(result):
print("\n⚠️ PO Token provider did not respond. Restarting it and retrying...")
if restart_po_token_provider(proxy_value, no_proxy):
retry_cmd = with_player_client(cmd, retry_client or "mweb")
before_retry = list_files(output_root)
print(f"Executing: {' '.join(retry_cmd)}")
retry_result = run_yt_dlp(retry_cmd, hide_cookie_logs=bool(cookies_from_browser))
if retry_result.returncode == 0:
finalize_download(before_retry)
return 0
result = retry_result
if has_pot_error(result):
print("\n⚠️ Docker provider still failing. Switching to browser-based PO Token provider...")
if ensure_wpc_provider(proxy_value):
retry_cmd = with_player_client(cmd, retry_client or "mweb")
retry_cmd = with_wpc_browser(retry_cmd, wpc_browser_path)
before_retry = list_files(output_root)
print(f"Executing: {' '.join(retry_cmd)}")
retry_result = run_yt_dlp(retry_cmd, hide_cookie_logs=bool(cookies_from_browser))
if retry_result.returncode == 0:
finalize_download(before_retry)
return 0
result = retry_result
wpc_retried = True
if use_po_token and has_wpc_error(result):
print("\n⚠️ Browser verification not ready. Keeping Chrome open and retrying once...")
time.sleep(3)
retry_cmd = with_player_client(cmd, retry_client or "mweb")
retry_cmd = with_wpc_browser(retry_cmd, wpc_browser_path)
before_retry = list_files(output_root)
print(f"Executing: {' '.join(retry_cmd)}")
retry_result = run_yt_dlp(retry_cmd, hide_cookie_logs=bool(cookies_from_browser))
if retry_result.returncode == 0:
finalize_download(before_retry)
return 0
result = retry_result
wpc_retried = True
if use_po_token and has_pot_error(result) and not wpc_retried:
print("\n⚠️ PO Token provider failed. Switching to browser-based PO Token provider...")
if ensure_wpc_provider(proxy_value):
retry_cmd = with_player_client(cmd, retry_client or "mweb")
retry_cmd = with_wpc_browser(retry_cmd, wpc_browser_path)
before_retry = list_files(output_root)
print(f"Executing: {' '.join(retry_cmd)}")
retry_result = run_yt_dlp(retry_cmd, hide_cookie_logs=bool(cookies_from_browser))
if retry_result.returncode == 0:
finalize_download(before_retry)
return 0
result = retry_result
if cookies_from_browser and not player_client and has_403_error(result):
print("\n⚠️ Download failed with 403 errors. Retrying with web_safari client...")
retry_cmd = with_player_client(cmd, "web_safari")
before_retry = list_files(output_root)
print(f"Executing: {' '.join(retry_cmd)}")
retry_result = run_yt_dlp(retry_cmd, hide_cookie_logs=bool(cookies_from_browser))
if retry_result.returncode == 0:
finalize_download(before_retry)
return 0
result = retry_result
if not audio_only and format_from_quality:
print("\n⚠️ Download failed. Retrying with non-m3u8 progressive formats...")
retry_cmd = cmd[:]
retry_format = fallback_format_for_quality(quality)
if "-f" in retry_cmd:
format_index = retry_cmd.index("-f") + 1
if format_index < len(retry_cmd):
retry_cmd[format_index] = retry_format
else:
retry_cmd.extend(["-f", retry_format])
before_retry = list_files(output_root)
print(f"Executing: {' '.join(retry_cmd)}")
retry_result = run_yt_dlp(retry_cmd, hide_cookie_logs=bool(cookies_from_browser))
if retry_result.returncode == 0:
finalize_download(before_retry)
return 0
print(f"\n✗ Download failed with exit code {retry_result.returncode}")
return retry_result.returncode
print(f"\n✗ Download failed with exit code {result.returncode}")
return result.returncode
def main():
parser = argparse.ArgumentParser(
description="Download YouTube videos using yt-dlp with robust error handling"
)
parser.add_argument("url", help="YouTube video URL")
parser.add_argument(
"-o",
"--output-dir",
default=".",
help="Output directory (default: current directory)",
)
parser.add_argument(
"--output-template",
help="Output template (e.g., '%%(title)s.%%(ext)s')",
)
parser.add_argument(
"-f", "--format", help="Format specification (e.g., 'bestvideo+bestaudio/best')"
)
parser.add_argument(
"-q",
"--quality",
choices=sorted(QUALITY_PRESETS.keys()),
help="Quality preset (best, 1080p, 720p, 480p, 360p, worst)",
)
parser.add_argument(
"--merge-format",
default="mp4",
help="Merge output container format (default: mp4)",
)
parser.add_argument(
"--subtitles",
action="store_true",
help="Download subtitles if available",
)
parser.add_argument(
"--sub-lang",
default="en",
help="Subtitle languages (comma-separated, default: en)",
)
parser.add_argument(
"--cookies-from-browser",
help="Load cookies from browser (e.g., chrome, firefox)",
)
parser.add_argument(
"--cookies-file",
help="Load cookies from a cookies.txt file",
)
parser.add_argument(
"--player-client",
help="Use a specific YouTube player client (e.g., web_safari)",
)
parser.add_argument(
"--proxy",
help="Proxy URL for yt-dlp and PO Token provider (e.g., http://127.0.0.1:1082)",
)
parser.add_argument(
"--wpc-browser-path",
help="Browser executable path for WPC PO Token provider",
)
auto_group = parser.add_mutually_exclusive_group()
auto_group.add_argument(
"--auto-po-token",
action="store_true",
help="Automatically start a PO Token provider (default)",
)
auto_group.add_argument(
"--no-auto-po-token",
action="store_true",
help="Disable automatic PO Token provider setup",
)
parser.add_argument(
"--playlist",
action="store_true",
help="Allow playlist downloads (default: single video only)",
)
parser.add_argument(
"--no-android-client",
action="store_true",
help="Disable Android client workaround",
)
parser.add_argument(
"-a", "--audio-only", action="store_true", help="Download audio only (as MP3)"
)
parser.add_argument(
"-F", "--list-formats", action="store_true", help="List available formats"
)
parser.add_argument(
"--info",
action="store_true",
help="Print video metadata (title/uploader/duration) and exit",
)
args = parser.parse_args()
exit_code = download_video(
url=args.url,
output_dir=args.output_dir,
format_spec=args.format,
quality=args.quality,
output_template=args.output_template,
merge_format=args.merge_format,
subtitles=args.subtitles,
subtitle_lang=args.sub_lang,
cookies_from_browser=args.cookies_from_browser,
cookies_file=args.cookies_file,
player_client=args.player_client,
auto_po_token=not args.no_auto_po_token,
proxy=args.proxy,
wpc_browser_path=args.wpc_browser_path,
allow_playlist=args.playlist,
use_android_client=not args.no_android_client,
audio_only=args.audio_only,
list_formats=args.list_formats,
info_only=args.info,
)
sys.exit(exit_code)
if __name__ == "__main__":
main()