Source code for youtube_helper.cli

"""
YouTube Helper — the command line.

``youtube-helper <subcommand>``: download a video, its audio track or its
thumbnail, resolve a page URL to a direct ffmpeg-ready media URL, browse or
pick among the stream formats yt-dlp reports, and pull public engagement
metadata (channel, video, comments, subtitles) without any platform API key.
Works on YouTube, Vimeo, DailyMotion, Twitch VOD, SoundCloud, and every other
site yt-dlp supports.

Design notes
------------
- One command line, built with :mod:`click`. There is no second parser:
  the subcommand names and flag names here are the contract, and the
  FastAPI surface (:mod:`youtube_helper.api`) mirrors them one-to-one.
- Flags are named (``--url`` / ``--output`` / …) rather than positional,
  so a long download command stays readable and the HTTP surface can
  reuse the same parameter names.
- 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 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 resolve      --url https://www.youtube.com/watch?v=YE7VzlLtp-4 --prefer audio
>>> #   youtube-helper 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

import click

# The same functions the library exposes — the CLI is a thin shell over them,
# never a second implementation.
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},
)
# ``-v`` is already ``--verbose`` on most subcommands, hence the capital ``-V``.
# The version comes from the installed distribution metadata: the package
# defines no ``__version__``, so pyproject.toml stays the single source.
@click.version_option(None, "-V", "--version", package_name="youtube-helper")
def cli() -> None:
    """YouTube Helper — download and inspect media from yt-dlp-supported sites.

    Downloads video, audio or thumbnails, resolves a page URL to a direct
    ffmpeg-ready media URL, browses the stream catalog, and pulls no-API
    engagement metadata on YouTube, Vimeo, DailyMotion, Twitch VOD,
    SoundCloud and anything else yt-dlp handles.
    """
    # 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).")
@click.option(
    "--max-height",
    type=int,
    default=None,
    help="Cap the video height, e.g. 720. Omit for best available (or set YOUTUBE_HELPER_MAX_HEIGHT). A cap also prefers direct https formats over the HLS ones YouTube throttles, so it is usually faster.",
)
def video(url: str, output: str | None, max_height: int | None) -> None:
    """Download the video (best video + audio) to disk."""
    click.echo(download_video(url=url, output_path=output, max_height=max_height))


# ---------------------------------------------------------------------------
# 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'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()