import logging import platform import re import sys from pathlib import Path from typing import Optional, Tuple import click from twitchdl import __version__ from twitchdl.entities import DownloadOptions from twitchdl.naming import DEFAULT_OUTPUT_TEMPLATE from twitchdl.twitch import ClipsPeriod, VideosSort, VideosType # Tweak the Click context # https://click.palletsprojects.com/en/8.1.x/api/#context CONTEXT = dict( # Enable using environment variables to set options auto_envvar_prefix="TWITCH_DL", # Add shorthand -h for invoking help help_option_names=["-h", "--help"], # Always show default values for options show_default=True, # Make help a bit wider max_content_width=100, ) json_option = click.option( "--json", is_flag=True, default=False, help="Print data as JSON rather than human readable text", ) def validate_positive(_ctx: click.Context, _param: click.Parameter, value: Optional[int]): if value is not None and value <= 0: raise click.BadParameter("must be greater than 0") return value def validate_time(_ctx: click.Context, _param: click.Parameter, value: str) -> Optional[int]: """Parse a time string (hh:mm or hh:mm:ss) to number of seconds.""" if not value: return None parts = [int(p) for p in value.split(":")] if not 2 <= len(parts) <= 3: raise click.BadParameter("invalid time") hours = parts[0] minutes = parts[1] seconds = parts[2] if len(parts) > 2 else 0 if hours < 0 or not (0 <= minutes <= 59) or not (0 <= seconds <= 59): raise click.BadParameter("invalid time") return hours * 3600 + minutes * 60 + seconds def validate_rate(_ctx: click.Context, _param: click.Parameter, value: str) -> Optional[int]: if not value: return None match = re.search(r"^([0-9]+)(k|m|)$", value, flags=re.IGNORECASE) if not match: raise click.BadParameter("must be an integer, followed by an optional 'k' or 'm'") amount = int(match.group(1)) unit = match.group(2) if unit == "k": return amount * 1024 if unit == "m": return amount * 1024 * 1024 return amount @click.group(context_settings=CONTEXT) @click.option("--debug/--no-debug", default=False, help="Enable debug logging to stderr") @click.option("--verbose/--no-verbose", default=False, help="More verbose debug logging") @click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output") @click.version_option(package_name="twitch-dl") @click.pass_context def cli(ctx: click.Context, color: bool, debug: bool, verbose: bool): """twitch-dl - twitch.tv downloader https://twitch-dl.bezdomni.net/ """ ctx.color = color if debug: logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO) logging.getLogger("httpx").setLevel(logging.WARN) logging.getLogger("httpcore").setLevel(logging.WARN) @cli.command() @click.argument("channel_name") @click.option( "-a", "--all", help="Fetch all clips, overrides --limit", is_flag=True, ) @click.option( "-c", "--compact", help="Show clips in compact mode, one line per video", is_flag=True, ) @click.option( "-d", "--download", help="Download clips in given period (in source quality)", is_flag=True, ) @click.option( "-l", "--limit", help="Number of clips to fetch. Defaults to 40 in compact mode, 10 otherwise.", type=int, callback=validate_positive, ) @click.option( "-p", "--pager", help="Number of clips to show per page. Disabled by default.", type=int, callback=validate_positive, is_flag=False, flag_value=10, ) @click.option( "-P", "--period", help="Period from which to return clips", default="all_time", type=click.Choice(["last_day", "last_week", "last_month", "all_time"]), ) @click.option( "-t", "--target-dir", help="Target directory when downloading clips", type=click.Path( file_okay=False, readable=False, writable=True, path_type=Path, ), default=Path(), ) @json_option def clips( channel_name: str, all: bool, compact: bool, download: bool, json: bool, limit: Optional[int], pager: Optional[int], period: ClipsPeriod, target_dir: Path, ): """List or download clips for given CHANNEL_NAME.""" from twitchdl.commands.clips import clips if not target_dir.exists(): target_dir.mkdir(parents=True, exist_ok=True) clips( channel_name, all=all, compact=compact, download=download, json=json, limit=limit, pager=pager, period=period, target_dir=target_dir, ) @cli.command() @click.argument("ids", nargs=-1) @click.option( "-a", "--auth-token", help="""Authentication token, passed to Twitch to access subscriber only VODs. Can be copied from the `auth_token` cookie in any browser logged in on Twitch.""", ) @click.option( "-c", "--chapter", help="""Download a single chapter of the video. Specify the chapter number or use the flag without a number to display a chapter select prompt. """, type=int, is_flag=False, flag_value=0, ) @click.option( "--concat", is_flag=True, help="""Do not use ffmpeg to join files, concat them instead. This will produce a .ts file by default.""", ) @click.option( "-d", "--dry-run", help="Simulate the download provcess without actually downloading any files.", is_flag=True, ) @click.option( "-e", "--end", help="Download video up to this time (hh:mm or hh:mm:ss)", callback=validate_time, ) @click.option( "-f", "--format", help="""Video format to convert into, passed to ffmpeg as the target file extension. Defaults to `mkv`. If `--concat` is passed, defaults to `ts`.""", ) @click.option( "-k", "--keep", help="Don't delete downloaded VODs and playlists after merging.", is_flag=True, ) @click.option( "--no-join", help="Don't run ffmpeg to join the downloaded vods, implies --keep.", is_flag=True, ) @click.option( "--overwrite", help="Overwrite the target file if it already exists without prompting.", is_flag=True, ) @click.option( "-o", "--output", help="Output file name template. See docs for details.", default=DEFAULT_OUTPUT_TEMPLATE, ) @click.option( "-q", "--quality", help="Video quality, e.g. `720p`. Set to `source` to get best quality.", ) @click.option( "-r", "--rate-limit", help="""Limit the maximum download speed in bytes per second. Use 'k' and 'm' suffixes for kbps and mbps.""", callback=validate_rate, ) @click.option( "-s", "--start", help="Download video from this time (hh:mm or hh:mm:ss)", callback=validate_time, ) @click.option( "-w", "--max-workers", help="Number of workers for downloading vods concurrently", type=int, default=5, ) def download( ids: Tuple[str, ...], auth_token: Optional[str], chapter: Optional[int], concat: bool, dry_run: bool, end: Optional[int], format: str, keep: bool, no_join: bool, overwrite: bool, output: str, quality: Optional[str], rate_limit: Optional[int], start: Optional[int], max_workers: int, ): """Download videos or clips. Pass one or more video ID, clip slug or Twitch URL to download. """ from twitchdl.commands.download import download if not format: format = "ts" if concat else "mkv" options = DownloadOptions( auth_token=auth_token, chapter=chapter, concat=concat, dry_run=dry_run, end=end, format=format, keep=keep, no_join=no_join, overwrite=overwrite, output=output, quality=quality, rate_limit=rate_limit, start=start, max_workers=max_workers, ) download(list(ids), options) @cli.command() def env(): """Print environment information for inclusion in bug reports.""" click.echo(f"twitch-dl {__version__}") click.echo(f"Python {sys.version}") click.echo(f"Platform: {platform.platform()}") @cli.command() @click.argument("id") @json_option def info(id: str, json: bool): """Print information for a given Twitch URL, video ID or clip slug.""" from twitchdl.commands.info import info info(id, json=json) @cli.command() @click.argument("channel_name") @click.option( "-a", "--all", help="Fetch all clips, overrides --limit", is_flag=True, ) @click.option( "-c", "--compact", help="Show videos in compact mode, one line per video", is_flag=True, ) @click.option( "-l", "--limit", help="Number of videos to fetch. Defaults to 40 in compact mode, 10 otherwise.", type=int, callback=validate_positive, ) @click.option( "-p", "--pager", help="Number of videos to show per page. Disabled by default.", type=int, callback=validate_positive, is_flag=False, flag_value=10, ) @click.option( "-g", "--game", "games_tuple", help="Show videos of given game (can be given multiple times)", multiple=True, ) @click.option( "-s", "--sort", help="Sorting order of videos", default="time", type=click.Choice(["views", "time"]), ) @click.option( "-t", "--type", help="Broadcast type", default="archive", type=click.Choice(["archive", "highlight", "upload"]), ) @json_option def videos( channel_name: str, all: bool, compact: bool, games_tuple: Tuple[str, ...], json: bool, limit: Optional[int], pager: Optional[int], sort: VideosSort, type: VideosType, ): """List or download clips for given CHANNEL_NAME.""" from twitchdl.commands.videos import videos # Click provides a tuple, make it a list instead games = list(games_tuple) videos( channel_name, all=all, compact=compact, games=games, json=json, limit=limit, pager=pager, sort=sort, type=type, )