mirror of
https://github.com/ihabunek/twitch-dl
synced 2024-08-30 18:32:25 +00:00
Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
2f977be161 | |||
928c6d64cf | |||
dd1f4e0d26 | |||
caabe3138c | |||
9c3cf11635 | |||
2f51b3821b | |||
62092ee25f | |||
6f86aea493 | |||
e3f66bda43 | |||
5c3cebd0f3 | |||
a49dcab419 | |||
0dd04a7e2d | |||
5bd0747dde | |||
63c2aff334 | |||
e95b430eec | |||
8c582c600e | |||
c0c5cbf2a8 | |||
3f143b0c84 | |||
2242af05fc | |||
9c901a21d9 | |||
270f53c3c1 | |||
e12dba26b4 | |||
3a61e61226 | |||
8ddfad51bc | |||
d152cbff09 |
22
CHANGELOG.md
22
CHANGELOG.md
@ -1,6 +1,28 @@
|
||||
Twitch Downloader change log
|
||||
============================
|
||||
|
||||
1.17.0 (2021-12-03)
|
||||
-------------------
|
||||
|
||||
* Fix speed calculation when resuming download (#75, thanks CroquetteTheThe)
|
||||
* Add artist and title metadata to resulting video (#80)
|
||||
|
||||
1.16.1 (2021-07-31)
|
||||
-------------------
|
||||
|
||||
* Fix compat with older versions of python (#71)
|
||||
|
||||
1.16.0 (2021-06-09)
|
||||
-------------------
|
||||
|
||||
* Fix clips download caused by Twitch changes (#64, thanks to all participants)
|
||||
|
||||
|
||||
1.15.0 (2021-02-15)
|
||||
-------------------
|
||||
|
||||
* Add support for new format of clip slug (thanks @Loveangel1337)
|
||||
|
||||
1.14.1 (2021-01-14)
|
||||
-------------------
|
||||
|
||||
|
2
setup.py
2
setup.py
@ -11,7 +11,7 @@ makes it faster.
|
||||
|
||||
setup(
|
||||
name='twitch-dl',
|
||||
version='1.14.1',
|
||||
version='1.17.0',
|
||||
description='Twitch downloader',
|
||||
long_description=long_description.strip(),
|
||||
author='Ivan Habunek',
|
||||
|
30
tests/test_api.py
Normal file
30
tests/test_api.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""
|
||||
These tests depend on the channel having some videos and clips published.
|
||||
"""
|
||||
|
||||
from twitchdl import twitch
|
||||
|
||||
TEST_CHANNEL = "bananasaurus_rex"
|
||||
|
||||
|
||||
def test_get_videos():
|
||||
videos = twitch.get_channel_videos(TEST_CHANNEL, 3, "time")
|
||||
assert videos["pageInfo"]
|
||||
assert len(videos["edges"]) > 0
|
||||
|
||||
video_id = videos["edges"][0]["node"]["id"]
|
||||
video = twitch.get_video(video_id)
|
||||
assert video["id"] == video_id
|
||||
|
||||
|
||||
def test_get_clips():
|
||||
"""
|
||||
This test depends on the channel having some videos published.
|
||||
"""
|
||||
clips = twitch.get_channel_clips(TEST_CHANNEL, "all_time", 3)
|
||||
assert clips["pageInfo"]
|
||||
assert len(clips["edges"]) > 0
|
||||
|
||||
clip_slug = clips["edges"][0]["node"]["slug"]
|
||||
clip = twitch.get_clip(clip_slug)
|
||||
assert clip["slug"] == clip_slug
|
@ -1,10 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from unittest.mock import patch
|
||||
from twitchdl.commands import download
|
||||
from collections import namedtuple
|
||||
|
||||
Args = namedtuple("args", ["video"])
|
||||
from twitchdl.utils import parse_video_identifier, parse_clip_identifier
|
||||
|
||||
|
||||
TEST_VIDEO_PATTERNS = [
|
||||
@ -22,24 +18,18 @@ TEST_CLIP_PATTERNS = {
|
||||
("HungryProudRadicchioDoggo", "https://clips.twitch.tv/HungryProudRadicchioDoggo"),
|
||||
("HungryProudRadicchioDoggo", "https://www.twitch.tv/bananasaurus_rex/clip/HungryProudRadicchioDoggo?filter=clips&range=7d&sort=time"),
|
||||
("HungryProudRadicchioDoggo", "https://twitch.tv/bananasaurus_rex/clip/HungryProudRadicchioDoggo?filter=clips&range=7d&sort=time"),
|
||||
("GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ", "GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ"),
|
||||
("GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ", "https://twitch.tv/dracul1nx/clip/GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ"),
|
||||
("GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ", "https://twitch.tv/dracul1nx/clip/GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ?filter=clips&range=7d&sort=time"),
|
||||
("GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ", "https://www.twitch.tv/dracul1nx/clip/GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ?filter=clips&range=7d&sort=time"),
|
||||
}
|
||||
|
||||
|
||||
@patch("twitchdl.commands._download_clip")
|
||||
@patch("twitchdl.commands._download_video")
|
||||
@pytest.mark.parametrize("expected,input", TEST_VIDEO_PATTERNS)
|
||||
def test_video_patterns(video_dl, clip_dl, expected, input):
|
||||
args = Args(video=input)
|
||||
download(args)
|
||||
video_dl.assert_called_once_with(expected, args)
|
||||
clip_dl.assert_not_called()
|
||||
def test_video_patterns(expected, input):
|
||||
assert parse_video_identifier(input) == expected
|
||||
|
||||
|
||||
@patch("twitchdl.commands._download_clip")
|
||||
@patch("twitchdl.commands._download_video")
|
||||
@pytest.mark.parametrize("expected,input", TEST_CLIP_PATTERNS)
|
||||
def test_clip_patterns(video_dl, clip_dl, expected, input):
|
||||
args = Args(video=input)
|
||||
download(args)
|
||||
clip_dl.assert_called_once_with(expected, args)
|
||||
video_dl.assert_not_called()
|
||||
def test_clip_patterns(expected, input):
|
||||
assert parse_clip_identifier(input) == expected
|
@ -1,3 +1,3 @@
|
||||
__version__ = "1.14.1"
|
||||
__version__ = "1.17.0"
|
||||
|
||||
CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||
|
@ -3,11 +3,22 @@ import re
|
||||
from os import path
|
||||
|
||||
from twitchdl import twitch, utils
|
||||
from twitchdl.commands.download import get_clip_authenticated_url
|
||||
from twitchdl.download import download_file
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.output import print_out, print_clip, print_json
|
||||
|
||||
|
||||
def clips(args):
|
||||
if args.json:
|
||||
return _clips_json(args)
|
||||
|
||||
if args.download:
|
||||
return _clips_download(args)
|
||||
|
||||
return _clips_list(args)
|
||||
|
||||
|
||||
def _continue():
|
||||
print_out(
|
||||
"\nThere are more clips. "
|
||||
@ -63,26 +74,27 @@ def _clip_target_filename(clip):
|
||||
|
||||
|
||||
def _clips_download(args):
|
||||
downloaded_count = 0
|
||||
generator = twitch.channel_clips_generator(args.channel_name, args.period, 100)
|
||||
|
||||
for clips, _ in generator:
|
||||
for clip in clips["edges"]:
|
||||
clip = clip["node"]
|
||||
url = clip["videoQualities"][0]["sourceURL"]
|
||||
url = get_clip_authenticated_url(clip["slug"], "source")
|
||||
target = _clip_target_filename(clip)
|
||||
|
||||
if path.exists(target):
|
||||
print_out("Already downloaded: <green>{}</green>".format(target))
|
||||
else:
|
||||
print_out("Downloading: <yellow>{}</yellow>".format(target))
|
||||
download_file(url, target)
|
||||
|
||||
downloaded_count += 1
|
||||
if args.limit and downloaded_count >= args.limit:
|
||||
return
|
||||
|
||||
def clips(args):
|
||||
if args.json:
|
||||
return _clips_json(args)
|
||||
|
||||
if args.download:
|
||||
return _clips_download(args)
|
||||
|
||||
def _clips_list(args):
|
||||
print_out("<dim>Loading clips...</dim>")
|
||||
generator = twitch.channel_clips_generator(args.channel_name, args.period, args.limit)
|
||||
|
||||
|
@ -7,7 +7,7 @@ import tempfile
|
||||
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urlencode
|
||||
|
||||
from twitchdl import twitch, utils
|
||||
from twitchdl.download import download_file, download_files
|
||||
@ -48,14 +48,17 @@ def _select_playlist_interactive(playlists):
|
||||
return uri
|
||||
|
||||
|
||||
def _join_vods(playlist_path, target, overwrite):
|
||||
def _join_vods(playlist_path, target, overwrite, video):
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-i", playlist_path,
|
||||
"-c", "copy",
|
||||
target,
|
||||
"-metadata", "artist={}".format(video["creator"]["displayName"]),
|
||||
"-metadata", "title={}".format(video["title"]),
|
||||
"-metadata", "encoded_by=twitch-dl",
|
||||
"-stats",
|
||||
"-loglevel", "warning",
|
||||
target,
|
||||
]
|
||||
|
||||
if overwrite:
|
||||
@ -73,7 +76,7 @@ def _video_target_filename(video, format):
|
||||
|
||||
name = "_".join([
|
||||
date,
|
||||
video['id'][1:],
|
||||
video['id'],
|
||||
video['creator']['login'],
|
||||
utils.slugify(video['title']),
|
||||
])
|
||||
@ -124,7 +127,7 @@ def _crete_temp_dir(base_uri):
|
||||
path = urlparse(base_uri).path.lstrip("/")
|
||||
temp_dir = Path(tempfile.gettempdir(), "twitch-dl", path)
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
return temp_dir
|
||||
return str(temp_dir)
|
||||
|
||||
|
||||
def download(args):
|
||||
@ -139,21 +142,21 @@ def download(args):
|
||||
raise ConsoleError("Invalid input: {}".format(args.video))
|
||||
|
||||
|
||||
def _get_clip_url(clip, args):
|
||||
def _get_clip_url(clip, quality):
|
||||
qualities = clip["videoQualities"]
|
||||
|
||||
# Quality given as an argument
|
||||
if args.quality:
|
||||
if args.quality == "source":
|
||||
if quality:
|
||||
if quality == "source":
|
||||
return qualities[0]["sourceURL"]
|
||||
|
||||
selected_quality = args.quality.rstrip("p") # allow 720p as well as 720
|
||||
selected_quality = quality.rstrip("p") # allow 720p as well as 720
|
||||
for q in qualities:
|
||||
if q["quality"] == selected_quality:
|
||||
return q["sourceURL"]
|
||||
|
||||
available = ", ".join([str(q["quality"]) for q in qualities])
|
||||
msg = "Quality '{}' not found. Available qualities are: {}".format(args.quality, available)
|
||||
msg = "Quality '{}' not found. Available qualities are: {}".format(quality, available)
|
||||
raise ConsoleError(msg)
|
||||
|
||||
# Ask user to select quality
|
||||
@ -167,6 +170,23 @@ def _get_clip_url(clip, args):
|
||||
return selected_quality["sourceURL"]
|
||||
|
||||
|
||||
def get_clip_authenticated_url(slug, quality):
|
||||
print_out("<dim>Fetching access token...</dim>")
|
||||
access_token = twitch.get_clip_access_token(slug)
|
||||
|
||||
if not access_token:
|
||||
raise ConsoleError("Access token not found for slug '{}'".format(slug))
|
||||
|
||||
url = _get_clip_url(access_token, quality)
|
||||
|
||||
query = urlencode({
|
||||
"sig": access_token["playbackAccessToken"]["signature"],
|
||||
"token": access_token["playbackAccessToken"]["value"],
|
||||
})
|
||||
|
||||
return "{}?{}".format(url, query)
|
||||
|
||||
|
||||
def _download_clip(slug, args):
|
||||
print_out("<dim>Looking up clip...</dim>")
|
||||
clip = twitch.get_clip(slug)
|
||||
@ -174,6 +194,12 @@ def _download_clip(slug, args):
|
||||
if not clip:
|
||||
raise ConsoleError("Clip '{}' not found".format(slug))
|
||||
|
||||
print_out("<dim>Fetching access token...</dim>")
|
||||
access_token = twitch.get_clip_access_token(slug)
|
||||
|
||||
if not access_token:
|
||||
raise ConsoleError("Access token not found for slug '{}'".format(slug))
|
||||
|
||||
print_out("Found: <green>{}</green> by <yellow>{}</yellow>, playing <blue>{}</blue> ({})".format(
|
||||
clip["title"],
|
||||
clip["broadcaster"]["displayName"],
|
||||
@ -181,7 +207,7 @@ def _download_clip(slug, args):
|
||||
utils.format_duration(clip["durationSeconds"])
|
||||
))
|
||||
|
||||
url = _get_clip_url(clip, args)
|
||||
url = get_clip_authenticated_url(slug, args.quality)
|
||||
print_out("<dim>Selected URL: {}</dim>".format(url))
|
||||
|
||||
target = _clip_target_filename(clip)
|
||||
@ -199,6 +225,9 @@ def _download_video(video_id, args):
|
||||
print_out("<dim>Looking up video...</dim>")
|
||||
video = twitch.get_video(video_id)
|
||||
|
||||
if not video:
|
||||
raise ConsoleError("Video {} not found".format(video_id))
|
||||
|
||||
print_out("Found: <blue>{}</blue> by <yellow>{}</yellow>".format(
|
||||
video['title'], video['creator']['displayName']))
|
||||
|
||||
@ -249,7 +278,7 @@ def _download_video(video_id, args):
|
||||
|
||||
print_out("\n\nJoining files...")
|
||||
target = _video_target_filename(video, args.format)
|
||||
_join_vods(playlist_path, target, args.overwrite)
|
||||
_join_vods(playlist_path, target, args.overwrite, video)
|
||||
|
||||
if args.keep:
|
||||
print_out("\n<dim>Temporary files not deleted: {}</dim>".format(target_dir))
|
||||
|
@ -40,7 +40,7 @@ def info(args):
|
||||
clip_info(clip)
|
||||
return
|
||||
|
||||
raise ConsoleError("Invalid input: {}".format(args.video))
|
||||
raise ConsoleError("Invalid input: {}".format(args.identifier))
|
||||
|
||||
|
||||
def video_info(video, playlists):
|
||||
|
@ -239,6 +239,9 @@ def main():
|
||||
except ConsoleError as e:
|
||||
print_err(e)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print_err("Operation canceled")
|
||||
sys.exit(1)
|
||||
except GQLError as e:
|
||||
print_err(e)
|
||||
for err in e.errors:
|
||||
|
@ -34,11 +34,13 @@ def _download(url, path):
|
||||
|
||||
def download_file(url, path, retries=RETRY_COUNT):
|
||||
if os.path.exists(path):
|
||||
return os.path.getsize(path)
|
||||
from_disk = True
|
||||
return (os.path.getsize(path), from_disk)
|
||||
|
||||
from_disk = False
|
||||
for _ in range(retries):
|
||||
try:
|
||||
return _download(url, path)
|
||||
return (_download(url, path), from_disk)
|
||||
except RequestException:
|
||||
pass
|
||||
|
||||
@ -51,17 +53,26 @@ def _print_progress(futures):
|
||||
max_msg_size = 0
|
||||
start_time = datetime.now()
|
||||
total_count = len(futures)
|
||||
current_download_size = 0
|
||||
current_downloaded_count = 0
|
||||
|
||||
for future in as_completed(futures):
|
||||
size = future.result()
|
||||
size, from_disk = future.result()
|
||||
downloaded_count += 1
|
||||
downloaded_size += size
|
||||
|
||||
# If we find something on disk, we don't want to take it in account in
|
||||
# the speed calculation
|
||||
if not from_disk:
|
||||
current_download_size += size
|
||||
current_downloaded_count += 1
|
||||
|
||||
percentage = 100 * downloaded_count // total_count
|
||||
est_total_size = int(total_count * downloaded_size / downloaded_count)
|
||||
duration = (datetime.now() - start_time).seconds
|
||||
speed = downloaded_size // duration if duration else 0
|
||||
remaining = (total_count - downloaded_count) * duration / downloaded_count
|
||||
speed = current_download_size // duration if duration else 0
|
||||
remaining = (total_count - downloaded_count) * duration / current_downloaded_count \
|
||||
if current_downloaded_count else 0
|
||||
|
||||
msg = " ".join([
|
||||
"Downloaded VOD {}/{}".format(downloaded_count, total_count),
|
||||
@ -69,7 +80,7 @@ def _print_progress(futures):
|
||||
"<cyan>{}</cyan>".format(format_size(downloaded_size)),
|
||||
"of <cyan>~{}</cyan>".format(format_size(est_total_size)),
|
||||
"at <cyan>{}/s</cyan>".format(format_size(speed)) if speed > 0 else "",
|
||||
"remaining <cyan>~{}</cyan>".format(format_duration(remaining)) if speed > 0 else "",
|
||||
"remaining <cyan>~{}</cyan>".format(format_duration(remaining)) if remaining > 0 else "",
|
||||
])
|
||||
|
||||
max_msg_size = max(len(msg), max_msg_size)
|
||||
|
@ -42,13 +42,14 @@ def authenticated_post(url, data=None, json=None, headers={}):
|
||||
return response
|
||||
|
||||
|
||||
def kraken_get(url, params={}, headers={}):
|
||||
"""
|
||||
Add accept header required by kraken API v5.
|
||||
see: https://discuss.dev.twitch.tv/t/change-in-access-to-deprecated-kraken-twitch-apis/22241
|
||||
"""
|
||||
headers["Accept"] = "application/vnd.twitchtv.v5+json"
|
||||
return authenticated_get(url, params, headers)
|
||||
def gql_post(query):
|
||||
url = "https://gql.twitch.tv/gql"
|
||||
response = authenticated_post(url, data=query).json()
|
||||
|
||||
if "errors" in response:
|
||||
raise GQLError(response["errors"])
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def gql_query(query):
|
||||
@ -61,15 +62,6 @@ def gql_query(query):
|
||||
return response
|
||||
|
||||
|
||||
def get_video_legacy(video_id):
|
||||
"""
|
||||
https://dev.twitch.tv/docs/v5/reference/videos#get-video
|
||||
"""
|
||||
url = "https://api.twitch.tv/kraken/videos/{}".format(video_id)
|
||||
|
||||
return kraken_get(url).json()
|
||||
|
||||
|
||||
VIDEO_FIELDS = """
|
||||
id
|
||||
title
|
||||
@ -86,6 +78,30 @@ VIDEO_FIELDS = """
|
||||
"""
|
||||
|
||||
|
||||
CLIP_FIELDS = """
|
||||
id
|
||||
slug
|
||||
title
|
||||
createdAt
|
||||
viewCount
|
||||
durationSeconds
|
||||
url
|
||||
videoQualities {
|
||||
frameRate
|
||||
quality
|
||||
sourceURL
|
||||
}
|
||||
game {
|
||||
id
|
||||
name
|
||||
}
|
||||
broadcaster {
|
||||
displayName
|
||||
login
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def get_video(video_id):
|
||||
query = """
|
||||
{{
|
||||
@ -105,31 +121,32 @@ def get_clip(slug):
|
||||
query = """
|
||||
{{
|
||||
clip(slug: "{}") {{
|
||||
id
|
||||
slug
|
||||
title
|
||||
createdAt
|
||||
viewCount
|
||||
durationSeconds
|
||||
url
|
||||
videoQualities {{
|
||||
frameRate
|
||||
quality
|
||||
sourceURL
|
||||
}}
|
||||
game {{
|
||||
id
|
||||
name
|
||||
}}
|
||||
broadcaster {{
|
||||
displayName
|
||||
login
|
||||
{fields}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
response = gql_query(query.format(slug, fields=CLIP_FIELDS))
|
||||
return response["data"]["clip"]
|
||||
|
||||
|
||||
def get_clip_access_token(slug):
|
||||
query = """
|
||||
{{
|
||||
"operationName": "VideoAccessToken_Clip",
|
||||
"variables": {{
|
||||
"slug": "{slug}"
|
||||
}},
|
||||
"extensions": {{
|
||||
"persistedQuery": {{
|
||||
"version": 1,
|
||||
"sha256Hash": "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
response = gql_query(query.format(slug))
|
||||
response = gql_post(query.format(slug=slug).strip())
|
||||
return response["data"]["clip"]
|
||||
|
||||
|
||||
@ -154,26 +171,7 @@ def get_channel_clips(channel_id, period, limit, after=None):
|
||||
edges {{
|
||||
cursor
|
||||
node {{
|
||||
id
|
||||
slug
|
||||
title
|
||||
createdAt
|
||||
viewCount
|
||||
durationSeconds
|
||||
url
|
||||
videoQualities {{
|
||||
frameRate
|
||||
quality
|
||||
sourceURL
|
||||
}}
|
||||
game {{
|
||||
id
|
||||
name
|
||||
}}
|
||||
broadcaster {{
|
||||
displayName
|
||||
login
|
||||
}}
|
||||
{fields}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
@ -181,12 +179,13 @@ def get_channel_clips(channel_id, period, limit, after=None):
|
||||
}}
|
||||
"""
|
||||
|
||||
query = query.format(**{
|
||||
"channel_id": channel_id,
|
||||
"after": after if after else "",
|
||||
"limit": limit,
|
||||
"period": period.upper(),
|
||||
})
|
||||
query = query.format(
|
||||
channel_id=channel_id,
|
||||
after=after if after else "",
|
||||
limit=limit,
|
||||
period=period.upper(),
|
||||
fields=CLIP_FIELDS
|
||||
)
|
||||
|
||||
response = gql_query(query)
|
||||
user = response["data"]["user"]
|
||||
@ -234,18 +233,7 @@ def get_channel_videos(channel_id, limit, sort, type="archive", game_ids=[], aft
|
||||
edges {{
|
||||
cursor
|
||||
node {{
|
||||
id
|
||||
title
|
||||
publishedAt
|
||||
broadcastType
|
||||
lengthSeconds
|
||||
game {{
|
||||
name
|
||||
}}
|
||||
creator {{
|
||||
login
|
||||
displayName
|
||||
}}
|
||||
{fields}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
@ -253,16 +241,21 @@ def get_channel_videos(channel_id, limit, sort, type="archive", game_ids=[], aft
|
||||
}}
|
||||
"""
|
||||
|
||||
query = query.format(**{
|
||||
"channel_id": channel_id,
|
||||
"game_ids": game_ids,
|
||||
"after": after if after else "",
|
||||
"limit": limit,
|
||||
"sort": sort.upper(),
|
||||
"type": type.upper(),
|
||||
})
|
||||
query = query.format(
|
||||
channel_id=channel_id,
|
||||
game_ids=game_ids,
|
||||
after=after if after else "",
|
||||
limit=limit,
|
||||
sort=sort.upper(),
|
||||
type=type.upper(),
|
||||
fields=VIDEO_FIELDS
|
||||
)
|
||||
|
||||
response = gql_query(query)
|
||||
|
||||
if not response["data"]["user"]:
|
||||
raise ConsoleError("Channel {} not found".format(channel_id))
|
||||
|
||||
return response["data"]["user"]["videos"]
|
||||
|
||||
|
||||
|
@ -69,9 +69,9 @@ VIDEO_PATTERNS = [
|
||||
]
|
||||
|
||||
CLIP_PATTERNS = [
|
||||
r"^(?P<slug>[A-Za-z0-9]+)$",
|
||||
r"^https://(www.)?twitch.tv/\w+/clip/(?P<slug>[A-Za-z0-9]+)(\?.+)?$",
|
||||
r"^https://clips.twitch.tv/(?P<slug>[A-Za-z0-9]+)(\?.+)?$",
|
||||
r"^(?P<slug>[A-Za-z0-9]+(?:-[A-Za-z0-9_-]{16})?)$",
|
||||
r"^https://(www.)?twitch.tv/\w+/clip/(?P<slug>[A-Za-z0-9]+(?:-[A-Za-z0-9_-]{16})?)(\?.+)?$",
|
||||
r"^https://clips.twitch.tv/(?P<slug>[A-Za-z0-9]+(?:-[A-Za-z0-9_-]{16})?)(\?.+)?$",
|
||||
]
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user