Compare commits

..

16 Commits

Author SHA1 Message Date
2242af05fc Add changelog, bump version 2021-02-15 14:35:20 +01:00
9c901a21d9 Fix reference to invalid argument 2021-02-15 14:33:31 +01:00
270f53c3c1 Fix tests 2021-02-15 14:33:25 +01:00
e12dba26b4 Add dash as an allowed char, set length to 16. 2021-02-15 14:23:13 +01:00
3a61e61226 Add underscore as an allow char for clip extra ID 2021-02-15 14:23:13 +01:00
8ddfad51bc Update clip identifier patterns
New clips ID naming convention. ex: GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ
2021-02-15 14:23:13 +01:00
d152cbff09 Update tests with new clip naming
New clips ID naming convention. ex: GloriousColdbloodedTortoiseRuleFive-E017utJ4DZmHVpfQ
2021-02-15 14:23:13 +01:00
e0681ab53c Bump version, changelog 2021-01-14 22:25:22 +01:00
728e631623 Handle video not found in info 2021-01-14 22:23:40 +01:00
9d19acbe6d Re-apply fix lost to a merge conflict 2021-01-14 22:16:17 +01:00
baeaedaa54 Fix setup.py to detect new package 2021-01-14 22:14:53 +01:00
9e2bbd7e39 Bump version, changelog, readme 2021-01-14 22:14:53 +01:00
dbee7cdc52 Use GraphQL to fetch access token
issue #53
2021-01-14 22:14:52 +01:00
548a9350ba Add twitch-dl info command 2021-01-14 22:14:52 +01:00
2380dc5a35 Split up commands 2021-01-14 22:14:50 +01:00
a7340f178f Fix bug in videos command saying there are more videos when there aren’t
When listing videos, if --pager was not specified and all of the videos
for a channel were listed, a message would still print out saying
“There are more videos. Increase the --limit or use --pager to see the
rest."

This checks to see if there are actually more videos before printing
that error message.
2021-01-11 00:49:57 +01:00
14 changed files with 476 additions and 217 deletions

View File

@ -1,6 +1,23 @@
Twitch Downloader change log
============================
1.15.0 (2021-02-15)
-------------------
* Add support for new format of clip slug (thanks @Loveangel1337)
1.14.1 (2021-01-14)
-------------------
* Handle videos which don't exist more gracefully
1.14.0 (2021-01-14)
-------------------
* Added `info` command for displaying video or clip info (#51)
* Don't show there are more videos when there aren't (#52, thanks @scottyallen)
* Fixed Twitch regression for getting the access token (#53)
1.13.1 (2020-11-23)
-------------------

View File

@ -79,6 +79,39 @@ pipx install twitch-dl
Usage
-----
This section does an overview of available features.
To see a list of available commands run:
```
twitch-dl --help
```
And to see description and all arguments for a given command run:
```
twitch-dl <command> --help
```
### Print clip or video info
Videos can be referenced by URL or ID:
```
twitch-dl info 863849735
twitch-dl info https://www.twitch.tv/videos/863849735
```
Clips by slug or ID:
```
twitch-dl info BusyBlushingCattleItsBoshyTime
twitch-dl info https://www.twitch.tv/bananasaurus_rex/clip/BusyBlushingCattleItsBoshyTime
```
Shows info about the video or clip as well as download URLs for clips and
playlist URLs for videos.
### Listing videos
List recent streams for a given channel:
@ -87,27 +120,6 @@ List recent streams for a given channel:
twitch-dl videos bananasaurus_rex
```
Yields (trimmed):
```
Found 33 videos
221837124
SUPER MARIO ODYSSSEY - Stream #2 / 600,000,000
Bananasaurus_Rex playing Super Mario Odyssey
Published 2018-01-24 @ 12:05:25 Length: 3h 40min
221418913
Dead Space and then SUPER MARIO ODYSSEY PogChamp
Bananasaurus_Rex playing Dead Space
Published 2018-01-23 @ 02:40:58 Length: 6h 2min
220783179
Dead Space | Got my new setup working! rexChamp
Bananasaurus_Rex playing Dead Space
Published 2018-01-21 @ 05:47:03 Length: 5h 7min
```
Use the `--game` option to specify one or more games to show:
```
@ -116,7 +128,7 @@ twitch-dl videos --game "doom eternal" --game "cave story" bananasaurus_rex
### Downloading videos
Download a stream by ID or URL:
Download a video by ID or URL:
```
twitch-dl download 221837124

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python
from setuptools import setup
from setuptools import setup, find_packages
long_description = """
Quickly download videos from twitch.tv.
@ -11,7 +11,7 @@ makes it faster.
setup(
name='twitch-dl',
version='1.13.1',
version='1.15.0',
description='Twitch downloader',
long_description=long_description.strip(),
author='Ivan Habunek',
@ -27,7 +27,7 @@ setup(
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
],
packages=['twitchdl'],
packages=find_packages(),
python_requires='>=3.5',
install_requires=[
"m3u8>=0.3.12,<0.4",

View File

@ -3,6 +3,7 @@ import pytest
from unittest.mock import patch
from twitchdl.commands import download
from collections import namedtuple
from twitchdl.utils import parse_video_identifier, parse_clip_identifier
Args = namedtuple("args", ["video"])
@ -22,24 +23,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_video_patterns(expected, input):
assert parse_clip_identifier(input) == expected

View File

@ -1,3 +1,3 @@
__version__ = "1.13.1"
__version__ = "1.15.0"
CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"

View File

@ -0,0 +1,11 @@
from .clips import clips
from .download import download
from .info import info
from .videos import videos
__all__ = [
clips,
download,
info,
videos,
]

114
twitchdl/commands/clips.py Normal file
View File

@ -0,0 +1,114 @@
import re
from os import path
from twitchdl import twitch, utils
from twitchdl.download import download_file
from twitchdl.exceptions import ConsoleError
from twitchdl.output import print_out, print_clip, print_json
def _continue():
print_out(
"\nThere are more clips. "
"Press <green><b>Enter</green> to continue, "
"<yellow><b>Ctrl+C</yellow> to break."
)
try:
input()
except KeyboardInterrupt:
return False
return True
def _get_game_ids(names):
if not names:
return []
game_ids = []
for name in names:
print_out("<dim>Looking up game '{}'...</dim>".format(name))
game_id = twitch.get_game_id(name)
if not game_id:
raise ConsoleError("Game '{}' not found".format(name))
game_ids.append(int(game_id))
return game_ids
def _clips_json(args):
clips = twitch.get_channel_clips(args.channel_name, args.period, args.limit)
nodes = list(edge["node"] for edge in clips["edges"])
print_json(nodes)
def _clip_target_filename(clip):
url = clip["videoQualities"][0]["sourceURL"]
_, ext = path.splitext(url)
ext = ext.lstrip(".")
match = re.search(r"^(\d{4})-(\d{2})-(\d{2})T", clip["createdAt"])
date = "".join(match.groups())
name = "_".join([
date,
clip["id"],
clip["broadcaster"]["login"],
utils.slugify(clip["title"]),
])
return "{}.{}".format(name, ext)
def _clips_download(args):
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"]
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)
def clips(args):
if args.json:
return _clips_json(args)
if args.download:
return _clips_download(args)
print_out("<dim>Loading clips...</dim>")
generator = twitch.channel_clips_generator(args.channel_name, args.period, args.limit)
first = 1
for clips, has_more in generator:
count = len(clips["edges"]) if "edges" in clips else 0
last = first + count - 1
print_out("-" * 80)
print_out("<yellow>Showing clips {}-{} of ??</yellow>".format(first, last))
for clip in clips["edges"]:
print_out()
print_clip(clip["node"])
if not args.pager:
print_out(
"\n<dim>There are more clips. "
"Increase the --limit or use --pager to see the rest.</dim>"
)
break
if not has_more or not _continue():
break
first += count
else:
print_out("<yellow>No clips found</yellow>")

View File

@ -12,129 +12,7 @@ from urllib.parse import urlparse
from twitchdl import twitch, utils
from twitchdl.download import download_file, download_files
from twitchdl.exceptions import ConsoleError
from twitchdl.output import print_out, print_clip, print_video, print_json
def _continue():
print_out(
"\nThere are more videos. "
"Press <green><b>Enter</green> to continue, "
"<yellow><b>Ctrl+C</yellow> to break."
)
try:
input()
except KeyboardInterrupt:
return False
return True
def _get_game_ids(names):
if not names:
return []
game_ids = []
for name in names:
print_out("<dim>Looking up game '{}'...</dim>".format(name))
game_id = twitch.get_game_id(name)
if not game_id:
raise ConsoleError("Game '{}' not found".format(name))
game_ids.append(int(game_id))
return game_ids
def _clips_json(args):
clips = twitch.get_channel_clips(args.channel_name, args.period, args.limit)
nodes = list(edge["node"] for edge in clips["edges"])
print_json(nodes)
def _clips_download(args):
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"]
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)
def clips(args):
if args.json:
return _clips_json(args)
if args.download:
return _clips_download(args)
print_out("<dim>Loading clips...</dim>")
generator = twitch.channel_clips_generator(args.channel_name, args.period, args.limit)
first = 1
for clips, has_more in generator:
count = len(clips["edges"]) if "edges" in clips else 0
last = first + count - 1
print_out("-" * 80)
print_out("<yellow>Showing clips {}-{} of ??</yellow>".format(first, last))
for clip in clips["edges"]:
print_clip(clip["node"])
if not args.pager:
print_out(
"\n<dim>There are more clips. "
"Increase the --limit or use --pager to see the rest.</dim>"
)
break
if not has_more or not _continue():
break
first += count
else:
print_out("<yellow>No clips found</yellow>")
def videos(args):
game_ids = _get_game_ids(args.game)
print_out("<dim>Loading videos...</dim>")
generator = twitch.channel_videos_generator(
args.channel_name, args.limit, args.sort, args.type, game_ids=game_ids)
first = 1
for videos, has_more in generator:
count = len(videos["edges"]) if "edges" in videos else 0
total = videos["totalCount"]
last = first + count - 1
print_out("-" * 80)
print_out("<yellow>Showing videos {}-{} of {}</yellow>".format(first, last, total))
for video in videos["edges"]:
print_video(video["node"])
if not args.pager:
print_out(
"\n<dim>There are more videos. "
"Increase the --limit or use --pager to see the rest.</dim>"
)
break
if not has_more or not _continue():
break
first += count
else:
print_out("<yellow>No videos found</yellow>")
from twitchdl.output import print_out
def _parse_playlists(playlists_m3u8):
@ -190,13 +68,13 @@ def _join_vods(playlist_path, target, overwrite):
def _video_target_filename(video, format):
match = re.search(r"^(\d{4})-(\d{2})-(\d{2})T", video['published_at'])
match = re.search(r"^(\d{4})-(\d{2})-(\d{2})T", video['publishedAt'])
date = "".join(match.groups())
name = "_".join([
date,
video['_id'][1:],
video['channel']['name'],
video['id'][1:],
video['creator']['login'],
utils.slugify(video['title']),
])
@ -214,7 +92,7 @@ def _clip_target_filename(clip):
name = "_".join([
date,
clip["id"],
clip["broadcaster"]["channel"]["name"],
clip["broadcaster"]["login"],
utils.slugify(clip["title"]),
])
@ -249,32 +127,16 @@ def _crete_temp_dir(base_uri):
return temp_dir
VIDEO_PATTERNS = [
r"^(?P<id>\d+)?$",
r"^https://(www.)?twitch.tv/videos/(?P<id>\d+)(\?.+)?$",
]
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]+)(\?.+)?$",
]
def download(args):
for pattern in VIDEO_PATTERNS:
match = re.match(pattern, args.video)
if match:
video_id = match.group('id')
return _download_video(video_id, args)
video_id = utils.parse_video_identifier(args.video)
if video_id:
return _download_video(video_id, args)
for pattern in CLIP_PATTERNS:
match = re.match(pattern, args.video)
if match:
clip_slug = match.group('slug')
return _download_clip(clip_slug, args)
clip_slug = utils.parse_clip_identifier(args.video)
if clip_slug:
return _download_clip(clip_slug, args)
raise ConsoleError("Invalid video: {}".format(args.video))
raise ConsoleError("Invalid input: {}".format(args.video))
def _get_clip_url(clip, args):
@ -338,7 +200,7 @@ def _download_video(video_id, args):
video = twitch.get_video(video_id)
print_out("Found: <blue>{}</blue> by <yellow>{}</yellow>".format(
video['title'], video['channel']['display_name']))
video['title'], video['creator']['displayName']))
print_out("<dim>Fetching access token...</dim>")
access_token = twitch.get_access_token(video_id)

79
twitchdl/commands/info.py Normal file
View File

@ -0,0 +1,79 @@
import m3u8
from twitchdl import utils, twitch
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.identifier)
if video_id:
print_log("Fetching video...")
video = twitch.get_video(video_id)
if not video:
raise ConsoleError("Video {} not found".format(video_id))
print_log("Fetching access token...")
access_token = twitch.get_access_token(video_id)
print_log("Fetching playlists...")
playlists = twitch.get_playlists(video_id, access_token)
if video:
if args.json:
video_json(video, playlists)
else:
video_info(video, playlists)
return
clip_slug = utils.parse_clip_identifier(args.identifier)
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:
print_json(clip)
else:
clip_info(clip)
return
raise ConsoleError("Invalid input: {}".format(args.identifier))
def video_info(video, playlists):
print_out()
print_video(video)
print_out()
print_out("Playlists:")
for p in m3u8.loads(playlists).playlists:
print_out("<b>{}</b> {}".format(p.stream_info.video, p.uri))
def video_json(video, playlists):
playlists = m3u8.loads(playlists).playlists
video["playlists"] = [
{
"bandwidth": p.stream_info.bandwidth,
"resolution": p.stream_info.resolution,
"codecs": p.stream_info.codecs,
"video": p.stream_info.video,
"uri": p.uri
} for p in playlists
]
print_json(video)
def clip_info(clip):
print_out()
print_clip(clip)
print_out()
print_out("Download links:")
for q in clip["videoQualities"]:
print_out("<b>{quality}p{frameRate}</b> {sourceURL}".format(**q))

View File

@ -0,0 +1,69 @@
from twitchdl import twitch
from twitchdl.exceptions import ConsoleError
from twitchdl.output import print_out, print_video
def _continue():
print_out(
"\nThere are more videos. "
"Press <green><b>Enter</green> to continue, "
"<yellow><b>Ctrl+C</yellow> to break."
)
try:
input()
except KeyboardInterrupt:
return False
return True
def _get_game_ids(names):
if not names:
return []
game_ids = []
for name in names:
print_out("<dim>Looking up game '{}'...</dim>".format(name))
game_id = twitch.get_game_id(name)
if not game_id:
raise ConsoleError("Game '{}' not found".format(name))
game_ids.append(int(game_id))
return game_ids
def videos(args):
game_ids = _get_game_ids(args.game)
print_out("<dim>Loading videos...</dim>")
generator = twitch.channel_videos_generator(
args.channel_name, args.limit, args.sort, args.type, game_ids=game_ids)
first = 1
for videos, has_more in generator:
count = len(videos["edges"]) if "edges" in videos else 0
total = videos["totalCount"]
last = first + count - 1
print_out("-" * 80)
print_out("<yellow>Showing videos {}-{} of {}</yellow>".format(first, last, total))
for video in videos["edges"]:
print_out()
print_video(video["node"])
if not args.pager and has_more:
print_out(
"\n<dim>There are more videos. "
"Increase the --limit or use --pager to see the rest.</dim>"
)
break
if not has_more or not _continue():
break
first += count
else:
print_out("<yellow>No videos found</yellow>")

View File

@ -171,6 +171,21 @@ COMMANDS = [
})
],
),
Command(
name="info",
description="Print information for a given Twitch URL, video ID or clip slug",
arguments=[
(["identifier"], {
"help": "identifier",
"type": str,
}),
(["-j", "--json"], {
"help": "Show results as JSON",
"action": "store_true",
"default": False,
}),
],
)
]
COMMON_ARGUMENTS = [

View File

@ -62,10 +62,16 @@ def print_err(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
def print_log(*args, **kwargs):
args = ["<dim>{}</dim>".format(a) for a in args]
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
print(*args, file=sys.stderr, **kwargs)
def print_video(video):
published_at = video["publishedAt"].replace("T", " @ ").replace("Z", "")
length = utils.format_duration(video["lengthSeconds"])
channel = video["creator"]["channel"]["displayName"]
channel = video["creator"]["displayName"]
playing = (
"playing <blue>{}</blue>".format(video["game"]["name"])
if video["game"] else ""
@ -74,7 +80,7 @@ def print_video(video):
# Can't find URL in video object, strange
url = "https://www.twitch.tv/videos/{}".format(video["id"])
print_out("\n<b>{}</b>".format(video["id"]))
print_out("<b>Video {}</b>".format(video["id"]))
print_out("<green>{}</green>".format(video["title"]))
print_out("<blue>{}</blue> {}".format(channel, playing))
print_out("Published <blue>{}</blue> Length: <blue>{}</blue> ".format(published_at, length))
@ -84,13 +90,13 @@ def print_video(video):
def print_clip(clip):
published_at = clip["createdAt"].replace("T", " @ ").replace("Z", "")
length = utils.format_duration(clip["durationSeconds"])
channel = clip["broadcaster"]["channel"]["displayName"]
channel = clip["broadcaster"]["displayName"]
playing = (
"playing <blue>{}</blue>".format(clip["game"]["name"])
if clip["game"] else ""
)
print_out("\n<b>{}</b>".format(clip["slug"]))
print_out("Clip <b>{}</b>".format(clip["slug"]))
print_out("<green>{}</green>".format(clip["title"]))
print_out("<blue>{}</blue> {}".format(channel, playing))
print_out(
@ -98,3 +104,8 @@ def print_clip(clip):
" Length: <blue>{}</blue>"
" Views: <blue>{}</blue>".format(published_at, length, clip["viewCount"]))
print_out("<i>{}</i>".format(clip["url"]))
def print_clip_urls(clip):
from pprint import pprint
pprint(clip)

View File

@ -61,7 +61,7 @@ def gql_query(query):
return response
def get_video(video_id):
def get_video_legacy(video_id):
"""
https://dev.twitch.tv/docs/v5/reference/videos#get-video
"""
@ -70,29 +70,61 @@ def get_video(video_id):
return kraken_get(url).json()
VIDEO_FIELDS = """
id
title
publishedAt
broadcastType
lengthSeconds
game {
name
}
creator {
login
displayName
}
"""
def get_video(video_id):
query = """
{{
video(id: "{video_id}") {{
{fields}
}}
}}
"""
query = query.format(video_id=video_id, fields=VIDEO_FIELDS)
response = gql_query(query)
return response["data"]["video"]
def get_clip(slug):
query = """
{{
clip(slug: "{}") {{
id
slug
title
createdAt
viewCount
durationSeconds
game {{
name
}}
broadcaster {{
login
displayName
channel {{
name
}}
}}
url
videoQualities {{
frameRate
quality
sourceURL
}}
game {{
id
name
}}
broadcaster {{
displayName
login
}}
}}
}}
"""
@ -139,10 +171,8 @@ def get_channel_clips(channel_id, period, limit, after=None):
name
}}
broadcaster {{
channel {{
name
displayName
}}
displayName
login
}}
}}
}}
@ -213,9 +243,8 @@ def get_channel_videos(channel_id, limit, sort, type="archive", game_ids=[], aft
name
}}
creator {{
channel {{
displayName
}}
login
displayName
}}
}}
}}
@ -256,9 +285,26 @@ def channel_videos_generator(channel_id, limit, sort, type, game_ids=None):
def get_access_token(video_id):
url = "https://api.twitch.tv/api/vods/{}/access_token".format(video_id)
query = """
{{
videoPlaybackAccessToken(
id: {video_id},
params: {{
platform: "web",
playerBackend: "mediaplayer",
playerType: "site"
}}
) {{
signature
value
}}
}}
"""
return authenticated_get(url).json()
query = query.format(video_id=video_id)
response = gql_query(query)
return response["data"]["videoPlaybackAccessToken"]
def get_playlists(video_id, access_token):
@ -268,8 +314,8 @@ def get_playlists(video_id, access_token):
url = "http://usher.twitch.tv/vod/{}".format(video_id)
response = requests.get(url, params={
"nauth": access_token['token'],
"nauthsig": access_token['sig'],
"nauth": access_token['value'],
"nauthsig": access_token['signature'],
"allow_source": "true",
"player": "twitchweb",
})

View File

@ -61,3 +61,31 @@ def slugify(value):
value = unicodedata.normalize('NFKC', value)
value = re_pattern.sub('', value).strip().lower()
return re_spaces.sub('_', value)
VIDEO_PATTERNS = [
r"^(?P<id>\d+)?$",
r"^https://(www.)?twitch.tv/videos/(?P<id>\d+)(\?.+)?$",
]
CLIP_PATTERNS = [
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})?)(\?.+)?$",
]
def parse_video_identifier(identifier):
"""Given a video ID or URL returns the video ID, or null if not matched"""
for pattern in VIDEO_PATTERNS:
match = re.match(pattern, identifier)
if match:
return match.group("id")
def parse_clip_identifier(identifier):
"""Given a clip slug or URL returns the clip slug, or null if not matched"""
for pattern in CLIP_PATTERNS:
match = re.match(pattern, identifier)
if match:
return match.group("slug")