"""
YouTube Helper — click-based command-line interface.
Twin of :mod:`youtube_helper.cli_argparse`: same public surface (identical
subcommand names, identical flag semantics), but implemented with
:mod:`click` so users who already have a click-native shell setup
(bash / zsh completion via ``click.shell_completion``, colored `--help`,
nested command groups) can plug it in without friction. Installed as
the ``youtube-helper-click`` entry point in ``pyproject.toml``.
Design notes
------------
- Subcommands mirror ``youtube-helper`` (the argparse twin) so both CLIs
can be introspected identically by higher layers (FastAPI).
- Flags reuse the argparse names (``--url`` / ``--output`` / …) rather
than the more idiomatic click positional style — consistency across
the two CLIs beats micro-idiomaticity here.
- A library exception is caught by the ``main()`` entry point (not
click's own error handling, which only special-cases
``ClickException``/``Abort``) and printed as a clean ``Error: ...``
line + exit 1, instead of a raw Python traceback.
Usage Example
-------------
>>> # youtube-helper-click metadata --url https://www.youtube.com/watch?v=YE7VzlLtp-4
>>> # youtube-helper-click audio --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --output out.mp3
>>> # youtube-helper-click resolve --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --prefer audio
>>> # youtube-helper-click channel-info --url https://www.youtube.com/@blender
Author
------
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
"""
from __future__ import annotations
import json
import sys
try:
import click
except ImportError as exc: # pragma: no cover
raise ImportError(
"The click CLI requires the [cli] extra. Install with: pip install 'youtube-helper[cli]'"
) from exc
# Same underlying functions as the argparse twin — one source of truth.
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,
)
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)
# ---------------------------------------------------------------------------
# Top-level group
# ---------------------------------------------------------------------------
@click.group(
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
)
@click.version_option(package_name="youtube-helper", prog_name="youtube-helper-click")
def cli() -> None:
"""YouTube Helper — click twin of the argparse CLI. Same subcommands."""
# Nothing to do at the group level — every subcommand carries its
# own arguments and side effects.
# ---------------------------------------------------------------------------
# metadata
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--url", required=True, help="Video URL.")
def metadata(url: str) -> None:
"""Dump yt-dlp metadata for a video URL as JSON."""
click.echo(_dumps(video_url_meta_data(url)))
# ---------------------------------------------------------------------------
# valid
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--url", required=True, help="Video URL.")
def valid(url: str) -> None:
"""Check whether a URL is a valid video URL for yt-dlp (exit code encodes result)."""
ok = is_valid_video_url(url)
click.echo("true" if ok else "false")
if not ok:
raise click.exceptions.Exit(1)
# ---------------------------------------------------------------------------
# video
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--url", required=True, help="Video URL.")
@click.option("--output", type=click.Path(), default=None, help="Output path (auto if omitted).")
def video(url: str, output: str | None) -> None:
"""Download the video (best video + audio) to disk."""
click.echo(download_video(url=url, output_path=output))
# ---------------------------------------------------------------------------
# audio
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--url", required=True, help="Video URL.")
@click.option("--output", type=click.Path(), default=None, help="Output path (auto if omitted).")
@click.option(
"--sample-rate",
type=int,
default=44100,
show_default=True,
help="Target sample rate for the output audio.",
)
def audio(url: str, output: str | None, sample_rate: int) -> None:
"""Download only the audio track to disk."""
click.echo(download_audio(url=url, output_path=output, target_sample_rate=sample_rate))
# ---------------------------------------------------------------------------
# thumbnail
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--url", required=True, help="Video URL.")
@click.option("--output", type=click.Path(), default=None, help="Output path (auto if omitted).")
def thumbnail(url: str, output: str | None) -> None:
"""Download the video's thumbnail to disk."""
click.echo(download_thumbnail(url=url, output_path=output))
# ---------------------------------------------------------------------------
# resolve
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--url", required=True, help="Video URL.")
@click.option("--prefer", type=click.Choice(["audio", "video"]), default="audio", show_default=True)
@click.option(
"--live",
type=click.Choice(["auto", "force_live", "force_vod"]),
default="auto",
show_default=True,
)
def resolve(url: str, prefer: str, live: str) -> None:
"""Resolve a URL to a direct ffmpeg-ready media URL (audio or video)."""
click.echo(_dumps(resolve_direct_url(url=url, prefer=prefer, live=live)))
# ---------------------------------------------------------------------------
# list-streams
# ---------------------------------------------------------------------------
@cli.command("list-streams")
@click.option("--url", required=True, help="Video URL.")
@click.option("--include-video-only/--no-video-only", default=True, show_default=True)
@click.option("--include-combined/--no-combined", default=True, show_default=True)
@click.option(
"--cookies-from-browser",
default=None,
help="Browser to pull cookies from (firefox / chrome / safari / …).",
)
@click.option("--verbose", is_flag=True, default=False)
def list_streams(
url: str,
include_video_only: bool,
include_combined: bool,
cookies_from_browser: str | None,
verbose: bool,
) -> None:
"""List every video format yt-dlp finds for a URL."""
click.echo(
_dumps(
list_video_streams(
url=url,
include_video_only=include_video_only,
include_combined=include_combined,
cookies_from_browser=cookies_from_browser,
verbose=verbose,
)
)
)
# ---------------------------------------------------------------------------
# pick-stream
# ---------------------------------------------------------------------------
@cli.command("pick-stream")
@click.option("--url", required=True, help="Video URL.")
@click.option("--prefer-codec", default=None, help="Substring match on vcodec (h264 / vp9 / av1).")
@click.option("--prefer-format", default=None, help="Equality match on ext (mp4 / webm).")
@click.option("--max-fps", type=float, default=None, help="Drop formats with fps > max-fps.")
@click.option("--language", default=None, help="Equality match on language code.")
@click.option("--include-video-only/--no-video-only", default=True, show_default=True)
@click.option("--include-combined/--no-combined", default=True, show_default=True)
@click.option("--cookies-from-browser", default=None)
@click.option("--verbose", is_flag=True, default=False)
def pick_stream(
url: str,
prefer_codec: str | None,
prefer_format: str | None,
max_fps: float | None,
language: str | None,
include_video_only: bool,
include_combined: bool,
cookies_from_browser: str | None,
verbose: bool,
) -> None:
"""Pick one best video stream matching the given constraints."""
click.echo(
_dumps(
pick_video_stream(
url=url,
prefer_codec=prefer_codec,
prefer_format=prefer_format,
max_fps=max_fps,
language=language,
include_video_only=include_video_only,
include_combined=include_combined,
cookies_from_browser=cookies_from_browser,
verbose=verbose,
)
)
)
# ---------------------------------------------------------------------------
# channel-info
# ---------------------------------------------------------------------------
@cli.command("channel-info")
@click.option("--url", required=True, help="Channel / user page URL.")
@click.option("--verbose", is_flag=True, default=False)
def channel_info_cmd(url: str, verbose: bool) -> None:
"""Channel-level snapshot (subs, total views, video count)."""
click.echo(_dumps(channel_info(url=url, verbose=verbose)))
# ---------------------------------------------------------------------------
# channel-videos
# ---------------------------------------------------------------------------
@cli.command("channel-videos")
@click.option("--url", required=True, help="Channel / user page URL.")
@click.option("--max-videos", type=int, default=200, show_default=True)
@click.option("--include-shorts/--no-shorts", default=True, show_default=True)
@click.option("--include-lives/--no-lives", default=False, show_default=True)
@click.option("--verbose", is_flag=True, default=False)
def channel_videos_cmd(
url: str,
max_videos: int,
include_shorts: bool,
include_lives: bool,
verbose: bool,
) -> None:
"""List channel videos with normalised engagement metadata."""
click.echo(
_dumps(
channel_videos(
url=url,
max_videos=max_videos,
include_shorts=include_shorts,
include_lives=include_lives,
verbose=verbose,
)
)
)
# ---------------------------------------------------------------------------
# engagement
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--url", required=True, help="Video URL.")
@click.option("--verbose", is_flag=True, default=False)
def engagement(url: str, verbose: bool) -> None:
"""Single-video engagement snapshot."""
click.echo(_dumps(video_engagement(url=url, verbose=verbose)))
# ---------------------------------------------------------------------------
# engagement-batch
# ---------------------------------------------------------------------------
@cli.command("engagement-batch")
@click.option("--urls", multiple=True, required=True, help="Video URL (repeat --urls for each).")
@click.option("--verbose", is_flag=True, default=False)
def engagement_batch_cmd(urls: tuple[str, ...], verbose: bool) -> None:
"""Multi-URL engagement snapshot (tolerant of dead entries)."""
click.echo(_dumps(engagement_batch(urls=list(urls), verbose=verbose)))
# ---------------------------------------------------------------------------
# subtitles
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--url", required=True, help="Video URL.")
@click.option(
"--output-dir", required=True, type=click.Path(), help="Folder that receives the .vtt files."
)
@click.option(
"--langs",
multiple=True,
default=("fr", "en"),
show_default=True,
help="Language code (repeat --langs for each).",
)
@click.option(
"--auto-only/--manual",
default=True,
show_default=True,
help="Prefer auto-generated subtitles (default) or manual ones.",
)
@click.option("--verbose", is_flag=True, default=False)
def subtitles(
url: str, output_dir: str, langs: tuple[str, ...], auto_only: bool, verbose: bool
) -> None:
"""Download subtitles (auto or manual) for a video into a folder."""
click.echo(
_dumps(
video_subtitles(
url=url,
output_dir=output_dir,
langs=tuple(langs),
auto_only=auto_only,
verbose=verbose,
)
)
)
# ---------------------------------------------------------------------------
# comments
# ---------------------------------------------------------------------------
@cli.command()
@click.option("--url", required=True, help="Video URL.")
@click.option("--max", "max_count", type=int, default=100, show_default=True)
@click.option("--cookies-from-browser", default=None)
@click.option("--verbose", is_flag=True, default=False)
def comments(url: str, max_count: int, cookies_from_browser: str | None, verbose: bool) -> None:
"""Fetch top comments for a video."""
click.echo(
_dumps(
video_comments(
url=url,
max_count=max_count,
cookies_from_browser=cookies_from_browser,
verbose=verbose,
)
)
)
# ---------------------------------------------------------------------------
# ytdlp-version
# ---------------------------------------------------------------------------
@cli.command("ytdlp-version")
@click.option(
"--min-version",
default=None,
help="Emit a warning to stderr if installed yt-dlp is older than this.",
)
def ytdlp_version(min_version: str | None) -> None:
"""Print installed yt-dlp version; warn if stale."""
click.echo(ensure_recent_ytdlp(min_version=min_version))
[docs]
def main() -> None:
"""Console entry point (``youtube-helper-click``).
click's own ``main()`` only special-cases ``ClickException``/``Abort``
(and a broken pipe); a plain library exception (e.g. ``AssertionError``
from an invalid URL) would otherwise propagate as a raw Python
traceback instead of a clean CLI error. This wraps the whole
invocation and translates that last case into a one-line stderr
message + exit 1 — click's own control flow (usage errors, ``--help``,
an explicit ``sys.exit(1)`` in a subcommand) already raises
``SystemExit``, a ``BaseException`` this does not catch, so it passes
through untouched.
"""
try:
cli()
except Exception as err: # noqa: BLE001 — last resort: see docstring
click.echo(f"Error: {err}", err=True)
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()