
Youtube Tools
- 280 installs
- 11 repo stars
- Updated August 4, 2026
- casper-studios/casper-marketplace
youtube-tools is an agent skill that runs local yt-dlp Python scripts to download YouTube videos, extract transcripts, and fetch metadata without API keys for developers building free offline video datasets.
About
Downloads YouTube videos, audio, transcripts, and metadata (single or bulk) using yt-dlp with no API keys. A developer uses it to grab video files, subtitles, or metadata for free.
- Free downloads via yt-dlp, no API keys
- Single or bulk video, audio, transcript and metadata retrieval
Youtube Tools by the numbers
- 280 all-time installs (skills.sh)
- Ranked #503 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/casper-studios/casper-marketplace --skill youtube-toolsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 280 |
|---|---|
| repo stars | ★ 11 |
| Last updated | August 4, 2026 |
| Repository | casper-studios/casper-marketplace ↗ |
How do you download YouTube transcripts locally?
Download YouTube videos, audio, transcripts/subtitles, and metadata in bulk using yt-dlp with no API keys.
Who is it for?
Developers building local video datasets, transcript corpora, or playlist archives without YouTube Data API keys or per-video costs.
Skip if: YouTube search-result scraping at scale, comment mining, or cloud-only Apify workflows that need trending discovery rather than direct URL downloads.
When should I use this skill?
User requests YouTube video download, transcript extraction, playlist archiving, or metadata export for specific video URLs.
What you get
Local video or audio files, transcript text files, and metadata JSON under .tmp/youtube/ directories.
- Downloaded video or audio files
- Transcript text files
- metadata.json from get_video_info.py
By the numbers
- Bundles 3 Python scripts for download, transcript, and metadata operations
- Zero API keys and zero per-video fees documented in SKILL.md
- Default output restricted to .tmp/youtube/ subdirectories
Files
YouTube Tools (yt-dlp)
Overview
Free, local YouTube operations using yt-dlp. No API keys required, no per-video costs. Works offline after installation.
When to Use This vs Apify
┌─────────────────────────────────────────────────────────────────┐
│ DECISION: YouTube Tools (yt-dlp) vs Apify │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Use youtube-tools (FREE) when: │
│ ├── Downloading videos to local storage │
│ ├── Extracting transcripts/subtitles │
│ ├── Getting video metadata (title, duration, views, etc.) │
│ ├── Bulk downloading playlists or channels │
│ ├── Converting to audio-only (MP3) │
│ └── You want zero API costs │
│ │
│ Use apify-scrapers when: │
│ ├── Scraping YouTube SEARCH results │
│ ├── Getting comments at scale │
│ ├── Channel analytics and statistics │
│ ├── Trending video discovery │
│ └── You need cloud-based processing │
│ │
└─────────────────────────────────────────────────────────────────┘Quick Decision Tree
What do you need?
│
├── Download video(s)
│ ├── Single video → scripts/download_video.py URL
│ ├── Multiple videos → scripts/download_video.py --urls-file list.txt
│ ├── Playlist → scripts/download_video.py "playlist_url"
│ ├── Audio only → scripts/download_video.py URL --audio-only
│ └── Specific quality → scripts/download_video.py URL --quality 720p
│
├── Get transcript/subtitles
│ ├── Auto-generated captions → scripts/get_transcript.py URL
│ ├── Manual subtitles → scripts/get_transcript.py URL --manual-only
│ ├── Specific language → scripts/get_transcript.py URL --lang es
│ └── All available → scripts/get_transcript.py URL --all-langs
│
├── Get video metadata
│ ├── Single video → scripts/get_video_info.py URL
│ ├── Multiple videos → scripts/get_video_info.py --urls-file list.txt
│ └── Playlist info → scripts/get_video_info.py "playlist_url"
│
└── Advanced
├── Age-restricted → scripts/download_video.py URL --cookies-from-browser chrome
├── Private videos → Requires authentication (see references/yt-dlp-guide.md)
└── Live streams → scripts/download_video.py URL --live-from-startEnvironment Setup
# Install yt-dlp (required)
pip install yt-dlp
# Optional: Install ffmpeg for format conversion
# macOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt install ffmpeg
# Windows
winget install ffmpegNo API keys required! This is completely free.
Common Usage
Download Single Video
python scripts/download_video.py "https://www.youtube.com/watch?v=VIDEO_ID"Download with Specific Quality
python scripts/download_video.py "https://youtu.be/VIDEO_ID" --quality 1080pDownload Audio Only (MP3)
python scripts/download_video.py "https://youtube.com/watch?v=VIDEO_ID" --audio-onlyDownload Entire Playlist
python scripts/download_video.py "https://youtube.com/playlist?list=PLAYLIST_ID" --output-dir ./videosBulk Download from File
# Create urls.txt with one URL per line
python scripts/download_video.py --urls-file urls.txt --output-dir ./downloadsGet Transcript
python scripts/get_transcript.py "https://youtube.com/watch?v=VIDEO_ID"Get Transcript in Specific Language
python scripts/get_transcript.py "https://youtu.be/VIDEO_ID" --lang esGet Video Metadata
python scripts/get_video_info.py "https://youtube.com/watch?v=VIDEO_ID"Get Metadata for Multiple Videos
python scripts/get_video_info.py --urls-file videos.txt --output metadata.jsonOutput Location
All outputs save to .tmp/youtube/ by default:
- Videos:
.tmp/youtube/videos/ - Audio:
.tmp/youtube/audio/ - Transcripts:
.tmp/youtube/transcripts/ - Metadata:
.tmp/youtube/metadata/
Cost
FREE - No API keys, no per-video costs, no subscriptions.
Security Notes
Safe by Design
- URL validation: Only accepts YouTube URLs (youtube.com, youtu.be)
- Filename sanitization: Removes dangerous characters
- Output restriction: Only writes to
.tmp/directory - No shell injection: Uses subprocess with argument lists, not string concatenation
- No stored credentials: Cookies only used when explicitly requested
Copyright Warning
- Only download content you have rights to access
- Respect YouTube's Terms of Service
- Do not redistribute copyrighted content
- Use for personal/educational purposes
Rate Limiting
- yt-dlp has built-in rate limiting
- For bulk downloads, use
--sleep-interval 5to avoid throttling - YouTube may temporarily block IPs with excessive requests
Troubleshooting
Issue: "Video unavailable"
Cause: Video is private, age-restricted, or region-locked Solution: Use --cookies-from-browser chrome for age-restricted content
Issue: "Unable to extract video data"
Cause: YouTube changed their page structure Solution: Update yt-dlp: pip install -U yt-dlp
Issue: No subtitles found
Cause: Video has no captions (auto or manual) Solution: Use --list-subs to see available subtitles first
Issue: Slow downloads
Cause: YouTube throttling or network issues Solution: Try --concurrent-fragments 4 for faster downloads
Issue: Format conversion failed
Cause: ffmpeg not installed Solution: Install ffmpeg (see Environment Setup)
Integration Patterns
Download + Transcribe + Summarize
# 1. Download video
python scripts/download_video.py "URL" --output-dir .tmp/video
# 2. Get transcript
python scripts/get_transcript.py "URL" --output .tmp/transcript.txt
# 3. Use content-generation to summarize
# (transcript file is now ready for summarization)Bulk Research Workflow
# 1. Get metadata for research videos
python scripts/get_video_info.py --urls-file research_videos.txt --output .tmp/metadata.json
# 2. Download transcripts for text analysis
python scripts/get_transcript.py --urls-file research_videos.txt --output-dir .tmp/transcripts
# 3. Use parallel-research to analyze contentCourse Content Download
# Download entire playlist as course modules
python scripts/download_video.py "PLAYLIST_URL" --output-dir .tmp/course --quality 720p
# Get all transcripts for notes
python scripts/get_transcript.py "PLAYLIST_URL" --output-dir .tmp/course/transcriptsResources
- references/yt-dlp-guide.md - Complete yt-dlp reference with all options
- yt-dlp documentation: https://github.com/yt-dlp/yt-dlp#readme
yt-dlp Complete Reference Guide
Overview
yt-dlp is a feature-rich command-line video downloader supporting thousands of websites. It's a maintained fork of youtube-dl with additional features and fixes.
Installation
# pip (recommended)
pip install yt-dlp
# Homebrew (macOS)
brew install yt-dlp
# Update to latest
pip install -U yt-dlpOptional: FFmpeg for Format Conversion
# macOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt install ffmpeg
# Windows
winget install ffmpegyoutube-tools vs Apify: When to Use Which
| Task | youtube-tools (yt-dlp) | apify-scrapers |
|---|---|---|
| Download videos | Best choice (FREE) | Not applicable |
| Download audio | Best choice (FREE) | Not applicable |
| Get transcripts | Best choice (FREE) | Slower, costs $ |
| Get video metadata | Good (FREE) | Good for bulk |
| Search YouTube | Not supported | Use this |
| Get comments at scale | Limited | Use this |
| Channel analytics | Not supported | Use this |
| Trending discovery | Not supported | Use this |
| Cloud processing | Not supported | Use this |
Rule of thumb:
- Downloading content → youtube-tools (FREE)
- Searching/scraping YouTube → apify-scrapers (paid)
Command Reference
Basic Download
# Best quality
yt-dlp "https://youtube.com/watch?v=VIDEO_ID"
# Specific quality
yt-dlp -f "bestvideo[height<=720]+bestaudio/best[height<=720]" URL
# Audio only (MP3)
yt-dlp -x --audio-format mp3 URL
# Audio only (best quality)
yt-dlp -x --audio-format best URLQuality Selection
# List available formats
yt-dlp -F URL
# Best video + best audio (default)
yt-dlp -f "bestvideo+bestaudio/best" URL
# Specific resolution
yt-dlp -f "bestvideo[height<=1080]+bestaudio" URL
# Specific format by ID
yt-dlp -f 22 URL # 720p mp4Quality Presets
| Preset | Format String |
|---|---|
| 4K | bestvideo[height<=2160]+bestaudio/best[height<=2160] |
| 1080p | bestvideo[height<=1080]+bestaudio/best[height<=1080] |
| 720p | bestvideo[height<=720]+bestaudio/best[height<=720] |
| 480p | bestvideo[height<=480]+bestaudio/best[height<=480] |
| Smallest | worstvideo+worstaudio/worst |
Output Options
# Custom filename
yt-dlp -o "%(title)s.%(ext)s" URL
# With upload date
yt-dlp -o "%(upload_date)s-%(title)s.%(ext)s" URL
# Organized by channel
yt-dlp -o "%(channel)s/%(title)s.%(ext)s" URL
# Sanitized filenames (safe for all systems)
yt-dlp --restrict-filenames URL
# Custom output directory
yt-dlp -P /path/to/output URLOutput Template Variables
| Variable | Description |
|---|---|
%(title)s | Video title |
%(id)s | Video ID |
%(ext)s | File extension |
%(channel)s | Channel name |
%(uploader)s | Uploader name |
%(upload_date)s | Upload date (YYYYMMDD) |
%(duration)s | Duration in seconds |
%(view_count)s | View count |
%(playlist_index)s | Playlist position |
Subtitles / Transcripts
# Download auto-generated captions
yt-dlp --write-auto-subs URL
# Download manual subtitles
yt-dlp --write-subs URL
# Both auto and manual
yt-dlp --write-subs --write-auto-subs URL
# Specific language
yt-dlp --write-subs --sub-langs en URL
# All languages
yt-dlp --all-subs URL
# Convert subtitle format
yt-dlp --write-subs --convert-subs srt URL
# List available subtitles
yt-dlp --list-subs URL
# Download only subtitles (no video)
yt-dlp --skip-download --write-auto-subs URLPlaylists
# Download entire playlist
yt-dlp "https://youtube.com/playlist?list=PLAYLIST_ID"
# Download specific range
yt-dlp --playlist-start 5 --playlist-end 10 URL
# Download specific items
yt-dlp --playlist-items "1,3,5-7" URL
# Reverse order
yt-dlp --playlist-reverse URL
# Don't download playlist (single video only)
yt-dlp --no-playlist URLMetadata
# Write info JSON
yt-dlp --write-info-json URL
# Write description
yt-dlp --write-description URL
# Write thumbnail
yt-dlp --write-thumbnail URL
# Embed metadata in file
yt-dlp --embed-metadata URL
# Embed thumbnail
yt-dlp --embed-thumbnail URL
# Get info without downloading
yt-dlp --dump-json --no-download URLAuthentication
# Browser cookies (safest)
yt-dlp --cookies-from-browser chrome URL
yt-dlp --cookies-from-browser firefox URL
yt-dlp --cookies-from-browser safari URL
# Cookie file (Netscape format)
yt-dlp --cookies cookies.txt URL
# Username/password (not recommended)
yt-dlp -u USERNAME -p PASSWORD URLRate Limiting & Throttling
# Sleep between downloads
yt-dlp --sleep-interval 5 URL
# Random sleep range
yt-dlp --min-sleep-interval 3 --max-sleep-interval 10 URL
# Limit download rate
yt-dlp --limit-rate 1M URL
# Concurrent fragments
yt-dlp --concurrent-fragments 4 URLFiltering
# Match title
yt-dlp --match-title "tutorial" URL
# Reject title
yt-dlp --reject-title "advertisement" URL
# Date filtering
yt-dlp --dateafter 20230101 URL
yt-dlp --datebefore 20231231 URL
# View count filtering
yt-dlp --min-views 1000 URL
yt-dlp --max-views 1000000 URL
# Duration filtering (seconds)
yt-dlp --match-filter "duration > 60 & duration < 600" URLLive Streams
# Download live stream (wait for it to end)
yt-dlp URL
# Download from start
yt-dlp --live-from-start URL
# Wait for stream to go live
yt-dlp --wait-for-video 30 URL # Check every 30 secondsAdvanced Options
# Archive (don't re-download)
yt-dlp --download-archive downloaded.txt URL
# Max downloads
yt-dlp --max-downloads 10 URL
# Retries
yt-dlp --retries 10 URL
# Ignore errors (continue on failure)
yt-dlp --ignore-errors URL
# Quiet mode
yt-dlp -q URL
# Verbose mode
yt-dlp -v URL
# Simulate (don't download)
yt-dlp --simulate URLCommon Use Cases
Download Course Playlist
yt-dlp \
-f "bestvideo[height<=720]+bestaudio" \
--merge-output-format mp4 \
-o "%(playlist_index)s-%(title)s.%(ext)s" \
--restrict-filenames \
--sleep-interval 5 \
"PLAYLIST_URL"Extract All Transcripts from Playlist
yt-dlp \
--skip-download \
--write-auto-subs \
--sub-langs en \
--convert-subs srt \
-o "%(playlist_index)s-%(title)s.%(ext)s" \
"PLAYLIST_URL"Download Audio Podcast
yt-dlp \
-x \
--audio-format mp3 \
--audio-quality 0 \
--embed-thumbnail \
--embed-metadata \
-o "%(title)s.%(ext)s" \
URLArchive Channel
yt-dlp \
--download-archive channel_archive.txt \
-f "bestvideo[height<=1080]+bestaudio" \
--merge-output-format mp4 \
-o "%(upload_date)s-%(title)s.%(ext)s" \
--sleep-interval 10 \
"https://youtube.com/@CHANNEL/videos"Get All Metadata (No Download)
yt-dlp \
--dump-json \
--no-download \
--flat-playlist \
"PLAYLIST_URL" > playlist_info.jsonError Handling
Common Errors
| Error | Cause | Solution |
|---|---|---|
Video unavailable | Private/deleted/region-locked | Use VPN or cookies |
Sign in to confirm age | Age-restricted | --cookies-from-browser chrome |
Unable to extract video data | YouTube changed API | Update: pip install -U yt-dlp |
HTTP Error 429 | Rate limited | Add --sleep-interval 10 |
Incomplete data | Network issue | Add --retries 10 |
Requested format not available | Format doesn't exist | Use -F to list formats |
Retry Strategy
yt-dlp \
--retries 10 \
--fragment-retries 10 \
--skip-unavailable-fragments \
URLSecurity Considerations
Safe Practices
- Only download from trusted URLs
- Validate URLs before processing
- Use
--restrict-filenamesto sanitize output - Avoid
--execfor untrusted content - Don't store cookies/credentials in scripts
Privacy
- yt-dlp doesn't track or phone home
- No data sent to third parties
- Browser cookie access requires explicit flag
- Downloaded files are local only
Performance Tips
Speed Up Downloads
# Use concurrent fragments
yt-dlp --concurrent-fragments 4 URL
# Use external downloader
yt-dlp --downloader aria2c URLReduce Bandwidth
# Lower quality
yt-dlp -f "bestvideo[height<=480]+bestaudio" URL
# Audio only
yt-dlp -x URLBatch Processing
# From file
yt-dlp -a urls.txt
# With archive (skip already downloaded)
yt-dlp --download-archive archive.txt -a urls.txtIntegration with Other Tools
FFmpeg (included when converting)
# Merge formats
yt-dlp --merge-output-format mp4 URL
# Post-process audio
yt-dlp -x --audio-format mp3 --postprocessor-args "-ar 44100" URLjq (JSON processing)
# Extract specific fields
yt-dlp --dump-json URL | jq '{title, duration, view_count}'aria2c (faster downloads)
# Use aria2c for downloading
yt-dlp --downloader aria2c --downloader-args "-x 16 -s 16" URLResources
- GitHub: https://github.com/yt-dlp/yt-dlp
- Documentation: https://github.com/yt-dlp/yt-dlp#readme
- Supported sites: https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md
- Options reference:
yt-dlp --help
#!/usr/bin/env python3
"""
YouTube Video Downloader - Safe, free video downloading using yt-dlp.
Security features:
- URL validation (only YouTube domains)
- Filename sanitization
- Output directory restriction (.tmp/ only by default)
- No shell injection (subprocess with list args)
- No credential storage
Usage:
# Single video
python download_video.py "https://youtube.com/watch?v=VIDEO_ID"
# With quality
python download_video.py "URL" --quality 720p
# Audio only
python download_video.py "URL" --audio-only
# Playlist
python download_video.py "PLAYLIST_URL"
# Bulk from file
python download_video.py --urls-file urls.txt
# Age-restricted (requires browser cookies)
python download_video.py "URL" --cookies-from-browser chrome
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse, parse_qs
# =============================================================================
# SECURITY: URL Validation
# =============================================================================
ALLOWED_DOMAINS = [
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'youtu.be',
'www.youtu.be',
'youtube-nocookie.com',
'www.youtube-nocookie.com',
]
def is_valid_youtube_url(url: str) -> bool:
"""
Validate that URL is a legitimate YouTube URL.
Security: Prevents arbitrary URL downloads and potential SSRF attacks.
"""
try:
parsed = urlparse(url)
domain = parsed.netloc.lower()
# Check domain whitelist
if domain not in ALLOWED_DOMAINS:
return False
# Must be http or https
if parsed.scheme not in ('http', 'https'):
return False
return True
except Exception:
return False
def extract_video_id(url: str) -> str | None:
"""Extract video ID from YouTube URL for logging (not for security)."""
try:
parsed = urlparse(url)
# youtu.be/VIDEO_ID
if 'youtu.be' in parsed.netloc:
return parsed.path.strip('/')
# youtube.com/watch?v=VIDEO_ID
if 'v' in parse_qs(parsed.query):
return parse_qs(parsed.query)['v'][0]
# youtube.com/shorts/VIDEO_ID
if '/shorts/' in parsed.path:
return parsed.path.split('/shorts/')[-1].split('/')[0]
return None
except Exception:
return None
# =============================================================================
# SECURITY: Filename Sanitization
# =============================================================================
def sanitize_filename(filename: str) -> str:
"""
Remove dangerous characters from filename.
Security: Prevents path traversal and command injection via filenames.
"""
# Remove path separators and null bytes
dangerous_chars = ['/', '\\', '\x00', '..', ':', '*', '?', '"', '<', '>', '|']
result = filename
for char in dangerous_chars:
result = result.replace(char, '_')
# Limit length
if len(result) > 200:
result = result[:200]
# Remove leading/trailing dots and spaces
result = result.strip('. ')
return result if result else 'video'
# =============================================================================
# SECURITY: Output Directory Validation
# =============================================================================
def validate_output_dir(output_dir: str, base_allowed: str = '.tmp') -> Path:
"""
Ensure output directory is within allowed path.
Security: Prevents writing to arbitrary filesystem locations.
"""
output_path = Path(output_dir).resolve()
# Default to .tmp/youtube if not specified
if not output_dir:
output_path = Path('.tmp/youtube/videos').resolve()
# Ensure .tmp directory exists
base_path = Path(base_allowed).resolve()
# Allow .tmp and subdirectories, or explicit user override with warning
if not str(output_path).startswith(str(base_path)):
print(f"WARNING: Output directory '{output_path}' is outside .tmp/")
print("For safety, files will be saved to .tmp/youtube/videos/")
output_path = Path('.tmp/youtube/videos').resolve()
output_path.mkdir(parents=True, exist_ok=True)
return output_path
# =============================================================================
# Core Download Functions
# =============================================================================
def check_yt_dlp_installed() -> bool:
"""Check if yt-dlp is installed."""
try:
result = subprocess.run(
['yt-dlp', '--version'],
capture_output=True,
text=True,
timeout=10
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def build_download_command(
url: str,
output_dir: Path,
quality: str | None = None,
audio_only: bool = False,
cookies_browser: str | None = None,
extra_args: list[str] | None = None
) -> list[str]:
"""
Build yt-dlp command with safe arguments.
Security: Uses list-based arguments to prevent shell injection.
"""
cmd = ['yt-dlp']
# Output template with sanitized filename
output_template = str(output_dir / '%(title)s.%(ext)s')
cmd.extend(['-o', output_template])
# Restrict filename characters
cmd.append('--restrict-filenames')
# Progress output
cmd.append('--progress')
# Quality selection
if audio_only:
cmd.extend([
'-x', # Extract audio
'--audio-format', 'mp3',
'--audio-quality', '0', # Best quality
])
elif quality:
quality_map = {
'2160p': 'bestvideo[height<=2160]+bestaudio/best[height<=2160]',
'1440p': 'bestvideo[height<=1440]+bestaudio/best[height<=1440]',
'1080p': 'bestvideo[height<=1080]+bestaudio/best[height<=1080]',
'720p': 'bestvideo[height<=720]+bestaudio/best[height<=720]',
'480p': 'bestvideo[height<=480]+bestaudio/best[height<=480]',
'360p': 'bestvideo[height<=360]+bestaudio/best[height<=360]',
'best': 'bestvideo+bestaudio/best',
'worst': 'worstvideo+worstaudio/worst',
}
format_str = quality_map.get(quality.lower(), quality_map['best'])
cmd.extend(['-f', format_str])
else:
# Default: best quality that merges
cmd.extend(['-f', 'bestvideo+bestaudio/best'])
# Merge format (requires ffmpeg)
cmd.extend(['--merge-output-format', 'mp4'])
# Browser cookies for age-restricted content
if cookies_browser:
allowed_browsers = ['chrome', 'firefox', 'safari', 'edge', 'brave', 'opera']
if cookies_browser.lower() in allowed_browsers:
cmd.extend(['--cookies-from-browser', cookies_browser.lower()])
else:
print(f"WARNING: Unknown browser '{cookies_browser}', skipping cookies")
# No playlist by default for single URLs (safety)
if 'playlist' not in url.lower() and 'list=' not in url.lower():
cmd.append('--no-playlist')
# Extra arguments (validated)
if extra_args:
safe_args = validate_extra_args(extra_args)
cmd.extend(safe_args)
# Add URL last
cmd.append(url)
return cmd
def validate_extra_args(args: list[str]) -> list[str]:
"""
Validate extra arguments for safety.
Security: Whitelist of allowed yt-dlp arguments.
"""
# Whitelist of safe arguments
safe_prefixes = [
'--sleep-interval', '--max-sleep-interval',
'--concurrent-fragments', '--retries',
'--fragment-retries', '--skip-unavailable-fragments',
'--keep-video', '--no-keep-video',
'--embed-thumbnail', '--embed-metadata',
'--write-info-json', '--write-description',
'--write-thumbnail', '--write-comments',
'--live-from-start', '--wait-for-video',
'--match-title', '--reject-title',
'--min-views', '--max-views',
'--match-filter', '--no-match-filter',
'--age-limit', '--download-archive',
'--max-downloads', '--playlist-start',
'--playlist-end', '--playlist-items',
'--quiet', '--verbose', '--simulate',
]
# Dangerous arguments to block
blocked_args = [
'--exec', '--exec-before-download', # Command execution
'--config-location', # Config file injection
'--cookies', # Direct cookie file (use --cookies-from-browser)
'--batch-file', # We handle this ourselves
'-a', # Alias for batch-file
]
safe = []
i = 0
while i < len(args):
arg = args[i]
# Check if blocked
if any(arg.startswith(blocked) for blocked in blocked_args):
print(f"WARNING: Blocked unsafe argument: {arg}")
i += 1
continue
# Check if allowed
if any(arg.startswith(prefix) for prefix in safe_prefixes):
safe.append(arg)
# If it's a flag that takes a value
if '=' not in arg and i + 1 < len(args) and not args[i + 1].startswith('-'):
safe.append(args[i + 1])
i += 1
i += 1
return safe
def download_video(
url: str,
output_dir: Path,
quality: str | None = None,
audio_only: bool = False,
cookies_browser: str | None = None,
extra_args: list[str] | None = None,
verbose: bool = False
) -> dict:
"""Download a single video and return result info."""
result = {
'url': url,
'video_id': extract_video_id(url),
'success': False,
'output_dir': str(output_dir),
'error': None,
'timestamp': datetime.now().isoformat(),
}
# Validate URL
if not is_valid_youtube_url(url):
result['error'] = f"Invalid YouTube URL: {url}"
print(f"ERROR: {result['error']}")
return result
# Build command
cmd = build_download_command(
url=url,
output_dir=output_dir,
quality=quality,
audio_only=audio_only,
cookies_browser=cookies_browser,
extra_args=extra_args
)
if verbose:
print(f"Command: {' '.join(cmd)}")
try:
# Run download
process = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=3600 # 1 hour timeout
)
if process.returncode == 0:
result['success'] = True
result['stdout'] = process.stdout
print(f"SUCCESS: Downloaded {url}")
else:
result['error'] = process.stderr or "Download failed"
print(f"ERROR: {result['error'][:200]}")
except subprocess.TimeoutExpired:
result['error'] = "Download timed out (1 hour limit)"
print(f"ERROR: {result['error']}")
except Exception as e:
result['error'] = str(e)
print(f"ERROR: {result['error']}")
return result
def download_from_file(
urls_file: str,
output_dir: Path,
quality: str | None = None,
audio_only: bool = False,
cookies_browser: str | None = None,
sleep_interval: int = 5
) -> list[dict]:
"""Download multiple videos from a file of URLs."""
results = []
# Read and validate URLs file
try:
with open(urls_file, 'r') as f:
urls = [line.strip() for line in f if line.strip() and not line.startswith('#')]
except Exception as e:
print(f"ERROR: Could not read URLs file: {e}")
return results
print(f"Found {len(urls)} URLs to download")
for i, url in enumerate(urls, 1):
print(f"\n[{i}/{len(urls)}] Processing: {url[:80]}...")
result = download_video(
url=url,
output_dir=output_dir,
quality=quality,
audio_only=audio_only,
cookies_browser=cookies_browser,
extra_args=[f'--sleep-interval={sleep_interval}'] if sleep_interval > 0 else None
)
results.append(result)
return results
# =============================================================================
# Main
# =============================================================================
def main():
parser = argparse.ArgumentParser(
description='Safe YouTube video downloader using yt-dlp',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s "https://youtube.com/watch?v=VIDEO_ID"
%(prog)s "URL" --quality 720p
%(prog)s "URL" --audio-only
%(prog)s "PLAYLIST_URL" --output-dir ./videos
%(prog)s --urls-file urls.txt
%(prog)s "URL" --cookies-from-browser chrome # For age-restricted
"""
)
parser.add_argument('url', nargs='?', help='YouTube video or playlist URL')
parser.add_argument('--urls-file', help='File containing URLs (one per line)')
parser.add_argument('--output-dir', '-o', default='.tmp/youtube/videos',
help='Output directory (default: .tmp/youtube/videos)')
parser.add_argument('--quality', '-q',
choices=['2160p', '1440p', '1080p', '720p', '480p', '360p', 'best', 'worst'],
help='Video quality (default: best)')
parser.add_argument('--audio-only', '-a', action='store_true',
help='Extract audio only (MP3)')
parser.add_argument('--cookies-from-browser', '-c',
choices=['chrome', 'firefox', 'safari', 'edge', 'brave', 'opera'],
help='Use cookies from browser (for age-restricted content)')
parser.add_argument('--sleep-interval', type=int, default=5,
help='Seconds to sleep between downloads in bulk mode (default: 5)')
parser.add_argument('--verbose', '-v', action='store_true',
help='Show verbose output')
parser.add_argument('--output-json', help='Save results to JSON file')
args = parser.parse_args()
# Check yt-dlp is installed
if not check_yt_dlp_installed():
print("ERROR: yt-dlp is not installed. Run: pip install yt-dlp")
sys.exit(1)
# Validate we have input
if not args.url and not args.urls_file:
parser.print_help()
sys.exit(1)
# Validate and create output directory
output_dir = validate_output_dir(args.output_dir)
# Adjust for audio-only
if args.audio_only:
output_dir = output_dir.parent / 'audio'
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Output directory: {output_dir}")
print("=" * 60)
# Download
if args.urls_file:
results = download_from_file(
urls_file=args.urls_file,
output_dir=output_dir,
quality=args.quality,
audio_only=args.audio_only,
cookies_browser=args.cookies_from_browser,
sleep_interval=args.sleep_interval
)
else:
results = [download_video(
url=args.url,
output_dir=output_dir,
quality=args.quality,
audio_only=args.audio_only,
cookies_browser=args.cookies_from_browser,
verbose=args.verbose
)]
# Summary
print("\n" + "=" * 60)
success_count = sum(1 for r in results if r['success'])
print(f"Results: {success_count}/{len(results)} successful")
# Save results to JSON if requested
if args.output_json:
with open(args.output_json, 'w') as f:
json.dump(results, f, indent=2)
print(f"Results saved to: {args.output_json}")
# Return appropriate exit code
sys.exit(0 if success_count == len(results) else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
YouTube Transcript Extractor - Safe subtitle/caption extraction using yt-dlp.
Security features:
- URL validation (only YouTube domains)
- Output directory restriction (.tmp/ only by default)
- No shell injection (subprocess with list args)
Usage:
# Get auto-generated captions
python get_transcript.py "https://youtube.com/watch?v=VIDEO_ID"
# Get specific language
python get_transcript.py "URL" --lang es
# Get manual subtitles only (higher quality)
python get_transcript.py "URL" --manual-only
# List available subtitles
python get_transcript.py "URL" --list-subs
# Bulk extraction
python get_transcript.py --urls-file videos.txt
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse, parse_qs
# =============================================================================
# SECURITY: URL Validation (same as download_video.py)
# =============================================================================
ALLOWED_DOMAINS = [
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'youtu.be',
'www.youtu.be',
'youtube-nocookie.com',
'www.youtube-nocookie.com',
]
def is_valid_youtube_url(url: str) -> bool:
"""Validate that URL is a legitimate YouTube URL."""
try:
parsed = urlparse(url)
domain = parsed.netloc.lower()
if domain not in ALLOWED_DOMAINS:
return False
if parsed.scheme not in ('http', 'https'):
return False
return True
except Exception:
return False
def extract_video_id(url: str) -> str | None:
"""Extract video ID from YouTube URL."""
try:
parsed = urlparse(url)
if 'youtu.be' in parsed.netloc:
return parsed.path.strip('/')
if 'v' in parse_qs(parsed.query):
return parse_qs(parsed.query)['v'][0]
if '/shorts/' in parsed.path:
return parsed.path.split('/shorts/')[-1].split('/')[0]
return None
except Exception:
return None
def validate_output_dir(output_dir: str, base_allowed: str = '.tmp') -> Path:
"""Ensure output directory is within allowed path."""
output_path = Path(output_dir).resolve()
if not output_dir:
output_path = Path('.tmp/youtube/transcripts').resolve()
base_path = Path(base_allowed).resolve()
if not str(output_path).startswith(str(base_path)):
print(f"WARNING: Output directory '{output_path}' is outside .tmp/")
output_path = Path('.tmp/youtube/transcripts').resolve()
output_path.mkdir(parents=True, exist_ok=True)
return output_path
def sanitize_filename(filename: str) -> str:
"""Remove dangerous characters from filename."""
dangerous_chars = ['/', '\\', '\x00', '..', ':', '*', '?', '"', '<', '>', '|']
result = filename
for char in dangerous_chars:
result = result.replace(char, '_')
if len(result) > 200:
result = result[:200]
result = result.strip('. ')
return result if result else 'transcript'
# =============================================================================
# Core Functions
# =============================================================================
def check_yt_dlp_installed() -> bool:
"""Check if yt-dlp is installed."""
try:
result = subprocess.run(
['yt-dlp', '--version'],
capture_output=True,
text=True,
timeout=10
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def list_available_subtitles(url: str) -> dict | None:
"""List available subtitles for a video."""
if not is_valid_youtube_url(url):
print(f"ERROR: Invalid YouTube URL: {url}")
return None
cmd = ['yt-dlp', '--list-subs', '--skip-download', url]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
return {
'url': url,
'video_id': extract_video_id(url),
'output': result.stdout,
'error': result.stderr if result.returncode != 0 else None
}
except Exception as e:
return {'url': url, 'error': str(e)}
def get_transcript(
url: str,
output_dir: Path,
lang: str = 'en',
manual_only: bool = False,
auto_only: bool = False,
all_langs: bool = False,
format: str = 'vtt',
cookies_browser: str | None = None
) -> dict:
"""
Extract transcript/subtitles from a YouTube video.
Args:
url: YouTube video URL
output_dir: Where to save the transcript
lang: Language code (default: en)
manual_only: Only get manually uploaded subtitles
auto_only: Only get auto-generated captions
all_langs: Download all available languages
format: Output format (vtt, srt, ass, json3)
cookies_browser: Browser to get cookies from (for age-restricted)
Returns:
dict with success status and file info
"""
result = {
'url': url,
'video_id': extract_video_id(url),
'success': False,
'output_dir': str(output_dir),
'files': [],
'error': None,
'timestamp': datetime.now().isoformat(),
}
# Validate URL
if not is_valid_youtube_url(url):
result['error'] = f"Invalid YouTube URL: {url}"
print(f"ERROR: {result['error']}")
return result
# Build command
cmd = ['yt-dlp', '--skip-download'] # Don't download video
# Output template
output_template = str(output_dir / '%(title)s.%(ext)s')
cmd.extend(['-o', output_template])
cmd.append('--restrict-filenames')
# Subtitle selection
if all_langs:
cmd.append('--all-subs')
else:
if manual_only:
cmd.extend(['--sub-langs', lang])
cmd.append('--write-subs')
elif auto_only:
cmd.extend(['--sub-langs', lang])
cmd.append('--write-auto-subs')
else:
# Try manual first, fall back to auto
cmd.extend(['--sub-langs', lang])
cmd.append('--write-subs')
cmd.append('--write-auto-subs')
# Convert to specified format
if format != 'vtt':
cmd.extend(['--convert-subs', format])
# Browser cookies if needed
if cookies_browser:
allowed_browsers = ['chrome', 'firefox', 'safari', 'edge', 'brave', 'opera']
if cookies_browser.lower() in allowed_browsers:
cmd.extend(['--cookies-from-browser', cookies_browser.lower()])
# Also write video info for context
cmd.append('--write-info-json')
cmd.append(url)
try:
process = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300
)
# Check for subtitle files
subtitle_extensions = ['.vtt', '.srt', '.ass', '.json3', '.ttml']
for file in output_dir.iterdir():
if any(file.name.endswith(ext) for ext in subtitle_extensions):
result['files'].append(str(file))
if result['files']:
result['success'] = True
print(f"SUCCESS: Extracted {len(result['files'])} transcript file(s)")
for f in result['files']:
print(f" - {f}")
else:
result['error'] = "No subtitles found for this video"
if process.stderr:
result['error'] += f"\n{process.stderr[:500]}"
print(f"WARNING: {result['error']}")
except subprocess.TimeoutExpired:
result['error'] = "Transcript extraction timed out"
print(f"ERROR: {result['error']}")
except Exception as e:
result['error'] = str(e)
print(f"ERROR: {result['error']}")
return result
def convert_vtt_to_text(vtt_file: Path) -> str:
"""
Convert VTT subtitle file to plain text.
Removes timestamps and formatting for easy reading/processing.
"""
try:
content = vtt_file.read_text(encoding='utf-8')
lines = content.split('\n')
text_lines = []
prev_line = None
for line in lines:
# Skip header
if line.startswith('WEBVTT') or line.startswith('Kind:') or line.startswith('Language:'):
continue
# Skip timestamps (00:00:00.000 --> 00:00:00.000)
if '-->' in line:
continue
# Skip empty lines and line numbers
if not line.strip() or line.strip().isdigit():
continue
# Skip position/alignment cues
if line.startswith('align:') or line.startswith('position:'):
continue
# Remove HTML tags
clean_line = re.sub(r'<[^>]+>', '', line)
clean_line = clean_line.strip()
# Deduplicate (YouTube auto-captions often repeat)
if clean_line and clean_line != prev_line:
text_lines.append(clean_line)
prev_line = clean_line
return ' '.join(text_lines)
except Exception as e:
return f"Error converting VTT: {e}"
def extract_from_file(
urls_file: str,
output_dir: Path,
lang: str = 'en',
manual_only: bool = False,
sleep_interval: int = 3
) -> list[dict]:
"""Extract transcripts from multiple videos."""
results = []
try:
with open(urls_file, 'r') as f:
urls = [line.strip() for line in f if line.strip() and not line.startswith('#')]
except Exception as e:
print(f"ERROR: Could not read URLs file: {e}")
return results
print(f"Found {len(urls)} URLs to process")
for i, url in enumerate(urls, 1):
print(f"\n[{i}/{len(urls)}] Processing: {url[:60]}...")
result = get_transcript(
url=url,
output_dir=output_dir,
lang=lang,
manual_only=manual_only
)
results.append(result)
# Sleep between requests
if i < len(urls) and sleep_interval > 0:
import time
time.sleep(sleep_interval)
return results
# =============================================================================
# Main
# =============================================================================
def main():
parser = argparse.ArgumentParser(
description='Extract transcripts/subtitles from YouTube videos',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s "https://youtube.com/watch?v=VIDEO_ID"
%(prog)s "URL" --lang es
%(prog)s "URL" --manual-only
%(prog)s "URL" --list-subs
%(prog)s "URL" --format srt
%(prog)s --urls-file videos.txt
%(prog)s "URL" --to-text # Convert to plain text
"""
)
parser.add_argument('url', nargs='?', help='YouTube video URL')
parser.add_argument('--urls-file', help='File containing URLs (one per line)')
parser.add_argument('--output-dir', '-o', default='.tmp/youtube/transcripts',
help='Output directory (default: .tmp/youtube/transcripts)')
parser.add_argument('--output', help='Specific output file path')
parser.add_argument('--lang', '-l', default='en',
help='Language code (default: en)')
parser.add_argument('--manual-only', action='store_true',
help='Only get manually uploaded subtitles')
parser.add_argument('--auto-only', action='store_true',
help='Only get auto-generated captions')
parser.add_argument('--all-langs', action='store_true',
help='Download all available languages')
parser.add_argument('--list-subs', action='store_true',
help='List available subtitles without downloading')
parser.add_argument('--format', '-f', default='vtt',
choices=['vtt', 'srt', 'ass', 'json3'],
help='Subtitle format (default: vtt)')
parser.add_argument('--to-text', action='store_true',
help='Convert transcript to plain text')
parser.add_argument('--cookies-from-browser', '-c',
choices=['chrome', 'firefox', 'safari', 'edge', 'brave', 'opera'],
help='Use cookies from browser (for age-restricted)')
parser.add_argument('--output-json', help='Save results to JSON file')
args = parser.parse_args()
# Check yt-dlp is installed
if not check_yt_dlp_installed():
print("ERROR: yt-dlp is not installed. Run: pip install yt-dlp")
sys.exit(1)
# Validate we have input
if not args.url and not args.urls_file:
parser.print_help()
sys.exit(1)
# List subtitles mode
if args.list_subs:
if not args.url:
print("ERROR: URL required for --list-subs")
sys.exit(1)
result = list_available_subtitles(args.url)
if result:
print(result.get('output', 'No output'))
if result.get('error'):
print(f"Error: {result['error']}")
sys.exit(0)
# Validate and create output directory
output_dir = validate_output_dir(args.output_dir)
print(f"Output directory: {output_dir}")
print("=" * 60)
# Extract transcripts
if args.urls_file:
results = extract_from_file(
urls_file=args.urls_file,
output_dir=output_dir,
lang=args.lang,
manual_only=args.manual_only
)
else:
results = [get_transcript(
url=args.url,
output_dir=output_dir,
lang=args.lang,
manual_only=args.manual_only,
auto_only=args.auto_only,
all_langs=args.all_langs,
format=args.format,
cookies_browser=args.cookies_from_browser
)]
# Convert to plain text if requested
if args.to_text:
for result in results:
for file_path in result.get('files', []):
if file_path.endswith('.vtt'):
vtt_path = Path(file_path)
text = convert_vtt_to_text(vtt_path)
text_path = vtt_path.with_suffix('.txt')
text_path.write_text(text, encoding='utf-8')
print(f"Converted to text: {text_path}")
result['files'].append(str(text_path))
# Summary
print("\n" + "=" * 60)
success_count = sum(1 for r in results if r['success'])
print(f"Results: {success_count}/{len(results)} successful")
# Save to specific output file if requested
if args.output and len(results) == 1 and results[0]['success']:
for file_path in results[0].get('files', []):
if file_path.endswith('.vtt') or file_path.endswith('.srt'):
content = Path(file_path).read_text(encoding='utf-8')
Path(args.output).write_text(content, encoding='utf-8')
print(f"Saved to: {args.output}")
break
# Save results JSON if requested
if args.output_json:
with open(args.output_json, 'w') as f:
json.dump(results, f, indent=2)
print(f"Results saved to: {args.output_json}")
sys.exit(0 if success_count == len(results) else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
YouTube Video Info Extractor - Get metadata without downloading.
Security features:
- URL validation (only YouTube domains)
- No file system writes except to .tmp/
- No shell injection
Usage:
# Single video
python get_video_info.py "https://youtube.com/watch?v=VIDEO_ID"
# Multiple videos
python get_video_info.py --urls-file videos.txt
# Playlist info
python get_video_info.py "PLAYLIST_URL"
# Output to JSON
python get_video_info.py "URL" --output info.json
"""
import argparse
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse, parse_qs
# =============================================================================
# SECURITY: URL Validation
# =============================================================================
ALLOWED_DOMAINS = [
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'youtu.be',
'www.youtu.be',
'youtube-nocookie.com',
'www.youtube-nocookie.com',
]
def is_valid_youtube_url(url: str) -> bool:
"""Validate that URL is a legitimate YouTube URL."""
try:
parsed = urlparse(url)
domain = parsed.netloc.lower()
if domain not in ALLOWED_DOMAINS:
return False
if parsed.scheme not in ('http', 'https'):
return False
return True
except Exception:
return False
def extract_video_id(url: str) -> str | None:
"""Extract video ID from YouTube URL."""
try:
parsed = urlparse(url)
if 'youtu.be' in parsed.netloc:
return parsed.path.strip('/')
if 'v' in parse_qs(parsed.query):
return parse_qs(parsed.query)['v'][0]
if '/shorts/' in parsed.path:
return parsed.path.split('/shorts/')[-1].split('/')[0]
return None
except Exception:
return None
# =============================================================================
# Core Functions
# =============================================================================
def check_yt_dlp_installed() -> bool:
"""Check if yt-dlp is installed."""
try:
result = subprocess.run(
['yt-dlp', '--version'],
capture_output=True,
text=True,
timeout=10
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def format_duration(seconds: int) -> str:
"""Format duration in seconds to human-readable string."""
if not seconds:
return "Unknown"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
def format_number(num: int) -> str:
"""Format large numbers with K/M/B suffixes."""
if not num:
return "0"
if num >= 1_000_000_000:
return f"{num / 1_000_000_000:.1f}B"
if num >= 1_000_000:
return f"{num / 1_000_000:.1f}M"
if num >= 1_000:
return f"{num / 1_000:.1f}K"
return str(num)
def get_video_info(url: str, cookies_browser: str | None = None) -> dict:
"""
Get metadata for a YouTube video without downloading.
Returns dict with:
- title, description, channel, upload_date
- duration, view_count, like_count
- thumbnail URLs, available formats
- tags, categories
"""
result = {
'url': url,
'video_id': extract_video_id(url),
'success': False,
'error': None,
'timestamp': datetime.now().isoformat(),
'info': None
}
# Validate URL
if not is_valid_youtube_url(url):
result['error'] = f"Invalid YouTube URL: {url}"
print(f"ERROR: {result['error']}")
return result
# Build command - extract info without downloading
cmd = [
'yt-dlp',
'--dump-json', # Output info as JSON
'--no-download', # Don't download anything
'--no-warnings',
]
# Browser cookies if needed
if cookies_browser:
allowed_browsers = ['chrome', 'firefox', 'safari', 'edge', 'brave', 'opera']
if cookies_browser.lower() in allowed_browsers:
cmd.extend(['--cookies-from-browser', cookies_browser.lower()])
cmd.append(url)
try:
process = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120
)
if process.returncode == 0 and process.stdout:
# Parse JSON output
raw_info = json.loads(process.stdout)
# Extract key fields
info = {
'id': raw_info.get('id'),
'title': raw_info.get('title'),
'description': raw_info.get('description', '')[:1000], # Truncate
'channel': raw_info.get('channel'),
'channel_id': raw_info.get('channel_id'),
'channel_url': raw_info.get('channel_url'),
'uploader': raw_info.get('uploader'),
'upload_date': raw_info.get('upload_date'),
'duration': raw_info.get('duration'),
'duration_string': format_duration(raw_info.get('duration')),
'view_count': raw_info.get('view_count'),
'view_count_string': format_number(raw_info.get('view_count')),
'like_count': raw_info.get('like_count'),
'like_count_string': format_number(raw_info.get('like_count')),
'comment_count': raw_info.get('comment_count'),
'age_limit': raw_info.get('age_limit', 0),
'is_live': raw_info.get('is_live', False),
'was_live': raw_info.get('was_live', False),
'thumbnail': raw_info.get('thumbnail'),
'thumbnails': [t.get('url') for t in raw_info.get('thumbnails', [])[-3:]], # Last 3 (highest quality)
'tags': raw_info.get('tags', [])[:20], # Limit tags
'categories': raw_info.get('categories', []),
'language': raw_info.get('language'),
'availability': raw_info.get('availability'),
'webpage_url': raw_info.get('webpage_url'),
'formats_available': len(raw_info.get('formats', [])),
'subtitles_available': list(raw_info.get('subtitles', {}).keys()),
'automatic_captions_available': list(raw_info.get('automatic_captions', {}).keys())[:10],
}
# Best format info
formats = raw_info.get('formats', [])
video_formats = [f for f in formats if f.get('vcodec') != 'none' and f.get('height')]
if video_formats:
best = max(video_formats, key=lambda x: x.get('height', 0))
info['best_quality'] = f"{best.get('height')}p"
info['best_format'] = best.get('format_note', '')
result['info'] = info
result['success'] = True
# Print summary
print(f"Title: {info['title']}")
print(f"Channel: {info['channel']}")
print(f"Duration: {info['duration_string']}")
print(f"Views: {info['view_count_string']}")
print(f"Likes: {info['like_count_string']}")
print(f"Upload Date: {info['upload_date']}")
print(f"Best Quality: {info.get('best_quality', 'N/A')}")
else:
result['error'] = process.stderr or "Failed to get video info"
print(f"ERROR: {result['error'][:200]}")
except json.JSONDecodeError as e:
result['error'] = f"Failed to parse video info: {e}"
print(f"ERROR: {result['error']}")
except subprocess.TimeoutExpired:
result['error'] = "Request timed out"
print(f"ERROR: {result['error']}")
except Exception as e:
result['error'] = str(e)
print(f"ERROR: {result['error']}")
return result
def get_playlist_info(url: str) -> dict:
"""Get info for all videos in a playlist."""
result = {
'url': url,
'success': False,
'error': None,
'timestamp': datetime.now().isoformat(),
'playlist_title': None,
'playlist_count': 0,
'videos': []
}
if not is_valid_youtube_url(url):
result['error'] = f"Invalid YouTube URL: {url}"
return result
cmd = [
'yt-dlp',
'--dump-json',
'--flat-playlist', # Don't extract video info, just list
'--no-download',
url
]
try:
process = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300
)
if process.returncode == 0 and process.stdout:
# Each line is a separate JSON object
videos = []
for line in process.stdout.strip().split('\n'):
if line:
try:
video = json.loads(line)
videos.append({
'id': video.get('id'),
'title': video.get('title'),
'url': video.get('url') or f"https://youtube.com/watch?v={video.get('id')}",
'duration': video.get('duration'),
'duration_string': format_duration(video.get('duration')),
})
except json.JSONDecodeError:
continue
result['videos'] = videos
result['playlist_count'] = len(videos)
result['success'] = True
print(f"Playlist contains {len(videos)} videos")
except Exception as e:
result['error'] = str(e)
return result
def process_urls_file(urls_file: str, cookies_browser: str | None = None) -> list[dict]:
"""Process multiple URLs from a file."""
results = []
try:
with open(urls_file, 'r') as f:
urls = [line.strip() for line in f if line.strip() and not line.startswith('#')]
except Exception as e:
print(f"ERROR: Could not read URLs file: {e}")
return results
print(f"Processing {len(urls)} URLs...")
for i, url in enumerate(urls, 1):
print(f"\n[{i}/{len(urls)}] {url[:60]}...")
result = get_video_info(url, cookies_browser)
results.append(result)
return results
# =============================================================================
# Main
# =============================================================================
def main():
parser = argparse.ArgumentParser(
description='Get YouTube video metadata without downloading',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s "https://youtube.com/watch?v=VIDEO_ID"
%(prog)s "PLAYLIST_URL"
%(prog)s --urls-file videos.txt
%(prog)s "URL" --output info.json
%(prog)s "URL" --full # Include all raw data
"""
)
parser.add_argument('url', nargs='?', help='YouTube video or playlist URL')
parser.add_argument('--urls-file', help='File containing URLs (one per line)')
parser.add_argument('--output', '-o', help='Output JSON file')
parser.add_argument('--full', action='store_true',
help='Include full raw data (larger output)')
parser.add_argument('--cookies-from-browser', '-c',
choices=['chrome', 'firefox', 'safari', 'edge', 'brave', 'opera'],
help='Use cookies from browser (for age-restricted)')
parser.add_argument('--quiet', '-q', action='store_true',
help='Minimal output (JSON only)')
args = parser.parse_args()
# Check yt-dlp is installed
if not check_yt_dlp_installed():
print("ERROR: yt-dlp is not installed. Run: pip install yt-dlp")
sys.exit(1)
# Validate we have input
if not args.url and not args.urls_file:
parser.print_help()
sys.exit(1)
# Process
if args.urls_file:
results = process_urls_file(args.urls_file, args.cookies_from_browser)
elif 'playlist' in args.url.lower() or 'list=' in args.url:
results = [get_playlist_info(args.url)]
else:
results = [get_video_info(args.url, args.cookies_from_browser)]
# Output
if args.output:
output_path = Path(args.output)
# Ensure we're writing to .tmp if no path specified
if not output_path.is_absolute() and not str(output_path).startswith('.tmp'):
output_path = Path('.tmp/youtube/metadata') / output_path
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w') as f:
json.dump(results if len(results) > 1 else results[0], f, indent=2)
print(f"\nSaved to: {output_path}")
elif args.quiet:
print(json.dumps(results if len(results) > 1 else results[0], indent=2))
# Summary
if not args.quiet:
print("\n" + "=" * 60)
success_count = sum(1 for r in results if r['success'])
print(f"Results: {success_count}/{len(results)} successful")
sys.exit(0 if all(r['success'] for r in results) else 1)
if __name__ == '__main__':
main()
Related skills
How it compares
Pick youtube-tools for free local yt-dlp downloads and transcripts; pick apify-scrapers when the task is YouTube search, comments, or cloud analytics at scale.
FAQ
Does youtube-tools require YouTube API keys?
youtube-tools uses local yt-dlp via three Python scripts with no API keys, no per-video costs, and offline operation after yt-dlp and optional ffmpeg are installed.
Where does youtube-tools save downloaded files?
youtube-tools writes videos, audio, transcripts, and metadata under .tmp/youtube/ subdirectories with URL validation and filename sanitization enforced by the bundled scripts.
When should developers choose youtube-tools over Apify?
youtube-tools fits direct video downloads, transcript extraction, playlist archiving, and metadata retrieval; Apify scrapers are recommended for search results, comments at scale, and channel analytics.