Compare commits

..

25 Commits

Author SHA1 Message Date
2f977be161 Bump version 2021-12-03 10:26:17 +01:00
928c6d64cf Add metadata to the encoded video file 2021-12-03 10:25:04 +01:00
dd1f4e0d26 Better computation of speed
Distinct VODs took from disk and freshly downloaded
2021-09-16 08:29:33 +02:00
caabe3138c Remove old kraken requests 2021-09-16 08:01:31 +02:00
9c3cf11635 Dedupe clip fields 2021-09-16 07:59:07 +02:00
2f51b3821b Handle video not found gracefully 2021-09-16 07:58:43 +02:00
62092ee25f Add some tests 2021-09-16 07:57:55 +02:00
6f86aea493 Bump version, changelog 2021-07-31 11:44:56 +02:00
e3f66bda43 Fix compat with older versions of python
path.join doesn't handle PosixPath instances in older versions of python
which causes breakage.

fixes #71
2021-07-31 11:41:45 +02:00
5c3cebd0f3 Bump version, changelog 2021-06-09 15:10:39 +02:00
a49dcab419 Fix clips download by fetching access token
fixes #64
2021-06-09 15:08:40 +02:00
0dd04a7e2d Extract clips list method 2021-05-18 14:25:49 +02:00
5bd0747dde Respect --limit when downloading clips 2021-05-18 14:23:56 +02:00
63c2aff334 Handle keyboard interrupt 2021-05-18 14:00:50 +02:00
e95b430eec Remove unused namedtuple 2021-05-18 13:54:17 +02:00
8c582c600e Fix duplicate named test function 2021-05-18 13:52:34 +02:00
c0c5cbf2a8 Don't break if channel not found 2021-03-22 07:42:07 +01:00
3f143b0c84 Fix file naming not to strip first digit of id
fixes #60
2021-03-22 07:42:07 +01:00
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
12 changed files with 222 additions and 132 deletions

View File

@ -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)
-------------------

View File

@ -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
View 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

View File

@ -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

View File

@ -1,3 +1,3 @@
__version__ = "1.14.1"
__version__ = "1.17.0"
CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"

View File

@ -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)

View File

@ -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))

View File

@ -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):

View File

@ -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:

View File

@ -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)

View File

@ -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"]

View File

@ -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})?)(\?.+)?$",
]