2024-03-23 09:50:42 +00:00
|
|
|
import logging
|
|
|
|
import platform
|
|
|
|
import re
|
|
|
|
import sys
|
2024-08-28 11:12:11 +00:00
|
|
|
from pathlib import Path
|
2024-04-24 06:10:56 +00:00
|
|
|
from typing import Optional, Tuple
|
2024-03-23 09:50:42 +00:00
|
|
|
|
2024-04-06 08:15:26 +00:00
|
|
|
import click
|
|
|
|
|
2024-03-23 09:50:42 +00:00
|
|
|
from twitchdl import __version__
|
|
|
|
from twitchdl.entities import DownloadOptions
|
2024-08-28 08:32:40 +00:00
|
|
|
from twitchdl.naming import DEFAULT_OUTPUT_TEMPLATE
|
2024-04-01 07:40:54 +00:00
|
|
|
from twitchdl.twitch import ClipsPeriod, VideosSort, VideosType
|
2024-03-23 09:50:42 +00:00
|
|
|
|
|
|
|
# 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",
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2024-04-23 15:14:27 +00:00
|
|
|
def validate_positive(_ctx: click.Context, _param: click.Parameter, value: Optional[int]):
|
2024-03-23 09:50:42 +00:00
|
|
|
if value is not None and value <= 0:
|
|
|
|
raise click.BadParameter("must be greater than 0")
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
2024-04-23 15:14:27 +00:00
|
|
|
def validate_time(_ctx: click.Context, _param: click.Parameter, value: str) -> Optional[int]:
|
2024-03-23 09:50:42 +00:00
|
|
|
"""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
|
|
|
|
|
|
|
|
|
2024-04-23 15:14:27 +00:00
|
|
|
def validate_rate(_ctx: click.Context, _param: click.Parameter, value: str) -> Optional[int]:
|
2024-03-23 09:50:42 +00:00
|
|
|
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)
|
2024-08-28 10:58:36 +00:00
|
|
|
@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")
|
2024-03-23 09:50:42 +00:00
|
|
|
@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
|
2024-08-28 10:58:36 +00:00
|
|
|
def cli(ctx: click.Context, color: bool, debug: bool, verbose: bool):
|
2024-03-23 09:50:42 +00:00
|
|
|
"""twitch-dl - twitch.tv downloader
|
|
|
|
|
2024-04-10 06:25:03 +00:00
|
|
|
https://twitch-dl.bezdomni.net/
|
2024-03-23 09:50:42 +00:00
|
|
|
"""
|
|
|
|
ctx.color = color
|
|
|
|
|
|
|
|
if debug:
|
2024-08-28 10:58:36 +00:00
|
|
|
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO)
|
2024-04-24 06:41:11 +00:00
|
|
|
logging.getLogger("httpx").setLevel(logging.WARN)
|
|
|
|
logging.getLogger("httpcore").setLevel(logging.WARN)
|
2024-03-23 09:50:42 +00:00
|
|
|
|
|
|
|
|
|
|
|
@cli.command()
|
|
|
|
@click.argument("channel_name")
|
|
|
|
@click.option(
|
|
|
|
"-a",
|
|
|
|
"--all",
|
|
|
|
help="Fetch all clips, overrides --limit",
|
|
|
|
is_flag=True,
|
|
|
|
)
|
2024-04-06 08:43:12 +00:00
|
|
|
@click.option(
|
|
|
|
"-c",
|
|
|
|
"--compact",
|
|
|
|
help="Show clips in compact mode, one line per video",
|
|
|
|
is_flag=True,
|
|
|
|
)
|
2024-03-23 09:50:42 +00:00
|
|
|
@click.option(
|
|
|
|
"-d",
|
|
|
|
"--download",
|
|
|
|
help="Download clips in given period (in source quality)",
|
|
|
|
is_flag=True,
|
|
|
|
)
|
|
|
|
@click.option(
|
|
|
|
"-l",
|
|
|
|
"--limit",
|
2024-04-06 08:43:12 +00:00
|
|
|
help="Number of clips to fetch. Defaults to 40 in compact mode, 10 otherwise.",
|
2024-03-23 09:50:42 +00:00
|
|
|
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"]),
|
|
|
|
)
|
2024-08-28 11:12:11 +00:00
|
|
|
@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(),
|
|
|
|
)
|
2024-03-23 09:50:42 +00:00
|
|
|
@json_option
|
|
|
|
def clips(
|
|
|
|
channel_name: str,
|
|
|
|
all: bool,
|
2024-04-06 08:43:12 +00:00
|
|
|
compact: bool,
|
2024-03-23 09:50:42 +00:00
|
|
|
download: bool,
|
|
|
|
json: bool,
|
2024-04-23 15:14:27 +00:00
|
|
|
limit: Optional[int],
|
|
|
|
pager: Optional[int],
|
2024-03-23 09:50:42 +00:00
|
|
|
period: ClipsPeriod,
|
2024-08-28 11:12:11 +00:00
|
|
|
target_dir: Path,
|
2024-03-23 09:50:42 +00:00
|
|
|
):
|
|
|
|
"""List or download clips for given CHANNEL_NAME."""
|
|
|
|
from twitchdl.commands.clips import clips
|
|
|
|
|
2024-08-28 11:12:11 +00:00
|
|
|
if not target_dir.exists():
|
|
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
2024-03-23 09:50:42 +00:00
|
|
|
clips(
|
|
|
|
channel_name,
|
|
|
|
all=all,
|
2024-04-06 08:43:12 +00:00
|
|
|
compact=compact,
|
2024-03-23 09:50:42 +00:00
|
|
|
download=download,
|
|
|
|
json=json,
|
|
|
|
limit=limit,
|
|
|
|
pager=pager,
|
|
|
|
period=period,
|
2024-08-28 11:12:11 +00:00
|
|
|
target_dir=target_dir,
|
2024-03-23 09:50:42 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@cli.command()
|
|
|
|
@click.argument("ids", nargs=-1)
|
|
|
|
@click.option(
|
|
|
|
"-a",
|
|
|
|
"--auth-token",
|
|
|
|
help="""Authentication token, passed to Twitch to access subscriber only
|
2024-03-26 09:04:46 +00:00
|
|
|
VODs. Can be copied from the `auth_token` cookie in any browser logged
|
2024-03-23 09:50:42 +00:00
|
|
|
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,
|
|
|
|
)
|
2024-03-29 07:24:45 +00:00
|
|
|
@click.option(
|
|
|
|
"--concat",
|
|
|
|
is_flag=True,
|
2024-03-29 07:43:53 +00:00
|
|
|
help="""Do not use ffmpeg to join files, concat them instead. This will
|
|
|
|
produce a .ts file by default.""",
|
2024-03-29 07:24:45 +00:00
|
|
|
)
|
2024-03-23 09:50:42 +00:00
|
|
|
@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",
|
2024-03-29 07:24:45 +00:00
|
|
|
help="""Video format to convert into, passed to ffmpeg as the target file
|
|
|
|
extension. Defaults to `mkv`. If `--concat` is passed, defaults to
|
|
|
|
`ts`.""",
|
2024-03-23 09:50:42 +00:00
|
|
|
)
|
|
|
|
@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.",
|
2024-08-28 08:32:40 +00:00
|
|
|
default=DEFAULT_OUTPUT_TEMPLATE,
|
2024-03-23 09:50:42 +00:00
|
|
|
)
|
|
|
|
@click.option(
|
|
|
|
"-q",
|
|
|
|
"--quality",
|
2024-03-26 09:04:46 +00:00
|
|
|
help="Video quality, e.g. `720p`. Set to `source` to get best quality.",
|
2024-03-23 09:50:42 +00:00
|
|
|
)
|
|
|
|
@click.option(
|
|
|
|
"-r",
|
|
|
|
"--rate-limit",
|
2024-03-29 07:43:53 +00:00
|
|
|
help="""Limit the maximum download speed in bytes per second. Use 'k' and
|
|
|
|
'm' suffixes for kbps and mbps.""",
|
2024-03-23 09:50:42 +00:00
|
|
|
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(
|
2024-04-24 06:10:56 +00:00
|
|
|
ids: Tuple[str, ...],
|
2024-04-23 15:14:27 +00:00
|
|
|
auth_token: Optional[str],
|
|
|
|
chapter: Optional[int],
|
2024-03-29 07:24:45 +00:00
|
|
|
concat: bool,
|
2024-03-23 09:50:42 +00:00
|
|
|
dry_run: bool,
|
2024-04-23 15:14:27 +00:00
|
|
|
end: Optional[int],
|
2024-03-23 09:50:42 +00:00
|
|
|
format: str,
|
|
|
|
keep: bool,
|
|
|
|
no_join: bool,
|
|
|
|
overwrite: bool,
|
|
|
|
output: str,
|
2024-04-23 15:14:27 +00:00
|
|
|
quality: Optional[str],
|
|
|
|
rate_limit: Optional[int],
|
|
|
|
start: Optional[int],
|
2024-03-23 09:50:42 +00:00
|
|
|
max_workers: int,
|
|
|
|
):
|
|
|
|
"""Download videos or clips.
|
|
|
|
|
|
|
|
Pass one or more video ID, clip slug or Twitch URL to download.
|
|
|
|
"""
|
2024-03-29 07:24:45 +00:00
|
|
|
from twitchdl.commands.download import download
|
|
|
|
|
|
|
|
if not format:
|
|
|
|
format = "ts" if concat else "mkv"
|
2024-03-23 09:50:42 +00:00
|
|
|
|
|
|
|
options = DownloadOptions(
|
|
|
|
auth_token=auth_token,
|
|
|
|
chapter=chapter,
|
2024-03-29 07:24:45 +00:00
|
|
|
concat=concat,
|
2024-03-23 09:50:42 +00:00
|
|
|
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,
|
|
|
|
)
|
|
|
|
|
2024-03-29 07:24:45 +00:00
|
|
|
download(list(ids), options)
|
2024-03-23 09:50:42 +00:00
|
|
|
|
|
|
|
|
|
|
|
@cli.command()
|
|
|
|
def env():
|
|
|
|
"""Print environment information for inclusion in bug reports."""
|
2024-03-26 09:23:35 +00:00
|
|
|
click.echo(f"twitch-dl {__version__}")
|
2024-03-23 09:50:42 +00:00
|
|
|
click.echo(f"Python {sys.version}")
|
2024-03-26 09:23:35 +00:00
|
|
|
click.echo(f"Platform: {platform.platform()}")
|
2024-03-23 09:50:42 +00:00
|
|
|
|
|
|
|
|
|
|
|
@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,
|
2024-04-24 06:10:56 +00:00
|
|
|
games_tuple: Tuple[str, ...],
|
2024-03-23 09:50:42 +00:00
|
|
|
json: bool,
|
2024-04-23 15:14:27 +00:00
|
|
|
limit: Optional[int],
|
|
|
|
pager: Optional[int],
|
2024-04-01 07:40:54 +00:00
|
|
|
sort: VideosSort,
|
|
|
|
type: VideosType,
|
2024-03-23 09:50:42 +00:00
|
|
|
):
|
|
|
|
"""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,
|
|
|
|
)
|