From 07f3a2fa489b4375152c27f211f56e582a21327b Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 11 Apr 2020 16:07:17 +0200 Subject: [PATCH] Implement downloading clips issue #15 --- README.md | 7 ++++ twitch-dl.1.scd | 19 +++++++++-- twitchdl/commands.py | 79 +++++++++++++++++++++++++++++++++++--------- twitchdl/console.py | 6 ++-- twitchdl/twitch.py | 46 ++++++++++++++++++++++++-- 5 files changed, 135 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index d17c565..d3edbfe 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,13 @@ twitch-dl download 221837124 twitch-dl download https://www.twitch.tv/videos/221837124 ``` +Download a clip by slug or URL: + +``` +twitch-dl download VenomousTameWormHumbleLife +twitch-dl download https://www.twitch.tv/bananasaurus_rex/clip/VenomousTameWormHumbleLife +``` + Man page -------- diff --git a/twitch-dl.1.scd b/twitch-dl.1.scd index 604b88e..695ba1a 100644 --- a/twitch-dl.1.scd +++ b/twitch-dl.1.scd @@ -24,13 +24,13 @@ List recent videos from bananasaurus\_rex's channel: twitch-dl videos bananasaurus_rex ``` -Download by URL: +Download video by URL: ``` twitch-dl download https://www.twitch.tv/videos/377220226 ``` -Download by ID: +Download video by ID: ``` twitch-dl download 377220226 @@ -48,6 +48,21 @@ Partial download by setting start and end time (hh:mm or hh:mm:ss): twitch-dl download --start=00:10 --end=02:15 377220226 ``` +Download clip by URL: + +``` +twitch-dl download https://www.twitch.tv/bananasaurus_rex/clip/VenomousTameWormHumbleLife +``` + +Download clip by slug: + +``` +twitch-dl download VenomousTameWormHumbleLife +``` + +Note that clips are a single download, and don't benefit from the paralelism +used when downloading videos. + # SEE ALSO youtube-dl(1) diff --git a/twitchdl/commands.py b/twitchdl/commands.py index 64c3c26..8a8e9b1 100644 --- a/twitchdl/commands.py +++ b/twitchdl/commands.py @@ -7,10 +7,11 @@ import shutil import subprocess import tempfile +from pathlib import Path from urllib.parse import urlparse from twitchdl import twitch, utils -from twitchdl.download import download_files +from twitchdl.download import download_file, download_files from twitchdl.exceptions import ConsoleError from twitchdl.output import print_out, print_video @@ -83,18 +84,6 @@ def _video_target_filename(video, format): return name + "." + format -def _parse_video_id(video_id): - """This can be either a integer ID or an URL to the video on twitch.""" - if re.search(r"^\d+$", video_id): - return int(video_id) - - match = re.search(r"^https://www.twitch.tv/videos/(\d+)(\?.+)?$", video_id) - if match: - return int(match.group(1)) - - raise ConsoleError("Invalid video ID given, expected integer ID or Twitch URL") - - def _get_files(playlist, start, end): """Extract files for download from playlist.""" vod_start = 0 @@ -120,9 +109,69 @@ def _crete_temp_dir(base_uri): return directory -def download(video_id, max_workers, format='mkv', start=None, end=None, keep=False, **kwargs): - video_id = _parse_video_id(video_id) +VIDEO_PATTERNS = [ + r"^(?P\d+)?$", + r"^https://www.twitch.tv/videos/(?P\d+)(\?.+)?$", +] +CLIP_PATTERNS = [ + r"^(?P[A-Za-z]+)$", + r"^https://www.twitch.tv/\w+/clip/(?P[A-Za-z]+)(\?.+)?$", + r"^https://clips.twitch.tv/(?P[A-Za-z]+)(\?.+)?$", +] + + +def download(video, **kwargs): + for pattern in CLIP_PATTERNS: + match = re.match(pattern, video) + if match: + clip_slug = match.group('slug') + return _download_clip(clip_slug, **kwargs) + + for pattern in VIDEO_PATTERNS: + match = re.match(pattern, video) + if match: + video_id = match.group('id') + return _download_video(video_id, **kwargs) + + raise ConsoleError("Invalid video: {}".format(video_id)) + + +def _download_clip(slug, **kwargs): + print_out("Looking up clip...") + clip = twitch.get_clip(slug) + + print_out("Found: {} by {}, playing {} ({})".format( + clip["title"], + clip["broadcaster"]["displayName"], + clip["game"]["name"], + utils.format_duration(clip["durationSeconds"]) + )) + + print_out("\nAvailable qualities:") + qualities = clip["videoQualities"] + for n, q in enumerate(qualities): + print_out("{}) {} [{} fps]".format(n + 1, q["quality"], q["frameRate"])) + + no = utils.read_int("Choose quality", min=1, max=len(qualities), default=1) + selected_quality = qualities[no - 1] + url = selected_quality["sourceURL"] + + url_path = urlparse(url).path + extension = Path(url_path).suffix + filename = "{}_{}{}".format( + clip["broadcaster"]["login"], + utils.slugify(clip["title"]), + extension + ) + + print("Downloading clip...") + download_file(url, filename) + + print("Downloaded: {}".format(filename)) + + +def _download_video(video_id, max_workers, format='mkv', start=None, end=None, keep=False, **kwargs): if start and end and end <= start: raise ConsoleError("End time must be greater than start time") diff --git a/twitchdl/console.py b/twitchdl/console.py index 2f69867..c28f0fa 100644 --- a/twitchdl/console.py +++ b/twitchdl/console.py @@ -63,13 +63,13 @@ COMMANDS = [ name="download", description="Download a video", arguments=[ - (["video_id"], { - "help": "video ID", + (["video"], { + "help": "video ID, clip slug, or URL", "type": str, }), (["-w", "--max_workers"], { "help": "maximal number of threads for downloading vods " - "concurrently (default 5)", + "concurrently (default 20)", "type": int, "default": 20, }), diff --git a/twitchdl/twitch.py b/twitchdl/twitch.py index 67087e4..87ca26e 100644 --- a/twitchdl/twitch.py +++ b/twitchdl/twitch.py @@ -21,6 +21,19 @@ def authenticated_get(url, params={}, headers={}): return response +def authenticated_post(url, data=None, json=None, headers={}): + headers['Client-ID'] = CLIENT_ID + + response = requests.post(url, data=data, json=json, headers=headers) + if response.status_code == 400: + data = response.json() + raise ConsoleError(data["message"]) + + response.raise_for_status() + + return response + + def kraken_get(url, params={}, headers={}): """ Add accept header required by kraken API v5. @@ -46,11 +59,40 @@ def get_video(video_id): """ https://dev.twitch.tv/docs/v5/reference/videos#get-video """ - url = "https://api.twitch.tv/kraken/videos/%d" % video_id + url = "https://api.twitch.tv/kraken/videos/{}".format(video_id) return kraken_get(url).json() +def get_clip(slug): + url = "https://gql.twitch.tv/gql" + + query = """ + {{ + clip(slug: "{}") {{ + title + durationSeconds + game {{ + name + }} + broadcaster {{ + login + displayName + }} + videoQualities {{ + frameRate + quality + sourceURL + }} + }} + }} + """ + + payload = {"query": query.format(slug)} + data = authenticated_post(url, json=payload).json() + return data["data"]["clip"] + + def get_channel_videos(channel_id, limit, offset, sort): """ https://dev.twitch.tv/docs/v5/reference/channels#get-channel-videos @@ -66,7 +108,7 @@ def get_channel_videos(channel_id, limit, offset, sort): def get_access_token(video_id): - url = "https://api.twitch.tv/api/vods/%d/access_token" % video_id + url = "https://api.twitch.tv/api/vods/{}/access_token".format(video_id) return authenticated_get(url).json()