mirror of
https://github.com/ihabunek/twitch-dl
synced 2024-08-30 18:32:25 +00:00
Convert to click
This commit is contained in:
parent
229a849f87
commit
b0c21ac436
@ -48,7 +48,7 @@ dev = [
|
||||
"Source" = "https://github.com/ihabunek/twitch-dl"
|
||||
|
||||
[project.scripts]
|
||||
twitch-dl = "twitchdl.console:main"
|
||||
twitch-dl = "twitchdl.cli:cli"
|
||||
|
||||
[tool.pyright]
|
||||
include = ["twitchdl"]
|
||||
|
@ -1,3 +1,3 @@
|
||||
from twitchdl.console import main
|
||||
from twitchdl.cli import cli
|
||||
|
||||
main()
|
||||
cli()
|
||||
|
378
twitchdl/cli.py
Normal file
378
twitchdl/cli.py
Normal file
@ -0,0 +1,378 @@
|
||||
import click
|
||||
import logging
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
|
||||
from twitchdl import __version__
|
||||
from twitchdl.commands.clips import ClipsPeriod
|
||||
from twitchdl.entities import DownloadOptions
|
||||
|
||||
# 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: int | None):
|
||||
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) -> int | None:
|
||||
"""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) -> int | None:
|
||||
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="Log debug info to stderr")
|
||||
@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):
|
||||
"""twitch-dl - twitch.tv downloader
|
||||
|
||||
https://toot.bezdomni.net/
|
||||
"""
|
||||
ctx.color = color
|
||||
|
||||
if debug:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("channel_name")
|
||||
@click.option(
|
||||
"-a",
|
||||
"--all",
|
||||
help="Fetch all clips, overrides --limit",
|
||||
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 [max: 100]",
|
||||
type=int,
|
||||
default=10,
|
||||
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"]),
|
||||
)
|
||||
@json_option
|
||||
def clips(
|
||||
channel_name: str,
|
||||
all: bool,
|
||||
download: bool,
|
||||
json: bool,
|
||||
limit: int,
|
||||
pager: int | None,
|
||||
period: ClipsPeriod,
|
||||
):
|
||||
"""List or download clips for given CHANNEL_NAME."""
|
||||
from twitchdl.commands.clips import clips
|
||||
|
||||
clips(
|
||||
channel_name,
|
||||
all=all,
|
||||
download=download,
|
||||
json=json,
|
||||
limit=limit,
|
||||
pager=pager,
|
||||
period=period,
|
||||
)
|
||||
|
||||
|
||||
@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(
|
||||
"-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.",
|
||||
default="mkv",
|
||||
)
|
||||
@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="{date}_{id}_{channel_login}_{title_slug}.{format}",
|
||||
)
|
||||
@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: str | None,
|
||||
chapter: int | None,
|
||||
dry_run: bool,
|
||||
end: int | None,
|
||||
format: str,
|
||||
keep: bool,
|
||||
no_join: bool,
|
||||
overwrite: bool,
|
||||
output: str,
|
||||
quality: str | None,
|
||||
rate_limit: str | None,
|
||||
start: int | None,
|
||||
max_workers: int,
|
||||
):
|
||||
"""Download videos or clips.
|
||||
|
||||
Pass one or more video ID, clip slug or Twitch URL to download.
|
||||
"""
|
||||
|
||||
options = DownloadOptions(
|
||||
auth_token=auth_token,
|
||||
chapter=chapter,
|
||||
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,
|
||||
)
|
||||
|
||||
from twitchdl.commands.download import download
|
||||
download(ids, options)
|
||||
|
||||
|
||||
@cli.command()
|
||||
def env():
|
||||
"""Print environment information for inclusion in bug reports."""
|
||||
click.echo(f"toot {__version__}")
|
||||
click.echo(f"Python {sys.version}")
|
||||
click.echo(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: int | None,
|
||||
pager: int | None,
|
||||
sort: str,
|
||||
type: str,
|
||||
):
|
||||
"""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,
|
||||
)
|
@ -1,13 +0,0 @@
|
||||
from .clips import clips
|
||||
from .download import download
|
||||
from .env import env
|
||||
from .info import info
|
||||
from .videos import videos
|
||||
|
||||
__all__ = [
|
||||
clips,
|
||||
download,
|
||||
env,
|
||||
info,
|
||||
videos,
|
||||
]
|
@ -1,6 +1,7 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
from typing import Literal
|
||||
from itertools import islice
|
||||
from os import path
|
||||
|
||||
@ -9,24 +10,34 @@ from twitchdl.commands.download import get_clip_authenticated_url
|
||||
from twitchdl.download import download_file
|
||||
from twitchdl.output import print_out, print_clip, print_json
|
||||
|
||||
ClipsPeriod = Literal["last_day", "last_week", "last_month", "all_time"]
|
||||
|
||||
def clips(args):
|
||||
|
||||
def clips(
|
||||
channel_name: str,
|
||||
*,
|
||||
all: bool = False,
|
||||
download: bool = False,
|
||||
json: bool = False,
|
||||
limit: int = 10,
|
||||
pager: int | None = None,
|
||||
period: ClipsPeriod = "all_time",
|
||||
):
|
||||
# Ignore --limit if --pager or --all are given
|
||||
limit = sys.maxsize if args.all or args.pager else args.limit
|
||||
limit = sys.maxsize if all or pager else limit
|
||||
|
||||
generator = twitch.channel_clips_generator(args.channel_name, args.period, limit)
|
||||
generator = twitch.channel_clips_generator(channel_name, period, limit)
|
||||
|
||||
if args.json:
|
||||
if json:
|
||||
return print_json(list(generator))
|
||||
|
||||
if args.download:
|
||||
if download:
|
||||
return _download_clips(generator)
|
||||
|
||||
if args.pager:
|
||||
print(args)
|
||||
return _print_paged(generator, args.pager)
|
||||
if pager:
|
||||
return _print_paged(generator, pager)
|
||||
|
||||
return _print_all(generator, args)
|
||||
return _print_all(generator, all)
|
||||
|
||||
|
||||
def _continue():
|
||||
@ -46,6 +57,8 @@ def _target_filename(clip):
|
||||
ext = ext.lstrip(".")
|
||||
|
||||
match = re.search(r"^(\d{4})-(\d{2})-(\d{2})T", clip["createdAt"])
|
||||
if not match:
|
||||
raise ValueError(f"Failed parsing date from: {clip['createdAt']}")
|
||||
date = "".join(match.groups())
|
||||
|
||||
name = "_".join([
|
||||
@ -70,12 +83,12 @@ def _download_clips(generator):
|
||||
download_file(url, target)
|
||||
|
||||
|
||||
def _print_all(generator, args):
|
||||
def _print_all(generator, all: bool):
|
||||
for clip in generator:
|
||||
print_out()
|
||||
print_clip(clip)
|
||||
|
||||
if not args.all:
|
||||
if not all:
|
||||
print_out(
|
||||
"\n<dim>There may be more clips. " +
|
||||
"Increase the --limit, use --all or --pager to see the rest.</dim>"
|
||||
|
@ -14,11 +14,29 @@ from urllib.parse import urlparse, urlencode
|
||||
|
||||
from twitchdl import twitch, utils
|
||||
from twitchdl.download import download_file
|
||||
from twitchdl.entities import DownloadOptions
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.http import download_all
|
||||
from twitchdl.output import print_out
|
||||
|
||||
|
||||
def download(ids, args: DownloadOptions):
|
||||
for video_id in ids:
|
||||
download_one(video_id, args)
|
||||
|
||||
|
||||
def download_one(video: str, args: DownloadOptions):
|
||||
video_id = utils.parse_video_identifier(video)
|
||||
if video_id:
|
||||
return _download_video(video_id, args)
|
||||
|
||||
clip_slug = utils.parse_clip_identifier(video)
|
||||
if clip_slug:
|
||||
return _download_clip(clip_slug, args)
|
||||
|
||||
raise ConsoleError("Invalid input: {}".format(video))
|
||||
|
||||
|
||||
def _parse_playlists(playlists_m3u8):
|
||||
playlists = m3u8.loads(playlists_m3u8)
|
||||
|
||||
@ -82,7 +100,7 @@ def _join_vods(playlist_path, target, overwrite, video):
|
||||
raise ConsoleError("Joining files failed")
|
||||
|
||||
|
||||
def _video_target_filename(video, args):
|
||||
def _video_target_filename(video, args: DownloadOptions):
|
||||
date, time = video['publishedAt'].split("T")
|
||||
game = video["game"]["name"] if video["game"] else "Unknown"
|
||||
|
||||
@ -107,7 +125,7 @@ def _video_target_filename(video, args):
|
||||
raise ConsoleError("Invalid key {} used in --output. Supported keys are: {}".format(e, supported))
|
||||
|
||||
|
||||
def _clip_target_filename(clip, args):
|
||||
def _clip_target_filename(clip, args: DownloadOptions):
|
||||
date, time = clip["createdAt"].split("T")
|
||||
game = clip["game"]["name"] if clip["game"] else "Unknown"
|
||||
|
||||
@ -165,23 +183,6 @@ def _crete_temp_dir(base_uri: str) -> str:
|
||||
return str(temp_dir)
|
||||
|
||||
|
||||
def download(args):
|
||||
for video_id in args.videos:
|
||||
download_one(video_id, args)
|
||||
|
||||
|
||||
def download_one(video: str, args):
|
||||
video_id = utils.parse_video_identifier(video)
|
||||
if video_id:
|
||||
return _download_video(video_id, args)
|
||||
|
||||
clip_slug = utils.parse_clip_identifier(video)
|
||||
if clip_slug:
|
||||
return _download_clip(clip_slug, args)
|
||||
|
||||
raise ConsoleError("Invalid input: {}".format(video))
|
||||
|
||||
|
||||
def _get_clip_url(clip, quality):
|
||||
qualities = clip["videoQualities"]
|
||||
|
||||
@ -227,7 +228,7 @@ def get_clip_authenticated_url(slug, quality):
|
||||
return "{}?{}".format(url, query)
|
||||
|
||||
|
||||
def _download_clip(slug: str, args) -> None:
|
||||
def _download_clip(slug: str, args: DownloadOptions) -> None:
|
||||
print_out("<dim>Looking up clip...</dim>")
|
||||
clip = twitch.get_clip(slug)
|
||||
game = clip["game"]["name"] if clip["game"] else "Unknown"
|
||||
|
@ -5,8 +5,8 @@ from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.output import print_video, print_clip, print_json, print_out, print_log
|
||||
|
||||
|
||||
def info(args):
|
||||
video_id = utils.parse_video_identifier(args.video)
|
||||
def info(id: str, *, json: bool = False):
|
||||
video_id = utils.parse_video_identifier(id)
|
||||
if video_id:
|
||||
print_log("Fetching video...")
|
||||
video = twitch.get_video(video_id)
|
||||
@ -23,26 +23,26 @@ def info(args):
|
||||
print_log("Fetching chapters...")
|
||||
chapters = twitch.get_video_chapters(video_id)
|
||||
|
||||
if args.json:
|
||||
if json:
|
||||
video_json(video, playlists, chapters)
|
||||
else:
|
||||
video_info(video, playlists, chapters)
|
||||
return
|
||||
|
||||
clip_slug = utils.parse_clip_identifier(args.video)
|
||||
clip_slug = utils.parse_clip_identifier(id)
|
||||
if clip_slug:
|
||||
print_log("Fetching clip...")
|
||||
clip = twitch.get_clip(clip_slug)
|
||||
if not clip:
|
||||
raise ConsoleError("Clip {} not found".format(clip_slug))
|
||||
|
||||
if args.json:
|
||||
if json:
|
||||
print_json(clip)
|
||||
else:
|
||||
clip_info(clip)
|
||||
return
|
||||
|
||||
raise ConsoleError("Invalid input: {}".format(args.video))
|
||||
raise ConsoleError("Invalid input: {}".format(id))
|
||||
|
||||
|
||||
def video_info(video, playlists, chapters):
|
||||
|
@ -5,19 +5,30 @@ from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.output import print_out, print_paged_videos, print_video, print_json, print_video_compact
|
||||
|
||||
|
||||
def videos(args):
|
||||
game_ids = _get_game_ids(args.game)
|
||||
def videos(
|
||||
channel_name: str,
|
||||
*,
|
||||
all: bool,
|
||||
compact: bool,
|
||||
games: list[str],
|
||||
json: bool,
|
||||
limit: int | None,
|
||||
pager: int | None,
|
||||
sort: str,
|
||||
type: str,
|
||||
):
|
||||
game_ids = _get_game_ids(games)
|
||||
|
||||
# Set different defaults for limit for compact display
|
||||
limit = args.limit or (40 if args.compact else 10)
|
||||
limit = limit or (40 if compact else 10)
|
||||
|
||||
# Ignore --limit if --pager or --all are given
|
||||
max_videos = sys.maxsize if args.all or args.pager else limit
|
||||
max_videos = sys.maxsize if all or pager else limit
|
||||
|
||||
total_count, generator = twitch.channel_videos_generator(
|
||||
args.channel_name, max_videos, args.sort, args.type, game_ids=game_ids)
|
||||
channel_name, max_videos, sort, type, game_ids=game_ids)
|
||||
|
||||
if args.json:
|
||||
if json:
|
||||
videos = list(generator)
|
||||
print_json({
|
||||
"count": len(videos),
|
||||
@ -30,13 +41,13 @@ def videos(args):
|
||||
print_out("<yellow>No videos found</yellow>")
|
||||
return
|
||||
|
||||
if args.pager:
|
||||
print_paged_videos(generator, args.pager, total_count)
|
||||
if pager:
|
||||
print_paged_videos(generator, pager, total_count)
|
||||
return
|
||||
|
||||
count = 0
|
||||
for video in generator:
|
||||
if args.compact:
|
||||
if compact:
|
||||
print_video_compact(video)
|
||||
else:
|
||||
print_out()
|
||||
|
@ -1,338 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import re
|
||||
|
||||
from argparse import ArgumentParser, ArgumentTypeError
|
||||
from typing import NamedTuple, List, Tuple, Any, Dict
|
||||
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.output import print_err
|
||||
from twitchdl.twitch import GQLError
|
||||
from . import commands, __version__
|
||||
|
||||
|
||||
Argument = Tuple[List[str], Dict[str, Any]]
|
||||
|
||||
|
||||
class Command(NamedTuple):
|
||||
name: str
|
||||
description: str
|
||||
arguments: List[Argument]
|
||||
|
||||
|
||||
CLIENT_WEBSITE = "https://twitch-dl.bezdomni.net/"
|
||||
|
||||
|
||||
def time(value: str) -> int:
|
||||
"""Parse a time string (hh:mm or hh:mm:ss) to number of seconds."""
|
||||
parts = [int(p) for p in value.split(":")]
|
||||
|
||||
if not 2 <= len(parts) <= 3:
|
||||
raise ArgumentTypeError()
|
||||
|
||||
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 ArgumentTypeError()
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
|
||||
def pos_integer(value: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError:
|
||||
raise ArgumentTypeError("must be an integer")
|
||||
|
||||
if parsed < 1:
|
||||
raise ArgumentTypeError("must be positive")
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def rate(value: str) -> int:
|
||||
match = re.search(r"^([0-9]+)(k|m|)$", value, flags=re.IGNORECASE)
|
||||
|
||||
if not match:
|
||||
raise ArgumentTypeError("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
|
||||
|
||||
|
||||
COMMANDS = [
|
||||
Command(
|
||||
name="videos",
|
||||
description="List videos for a channel.",
|
||||
arguments=[
|
||||
(["channel_name"], {
|
||||
"help": "Name of the channel to list videos for.",
|
||||
"type": str,
|
||||
}),
|
||||
(["-g", "--game"], {
|
||||
"help": "Show videos of given game (can be given multiple times)",
|
||||
"action": "append",
|
||||
"type": str,
|
||||
}),
|
||||
(["-l", "--limit"], {
|
||||
"help": "Number of videos to fetch. Defaults to 40 in copmpact mode, 10 otherwise.",
|
||||
"type": pos_integer,
|
||||
}),
|
||||
(["-a", "--all"], {
|
||||
"help": "Fetch all videos, overrides --limit",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["-s", "--sort"], {
|
||||
"help": "Sorting order of videos. Defaults to `time`.",
|
||||
"type": str,
|
||||
"choices": ["views", "time"],
|
||||
"default": "time",
|
||||
}),
|
||||
(["-t", "--type"], {
|
||||
"help": "Broadcast type. Defaults to `archive`.",
|
||||
"type": str,
|
||||
"choices": ["archive", "highlight", "upload"],
|
||||
"default": "archive",
|
||||
}),
|
||||
(["-j", "--json"], {
|
||||
"help": "Show results as JSON. Ignores `--pager`.",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["-p", "--pager"], {
|
||||
"help": "Print videos in pages. Ignores `--limit`. Defaults to 10.",
|
||||
"type": pos_integer,
|
||||
"nargs": "?",
|
||||
"const": 10,
|
||||
}),
|
||||
(["-c", "--compact"], {
|
||||
"help": "Show videos in compact mode, one line per video",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
],
|
||||
),
|
||||
Command(
|
||||
name="clips",
|
||||
description="List or download clips for a channel.",
|
||||
arguments=[
|
||||
(["channel_name"], {
|
||||
"help": "Name of the channel to list clips for.",
|
||||
"type": str,
|
||||
}),
|
||||
(["-l", "--limit"], {
|
||||
"help": "Number of videos to fetch (default 10, max 100)",
|
||||
"type": pos_integer,
|
||||
"default": 10,
|
||||
}),
|
||||
(["-a", "--all"], {
|
||||
"help": "Fetch all videos, overrides --limit",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["-P", "--period"], {
|
||||
"help": "Period from which to return clips. Defaults to `all_time`.",
|
||||
"type": str,
|
||||
"choices": ["last_day", "last_week", "last_month", "all_time"],
|
||||
"default": "all_time",
|
||||
}),
|
||||
(["-j", "--json"], {
|
||||
"help": "Show results as JSON. Ignores `--pager`.",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["-p", "--pager"], {
|
||||
"help": "Number of clips to show per page. Disabled by default.",
|
||||
"type": pos_integer,
|
||||
"nargs": "?",
|
||||
"const": 10,
|
||||
}),
|
||||
(["-d", "--download"], {
|
||||
"help": "Download all videos in given period (in source quality)",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
],
|
||||
),
|
||||
Command(
|
||||
name="download",
|
||||
description="Download videos or clips.",
|
||||
arguments=[
|
||||
(["videos"], {
|
||||
"help": "One or more video ID, clip slug or twitch URL to download.",
|
||||
"type": str,
|
||||
"nargs": "+",
|
||||
}),
|
||||
(["-w", "--max-workers"], {
|
||||
"help": "Number of workers for downloading vods concurrently (default 5)",
|
||||
"type": int,
|
||||
"default": 5,
|
||||
}),
|
||||
(["-s", "--start"], {
|
||||
"help": "Download video from this time (hh:mm or hh:mm:ss)",
|
||||
"type": time,
|
||||
"default": None,
|
||||
}),
|
||||
(["-e", "--end"], {
|
||||
"help": "Download video up to this time (hh:mm or hh:mm:ss)",
|
||||
"type": time,
|
||||
"default": None,
|
||||
}),
|
||||
(["-f", "--format"], {
|
||||
"help": "Video format to convert into, passed to ffmpeg as the "
|
||||
"target file extension. Defaults to `mkv`.",
|
||||
"type": str,
|
||||
"default": "mkv",
|
||||
}),
|
||||
(["-k", "--keep"], {
|
||||
"help": "Don't delete downloaded VODs and playlists after merging.",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["-q", "--quality"], {
|
||||
"help": "Video quality, e.g. 720p. Set to 'source' to get best quality.",
|
||||
"type": str,
|
||||
}),
|
||||
(["-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.",
|
||||
"type": str,
|
||||
"default": None,
|
||||
}),
|
||||
(["--no-join"], {
|
||||
"help": "Don't run ffmpeg to join the downloaded vods, implies --keep.",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["--overwrite"], {
|
||||
"help": "Overwrite the target file if it already exists without prompting.",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["-o", "--output"], {
|
||||
"help": "Output file name template. See docs for details.",
|
||||
"type": str,
|
||||
"default": "{date}_{id}_{channel_login}_{title_slug}.{format}"
|
||||
}),
|
||||
(["-r", "--rate-limit"], {
|
||||
"help": "Limit the maximum download speed in bytes per second. "
|
||||
"Use 'k' and 'm' suffixes for kbps and mbps.",
|
||||
"type": rate,
|
||||
}),
|
||||
(["-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,
|
||||
"nargs": "?",
|
||||
"const": 0
|
||||
}),
|
||||
(["-d", "--dry-run"], {
|
||||
"help": "argument provides users with a simulation mode, them to "
|
||||
"preview the download process without actually downloading any files",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
],
|
||||
),
|
||||
Command(
|
||||
name="info",
|
||||
description="Print information for a given Twitch URL, video ID or clip slug.",
|
||||
arguments=[
|
||||
(["video"], {
|
||||
"help": "Video ID, clip slug, or URL",
|
||||
"type": str,
|
||||
}),
|
||||
(["-j", "--json"], {
|
||||
"help": "Show results as JSON",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
],
|
||||
),
|
||||
Command(
|
||||
name="env",
|
||||
description="Print environment information for inclusion in bug reports.",
|
||||
arguments=[],
|
||||
)
|
||||
]
|
||||
|
||||
COMMON_ARGUMENTS = [
|
||||
(["--debug"], {
|
||||
"help": "show debug log in console",
|
||||
"action": 'store_true',
|
||||
"default": False,
|
||||
}),
|
||||
(["--no-color"], {
|
||||
"help": "disable ANSI colors in output",
|
||||
"action": 'store_true',
|
||||
"default": False,
|
||||
})
|
||||
]
|
||||
|
||||
|
||||
def get_parser():
|
||||
description = "A script for downloading videos from Twitch"
|
||||
|
||||
parser = ArgumentParser(prog='twitch-dl', description=description, epilog=CLIENT_WEBSITE)
|
||||
parser.add_argument("--version", help="show version number", action='store_true')
|
||||
|
||||
subparsers = parser.add_subparsers(title="commands")
|
||||
|
||||
for command in COMMANDS:
|
||||
sub = subparsers.add_parser(
|
||||
command.name,
|
||||
description=command.description,
|
||||
epilog=CLIENT_WEBSITE
|
||||
)
|
||||
|
||||
# Set the function to call to the function of same name in the "commands" package
|
||||
sub.set_defaults(func=commands.__dict__.get(command.name))
|
||||
|
||||
for args, kwargs in command.arguments + COMMON_ARGUMENTS:
|
||||
sub.add_argument(*args, **kwargs)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = get_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if "--debug" in sys.argv:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
if args.version:
|
||||
print("twitch-dl v{}".format(__version__))
|
||||
return
|
||||
|
||||
if "func" not in args:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
args.func(args)
|
||||
except ConsoleError as e:
|
||||
print_err(e)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print_err("\nOperation canceled")
|
||||
sys.exit(1)
|
||||
except GQLError as e:
|
||||
print_err(e)
|
||||
for err in e.errors:
|
||||
print_err("*", err["message"])
|
||||
sys.exit(1)
|
18
twitchdl/entities.py
Normal file
18
twitchdl/entities.py
Normal file
@ -0,0 +1,18 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadOptions:
|
||||
auth_token: str | None
|
||||
chapter: int | None
|
||||
dry_run: bool
|
||||
end: int | None
|
||||
format: str
|
||||
keep: bool
|
||||
no_join: bool
|
||||
overwrite: bool
|
||||
output: str
|
||||
quality: str | None
|
||||
rate_limit: str | None
|
||||
start: int | None
|
||||
max_workers: int
|
Loading…
x
Reference in New Issue
Block a user