"""
YouTube Helper — argparse-based command-line interface.
Thin wrapper around the pure functions in :mod:`youtube_helper.main`,
:mod:`youtube_helper.streaming` and :mod:`youtube_helper.branding` that
exposes the whole toolkit as subcommands under a single ``youtube-helper``
entry point. Written with :mod:`argparse` from the standard library so
the CLI works out of the box on any Python install that has the package
installed — no extra dependency required.
Subcommands
-----------
- ``metadata`` — dump JSON metadata for one video URL
- ``valid`` — exit 0 iff the URL is a valid video URL for yt-dlp
- ``video`` — download the video track to disk
- ``audio`` — download the audio track to disk
- ``thumbnail`` — download the thumbnail image to disk
- ``resolve`` — resolve a URL to a direct ffmpeg-ready media URL
- ``list-streams`` — enumerate every video format yt-dlp finds
- ``pick-stream`` — pick a single best video format matching constraints
- ``channel-info`` — channel snapshot (subs, video count, total views)
- ``channel-videos``— list channel videos with normalised engagement metrics
- ``engagement`` — one-video engagement snapshot (views/likes/comments)
- ``engagement-batch`` — multi-URL engagement snapshot
- ``subtitles`` — download (auto) subtitles for a video into a folder
- ``comments`` — dump top comments of a video
- ``ytdlp-version`` — print installed yt-dlp version
Usage Example
-------------
>>> # youtube-helper metadata --url https://www.youtube.com/watch?v=YE7VzlLtp-4
>>> # youtube-helper audio --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --output out.mp3
>>> # youtube-helper video --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --output out.mp4
>>> # youtube-helper thumbnail --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --output out.png
>>> # youtube-helper resolve --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --prefer audio
>>> # youtube-helper list-streams --url https://www.youtube.com/watch?v=YE7VzlLtp-4
>>> # youtube-helper pick-stream --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --prefer-codec h264
>>> # youtube-helper channel-info --url https://www.youtube.com/@blender
>>> # youtube-helper engagement --url https://www.youtube.com/watch?v=YE7VzlLtp-4
>>> # youtube-helper subtitles --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --output-dir subs/
>>> # youtube-helper comments --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --max 50
Author
------
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
"""
from __future__ import annotations
import argparse
import json
from collections.abc import Sequence
# Import the pure functions once here — every subcommand is a thin dispatch
# on top of these, no logic duplication.
from . import (
channel_info,
channel_videos,
download_audio,
download_thumbnail,
download_video,
engagement_batch,
ensure_recent_ytdlp,
is_valid_video_url,
list_video_streams,
pick_video_stream,
resolve_direct_url,
video_comments,
video_engagement,
video_subtitles,
video_url_meta_data,
)
# ---------------------------------------------------------------------------
# Subcommand handlers
#
# Each handler receives the parsed ``argparse.Namespace`` and returns a
# process exit code (``0`` on success). Handlers deliberately stay short:
# they translate CLI arguments into keyword arguments for the underlying
# library function, print a machine-friendly result (JSON for structured
# outputs, plain path for single-file outputs), and let exceptions
# propagate as non-zero exit codes.
# ---------------------------------------------------------------------------
def _dumps(obj: object) -> str:
"""JSON-encode with a safe default so odd yt-dlp types (bytes, sets) still print."""
return json.dumps(obj, indent=2, default=str, ensure_ascii=False)
def _handle_metadata(ns: argparse.Namespace) -> int:
"""Full yt-dlp info dict for the URL — verbose, useful for debugging.
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Full yt-dlp info dict for the URL — verbose, useful for debugging.
meta = video_url_meta_data(ns.url)
print(_dumps(meta))
return 0
def _handle_valid(ns: argparse.Namespace) -> int:
"""Exit 0 iff URL is a valid video URL. Handy in shell as `if …; then …; fi`.
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Exit 0 iff URL is a valid video URL. Handy in shell as `if …; then …; fi`.
ok = is_valid_video_url(ns.url)
print("true" if ok else "false")
return 0 if ok else 1
def _handle_video(ns: argparse.Namespace) -> int:
"""Download the video track (yt-dlp bestvideo+bestaudio, muxed).
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Download the video track (yt-dlp bestvideo+bestaudio, muxed).
out = download_video(url=ns.url, output_path=ns.output)
print(out)
return 0
def _handle_audio(ns: argparse.Namespace) -> int:
"""Download the audio track only (yt-dlp bestaudio, re-encoded via ffmpeg).
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Download the audio track only (yt-dlp bestaudio, re-encoded via ffmpeg).
out = download_audio(url=ns.url, output_path=ns.output, target_sample_rate=ns.sample_rate)
print(out)
return 0
def _handle_thumbnail(ns: argparse.Namespace) -> int:
"""Download the thumbnail image (PNG / JPG chosen by --output extension).
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Download the thumbnail image (PNG / JPG chosen by --output extension).
out = download_thumbnail(url=ns.url, output_path=ns.output)
print(out)
return 0
def _handle_resolve(ns: argparse.Namespace) -> int:
"""Quick resolver — one direct ffmpeg-ready URL. Emits JSON so callers can pluck `url`, `container`, `is_live`, `headers` without shelling grep tricks.
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Quick resolver — one direct ffmpeg-ready URL. Emits JSON so callers can pluck
# `url`, `container`, `is_live`, `headers` without shelling grep tricks.
result = resolve_direct_url(url=ns.url, prefer=ns.prefer, live=ns.live)
print(_dumps(result))
return 0
def _handle_list_streams(ns: argparse.Namespace) -> int:
"""Full video-format catalog. Excludes audio-only formats.
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Full video-format catalog. Excludes audio-only formats.
streams = list_video_streams(
url=ns.url,
include_video_only=ns.include_video_only,
include_combined=ns.include_combined,
cookies_from_browser=ns.cookies_from_browser,
verbose=ns.verbose,
)
print(_dumps(streams))
return 0
def _handle_pick_stream(ns: argparse.Namespace) -> int:
"""Single stream matching the constraints (or ValueError if none match).
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Single stream matching the constraints (or ValueError if none match).
chosen = pick_video_stream(
url=ns.url,
prefer_codec=ns.prefer_codec,
prefer_format=ns.prefer_format,
max_fps=ns.max_fps,
language=ns.language,
include_video_only=ns.include_video_only,
include_combined=ns.include_combined,
cookies_from_browser=ns.cookies_from_browser,
verbose=ns.verbose,
)
print(_dumps(chosen))
return 0
def _handle_channel_info(ns: argparse.Namespace) -> int:
"""Channel-level snapshot (subs, video_count, total views).
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Channel-level snapshot (subs, video_count, total views).
print(_dumps(channel_info(url=ns.url, verbose=ns.verbose)))
return 0
def _handle_channel_videos(ns: argparse.Namespace) -> int:
"""Paginated video list with normalised engagement metadata.
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Paginated video list with normalised engagement metadata.
items = channel_videos(
url=ns.url,
max_videos=ns.max_videos,
include_shorts=ns.include_shorts,
include_lives=ns.include_lives,
verbose=ns.verbose,
)
print(_dumps(items))
return 0
def _handle_engagement(ns: argparse.Namespace) -> int:
"""Single-video engagement snapshot.
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Single-video engagement snapshot.
print(_dumps(video_engagement(url=ns.url, verbose=ns.verbose)))
return 0
def _handle_engagement_batch(ns: argparse.Namespace) -> int:
"""Multi-URL variant — tolerant (dead entries surface as {"url": …, "_error": …}).
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Multi-URL variant — tolerant (dead entries surface as {"url": …, "_error": …}).
print(_dumps(engagement_batch(urls=list(ns.urls), verbose=ns.verbose)))
return 0
def _handle_subtitles(ns: argparse.Namespace) -> int:
"""Auto (or manual) subtitles → {lang: absolute_vtt_path}.
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Auto (or manual) subtitles → {lang: absolute_vtt_path}.
langs = tuple(ns.langs)
result = video_subtitles(
url=ns.url,
output_dir=ns.output_dir,
langs=langs,
auto_only=ns.auto_only,
verbose=ns.verbose,
)
print(_dumps(result))
return 0
def _handle_comments(ns: argparse.Namespace) -> int:
"""Top-N comments as a JSON list.
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Top-N comments as a JSON list.
result = video_comments(
url=ns.url,
max_count=ns.max,
cookies_from_browser=ns.cookies_from_browser,
verbose=ns.verbose,
)
print(_dumps(result))
return 0
def _handle_ytdlp_version(ns: argparse.Namespace) -> int:
"""Utility — cheapest way to check freshness in ops.
Parameters
----------
ns : argparse.Namespace
Parsed CLI arguments for this subcommand.
Returns
-------
int
Process exit code (0 on success).
"""
# Utility — cheapest way to check freshness in ops.
version = ensure_recent_ytdlp(min_version=ns.min_version)
print(version)
return 0
# ---------------------------------------------------------------------------
# Parser construction
#
# One helper per subcommand keeps ``build_parser`` readable and lets the
# click twin (:mod:`youtube_helper.cli_click`) mirror the exact same flag
# names without any risk of drift.
# ---------------------------------------------------------------------------
def _add_metadata(sub: argparse._SubParsersAction) -> None:
"""Register the ``metadata`` subcommand: dump yt-dlp metadata for a video URL as JSON.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("metadata", help="Dump yt-dlp metadata for a video URL as JSON.")
p.add_argument("--url", required=True, help="Video URL.")
p.set_defaults(func=_handle_metadata)
def _add_valid(sub: argparse._SubParsersAction) -> None:
"""Register the ``valid`` subcommand: check if a URL is a valid video URL for yt-dlp.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("valid", help="Check if a URL is a valid video URL for yt-dlp.")
p.add_argument("--url", required=True, help="Video URL.")
p.set_defaults(func=_handle_valid)
def _add_video(sub: argparse._SubParsersAction) -> None:
"""Register the ``video`` subcommand: download the video (best video + audio) to disk.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("video", help="Download the video (best video + audio) to disk.")
p.add_argument("--url", required=True, help="Video URL.")
p.add_argument("--output", default=None, help="Output path (auto-named from title if omitted).")
p.set_defaults(func=_handle_video)
def _add_audio(sub: argparse._SubParsersAction) -> None:
"""Register the ``audio`` subcommand: download only the audio track to disk.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("audio", help="Download only the audio track to disk.")
p.add_argument("--url", required=True, help="Video URL.")
p.add_argument("--output", default=None, help="Output audio path (auto-named if omitted).")
p.add_argument(
"--sample-rate",
type=int,
default=44100,
dest="sample_rate",
help="Target sample rate for the output audio (default 44100).",
)
p.set_defaults(func=_handle_audio)
def _add_thumbnail(sub: argparse._SubParsersAction) -> None:
"""Register the ``thumbnail`` subcommand: download the video's thumbnail to disk.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("thumbnail", help="Download the video's thumbnail to disk.")
p.add_argument("--url", required=True, help="Video URL.")
p.add_argument("--output", default=None, help="Output image path (auto-named if omitted).")
p.set_defaults(func=_handle_thumbnail)
def _add_resolve(sub: argparse._SubParsersAction) -> None:
"""Register the ``resolve`` subcommand: resolve a URL to a direct ffmpeg-ready media URL (audio or video).
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser(
"resolve",
help="Resolve a URL to a direct ffmpeg-ready media URL (audio or video).",
)
p.add_argument("--url", required=True, help="Video URL.")
p.add_argument(
"--prefer",
choices=["audio", "video"],
default="audio",
help="Format preference: audio (bestaudio) or video (best combined).",
)
p.add_argument(
"--live",
choices=["auto", "force_live", "force_vod"],
default="auto",
help="Live-stream classification override (default: propagate yt-dlp).",
)
p.set_defaults(func=_handle_resolve)
def _stream_filter_args(p: argparse.ArgumentParser) -> None:
"""Attach the shared stream-catalog filter flags used by list/pick."""
p.add_argument(
"--no-video-only",
dest="include_video_only",
action="store_false",
default=True,
help="Exclude video-only formats (DASH style, need separate audio mux).",
)
p.add_argument(
"--no-combined",
dest="include_combined",
action="store_false",
default=True,
help="Exclude combined video+audio formats (progressive).",
)
p.add_argument(
"--cookies-from-browser",
dest="cookies_from_browser",
default=None,
help="Browser to pull cookies from (firefox / chrome / safari / …).",
)
p.add_argument("--verbose", action="store_true", help="Echo yt-dlp's output to stderr.")
def _add_list_streams(sub: argparse._SubParsersAction) -> None:
"""Register the ``list-streams`` subcommand: list every video format yt-dlp finds for a URL.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("list-streams", help="List every video format yt-dlp finds for a URL.")
p.add_argument("--url", required=True, help="Video URL.")
_stream_filter_args(p)
p.set_defaults(func=_handle_list_streams)
def _add_pick_stream(sub: argparse._SubParsersAction) -> None:
"""Register the ``pick-stream`` subcommand: pick one best video stream matching constraints.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("pick-stream", help="Pick one best video stream matching constraints.")
p.add_argument("--url", required=True, help="Video URL.")
p.add_argument(
"--prefer-codec",
dest="prefer_codec",
default=None,
help="Substring match on vcodec (h264 / vp9 / av1).",
)
p.add_argument(
"--prefer-format",
dest="prefer_format",
default=None,
help="Equality match on ext (mp4 / webm).",
)
p.add_argument(
"--max-fps",
type=float,
dest="max_fps",
default=None,
help="Drop formats with fps > max_fps.",
)
p.add_argument("--language", default=None, help="Equality match on language code.")
_stream_filter_args(p)
p.set_defaults(func=_handle_pick_stream)
def _add_channel_info(sub: argparse._SubParsersAction) -> None:
"""Register the ``channel-info`` subcommand: channel-level snapshot (subs, total views, video count).
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser(
"channel-info", help="Channel-level snapshot (subs, total views, video count)."
)
p.add_argument("--url", required=True, help="Channel / user page URL.")
p.add_argument("--verbose", action="store_true")
p.set_defaults(func=_handle_channel_info)
def _add_channel_videos(sub: argparse._SubParsersAction) -> None:
"""Register the ``channel-videos`` subcommand: list channel videos with normalised engagement metadata.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser(
"channel-videos",
help="List channel videos with normalised engagement metadata.",
)
p.add_argument("--url", required=True, help="Channel / user page URL.")
p.add_argument(
"--max-videos",
type=int,
dest="max_videos",
default=200,
help="Cap on number of videos to fetch (default 200).",
)
p.add_argument(
"--no-shorts",
dest="include_shorts",
action="store_false",
default=True,
help="Exclude Shorts / vertical clips.",
)
p.add_argument(
"--include-lives",
dest="include_lives",
action="store_true",
default=False,
help="Include live streams (default: skip).",
)
p.add_argument("--verbose", action="store_true")
p.set_defaults(func=_handle_channel_videos)
def _add_engagement(sub: argparse._SubParsersAction) -> None:
"""Register the ``engagement`` subcommand: single-video engagement snapshot.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("engagement", help="Single-video engagement snapshot.")
p.add_argument("--url", required=True, help="Video URL.")
p.add_argument("--verbose", action="store_true")
p.set_defaults(func=_handle_engagement)
def _add_engagement_batch(sub: argparse._SubParsersAction) -> None:
"""Register the ``engagement-batch`` subcommand: multi-URL engagement snapshot (tolerant).
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("engagement-batch", help="Multi-URL engagement snapshot (tolerant).")
p.add_argument("--urls", nargs="+", required=True, help="One or more video URLs.")
p.add_argument("--verbose", action="store_true")
p.set_defaults(func=_handle_engagement_batch)
def _add_subtitles(sub: argparse._SubParsersAction) -> None:
"""Register the ``subtitles`` subcommand: download subtitles (auto or manual) for a video.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("subtitles", help="Download subtitles (auto or manual) for a video.")
p.add_argument("--url", required=True, help="Video URL.")
p.add_argument(
"--output-dir",
required=True,
dest="output_dir",
help="Folder that receives the .vtt files.",
)
p.add_argument(
"--langs", nargs="+", default=["fr", "en"], help="Language codes to try (default: fr en)."
)
p.add_argument(
"--manual",
dest="auto_only",
action="store_false",
default=True,
help="Prefer manual subtitles over auto-generated ones.",
)
p.add_argument("--verbose", action="store_true")
p.set_defaults(func=_handle_subtitles)
def _add_comments(sub: argparse._SubParsersAction) -> None:
"""Register the ``comments`` subcommand: fetch top comments for a video.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("comments", help="Fetch top comments for a video.")
p.add_argument("--url", required=True, help="Video URL.")
p.add_argument("--max", type=int, default=100, help="Comment count cap (default 100).")
p.add_argument(
"--cookies-from-browser",
dest="cookies_from_browser",
default=None,
help="Browser to pull cookies from (firefox / chrome / safari / …).",
)
p.add_argument("--verbose", action="store_true")
p.set_defaults(func=_handle_comments)
def _add_ytdlp_version(sub: argparse._SubParsersAction) -> None:
"""Register the ``ytdlp-version`` subcommand: print installed yt-dlp version; warn if stale.
Parameters
----------
sub : argparse._SubParsersAction
The subparsers object to attach this subcommand to.
Returns
-------
None
The parser is mutated in place.
"""
p = sub.add_parser("ytdlp-version", help="Print installed yt-dlp version; warn if stale.")
p.add_argument(
"--min-version",
dest="min_version",
default=None,
help="Emit a warning to stderr if installed yt-dlp is older than this.",
)
p.set_defaults(func=_handle_ytdlp_version)
[docs]
def build_parser() -> argparse.ArgumentParser:
"""
Assemble the top-level ``youtube-helper`` argument parser.
Returns
-------
argparse.ArgumentParser
Fully wired parser with every subcommand attached.
"""
parser = argparse.ArgumentParser(
prog="youtube-helper",
description=(
"YouTube Helper — yt-dlp-backed CLI for downloading video / audio / "
"thumbnails, resolving direct media URLs, browsing stream catalogs, "
"and pulling no-API engagement metadata across YouTube, Vimeo, "
"DailyMotion, Twitch VOD, SoundCloud, and any yt-dlp-supported source."
),
)
# Every non-trivial CLI benefits from `--version`. Resolve it lazily to
# avoid any issue with importlib.metadata edge cases.
try:
from importlib.metadata import version as _pkg_version
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {_pkg_version('youtube-helper')}",
)
except Exception: # pragma: no cover — never fatal
pass
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
subparsers.required = True
# Register every subcommand. Order matters for help output only.
_add_metadata(subparsers)
_add_valid(subparsers)
_add_video(subparsers)
_add_audio(subparsers)
_add_thumbnail(subparsers)
_add_resolve(subparsers)
_add_list_streams(subparsers)
_add_pick_stream(subparsers)
_add_channel_info(subparsers)
_add_channel_videos(subparsers)
_add_engagement(subparsers)
_add_engagement_batch(subparsers)
_add_subtitles(subparsers)
_add_comments(subparsers)
_add_ytdlp_version(subparsers)
return parser
[docs]
def main(argv: Sequence[str] | None = None) -> int:
"""
Entry point invoked by ``youtube-helper`` (see ``[project.scripts]``).
Parameters
----------
argv : sequence of str, optional
Arguments to parse. Defaults to ``sys.argv[1:]`` when None.
Returns
-------
int
Process exit code (``0`` on success).
"""
parser = build_parser()
args = parser.parse_args(argv)
# Every subparser sets ``func`` via ``set_defaults`` — no dispatch table
# needed, argparse resolved it for us.
return int(args.func(args))
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())