mirror of
https://github.com/ihabunek/twitch-dl
synced 2024-08-30 18:32:25 +00:00
Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
9685ea6a36 | |||
73757b557e | |||
ddead05712 | |||
bcb55be7ad | |||
a4bdb90faa | |||
8db7dd7b8a | |||
5a43a4388c | |||
6f461d889c | |||
7f6e792eae | |||
2402c3bfca | |||
533c91d133 | |||
ddf1b10e56 | |||
523f6449c3 | |||
32cb9b6602 | |||
c4cbf588a2 | |||
35e53ba4fd | |||
d505056fee | |||
8658d0fa24 |
23
CHANGELOG.md
23
CHANGELOG.md
@ -3,6 +3,29 @@ twitch-dl changelog
|
||||
|
||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||
|
||||
### [2.1.4 (2024-01-06)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.4)
|
||||
|
||||
* Fix error caused by twitch requiring https for the usher api (thanks
|
||||
@deanpcmad)
|
||||
|
||||
### [2.1.3 (2023-05-07)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.3)
|
||||
|
||||
* Replace client ID with one that works for now (thanks @mwhite34)
|
||||
|
||||
### [2.1.2 (2023-04-18)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.2)
|
||||
|
||||
* Fix error caused by twitch changing the Usher domain (thanks @adsa95)
|
||||
|
||||
### [2.1.1 (2022-11-20)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.1)
|
||||
|
||||
* Fix Python 3.7 compatibility (#117, thanks @eliduvid)
|
||||
* Fix default value for game_ids (#102, thanks @FunnyPocketBook)
|
||||
|
||||
### [2.1.0 (2022-11-20)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.0)
|
||||
|
||||
* Add chapter list to `info` command
|
||||
* Add `download --chapter` option for downloading a single chapter
|
||||
|
||||
### [2.0.1 (2022-09-09)](https://github.com/ihabunek/twitch-dl/releases/tag/2.0.1)
|
||||
|
||||
* Fix an issue where a temp vod file would be renamed while still being open,
|
||||
|
@ -1,3 +1,30 @@
|
||||
2.1.4:
|
||||
date: 2024-01-06
|
||||
changes:
|
||||
- "Fix error caused by twitch requiring https for the usher api (thanks @deanpcmad)"
|
||||
|
||||
2.1.3:
|
||||
date: 2023-05-07
|
||||
changes:
|
||||
- "Replace client ID with one that works for now (thanks @mwhite34)"
|
||||
|
||||
2.1.2:
|
||||
date: 2023-04-18
|
||||
changes:
|
||||
- "Fix error caused by twitch changing the Usher domain (thanks @adsa95)"
|
||||
|
||||
2.1.1:
|
||||
date: 2022-11-20
|
||||
changes:
|
||||
- "Fix Python 3.7 compatibility (#117, thanks @eliduvid)"
|
||||
- "Fix default value for game_ids (#102, thanks @FunnyPocketBook)"
|
||||
|
||||
2.1.0:
|
||||
date: 2022-11-20
|
||||
changes:
|
||||
- "Add chapter list to `info` command"
|
||||
- "Add `download --chapter` option for downloading a single chapter"
|
||||
|
||||
2.0.1:
|
||||
date: 2022-09-09
|
||||
changes:
|
||||
|
@ -3,6 +3,29 @@ twitch-dl changelog
|
||||
|
||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||
|
||||
### [2.1.4 (2024-01-06)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.4)
|
||||
|
||||
* Fix error caused by twitch requiring https for the usher api (thanks
|
||||
@deanpcmad)
|
||||
|
||||
### [2.1.3 (2023-05-07)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.3)
|
||||
|
||||
* Replace client ID with one that works for now (thanks @mwhite34)
|
||||
|
||||
### [2.1.2 (2023-04-18)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.2)
|
||||
|
||||
* Fix error caused by twitch changing the Usher domain (thanks @adsa95)
|
||||
|
||||
### [2.1.1 (2022-11-20)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.1)
|
||||
|
||||
* Fix Python 3.7 compatibility (#117, thanks @eliduvid)
|
||||
* Fix default value for game_ids (#102, thanks @FunnyPocketBook)
|
||||
|
||||
### [2.1.0 (2022-11-20)](https://github.com/ihabunek/twitch-dl/releases/tag/2.1.0)
|
||||
|
||||
* Add chapter list to `info` command
|
||||
* Add `download --chapter` option for downloading a single chapter
|
||||
|
||||
### [2.0.1 (2022-09-09)](https://github.com/ihabunek/twitch-dl/releases/tag/2.0.1)
|
||||
|
||||
* Fix an issue where a temp vod file would be renamed while still being open,
|
||||
|
@ -84,6 +84,11 @@ twitch-dl download <videos> [FLAGS] [OPTIONS]
|
||||
<td class="code">-r, --rate-limit</td>
|
||||
<td>Limit the maximum download speed in bytes per second. Use 'k' and 'm' suffixes for kbps and mbps.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="code">-c, --chapter</td>
|
||||
<td>Download a single chapter of the video. Specify the chapter number or use the flag without a number to display a chapter select prompt.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
pytest
|
||||
pyyaml
|
||||
setuptools
|
||||
twine
|
||||
wheel
|
||||
pyyaml
|
2
setup.py
2
setup.py
@ -11,7 +11,7 @@ makes it faster.
|
||||
|
||||
setup(
|
||||
name="twitch-dl",
|
||||
version="2.0.1",
|
||||
version="2.1.4",
|
||||
description="Twitch downloader",
|
||||
long_description=long_description.strip(),
|
||||
author="Ivan Habunek",
|
||||
|
@ -1,3 +1,3 @@
|
||||
__version__ = "2.0.1"
|
||||
__version__ = "2.1.4"
|
||||
|
||||
CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||
CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp"
|
||||
|
@ -51,9 +51,9 @@ def _select_playlist_interactive(playlists):
|
||||
print_out("\nAvailable qualities:")
|
||||
for n, (name, resolution, uri) in enumerate(playlists):
|
||||
if resolution:
|
||||
print_out("{}) {} [{}]".format(n + 1, name, resolution))
|
||||
print_out("{}) <b>{}</b> <dim>({})</dim>".format(n + 1, name, resolution))
|
||||
else:
|
||||
print_out("{}) {}".format(n + 1, name))
|
||||
print_out("{}) <b>{}</b>".format(n + 1, name))
|
||||
|
||||
no = utils.read_int("Choose quality", min=1, max=len(playlists) + 1, default=1)
|
||||
_, _, uri = playlists[no - 1]
|
||||
@ -282,6 +282,9 @@ def _download_video(video_id, args) -> None:
|
||||
raise ConsoleError("Aborted")
|
||||
args.overwrite = True
|
||||
|
||||
# Chapter select or manual offset
|
||||
start, end = _determine_time_range(video_id, args)
|
||||
|
||||
print_out("<dim>Fetching access token...</dim>")
|
||||
access_token = twitch.get_access_token(video_id, auth_token=args.auth_token)
|
||||
|
||||
@ -298,7 +301,7 @@ def _download_video(video_id, args) -> None:
|
||||
|
||||
base_uri = re.sub("/[^/]+$", "/", playlist_uri)
|
||||
target_dir = _crete_temp_dir(base_uri)
|
||||
vod_paths = _get_vod_paths(playlist, args.start, args.end)
|
||||
vod_paths = _get_vod_paths(playlist, start, end)
|
||||
|
||||
# Save playlists for debugging purposes
|
||||
with open(path.join(target_dir, "playlists.m3u8"), "w") as f:
|
||||
@ -341,3 +344,40 @@ def _download_video(video_id, args) -> None:
|
||||
shutil.rmtree(target_dir)
|
||||
|
||||
print_out("\nDownloaded: <green>{}</green>".format(target))
|
||||
|
||||
|
||||
def _determine_time_range(video_id, args):
|
||||
if args.start or args.end:
|
||||
return args.start, args.end
|
||||
|
||||
if args.chapter is not None:
|
||||
print_out("<dim>Fetching chapters...</dim>")
|
||||
chapters = twitch.get_video_chapters(video_id)
|
||||
|
||||
if not chapters:
|
||||
raise ConsoleError("This video has no chapters")
|
||||
|
||||
if args.chapter == 0:
|
||||
chapter = _choose_chapter_interactive(chapters)
|
||||
else:
|
||||
try:
|
||||
chapter = chapters[args.chapter - 1]
|
||||
except IndexError:
|
||||
raise ConsoleError(f"Chapter {args.chapter} does not exist. This video has {len(chapters)} chapters.")
|
||||
|
||||
print_out(f'Chapter selected: <blue>{chapter["description"]}</blue>\n')
|
||||
start = chapter["positionMilliseconds"] // 1000
|
||||
duration = chapter["durationMilliseconds"] // 1000
|
||||
return start, start + duration
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _choose_chapter_interactive(chapters):
|
||||
print_out("\nChapters:")
|
||||
for index, chapter in enumerate(chapters):
|
||||
duration = utils.format_time(chapter["durationMilliseconds"] // 1000)
|
||||
print_out(f'{index + 1}) <b>{chapter["description"]}</b> <dim>({duration})</dim>')
|
||||
index = utils.read_int("Select a chapter", 1, len(chapters))
|
||||
chapter = chapters[index - 1]
|
||||
return chapter
|
||||
|
@ -20,12 +20,14 @@ def info(args):
|
||||
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
|
||||
print_log("Fetching chapters...")
|
||||
chapters = twitch.get_video_chapters(video_id)
|
||||
|
||||
if args.json:
|
||||
video_json(video, playlists, chapters)
|
||||
else:
|
||||
video_info(video, playlists, chapters)
|
||||
return
|
||||
|
||||
clip_slug = utils.parse_clip_identifier(args.video)
|
||||
if clip_slug:
|
||||
@ -43,7 +45,7 @@ def info(args):
|
||||
raise ConsoleError("Invalid input: {}".format(args.video))
|
||||
|
||||
|
||||
def video_info(video, playlists):
|
||||
def video_info(video, playlists, chapters):
|
||||
print_out()
|
||||
print_video(video)
|
||||
|
||||
@ -52,8 +54,16 @@ def video_info(video, playlists):
|
||||
for p in m3u8.loads(playlists).playlists:
|
||||
print_out("<b>{}</b> {}".format(p.stream_info.video, p.uri))
|
||||
|
||||
if chapters:
|
||||
print_out()
|
||||
print_out("Chapters:")
|
||||
for chapter in chapters:
|
||||
start = utils.format_time(chapter["positionMilliseconds"] // 1000, force_hours=True)
|
||||
duration = utils.format_time(chapter["durationMilliseconds"] // 1000)
|
||||
print_out(f'{start} <b>{chapter["description"]}</b> ({duration})')
|
||||
|
||||
def video_json(video, playlists):
|
||||
|
||||
def video_json(video, playlists, chapters):
|
||||
playlists = m3u8.loads(playlists).playlists
|
||||
|
||||
video["playlists"] = [
|
||||
@ -66,6 +76,8 @@ def video_json(video, playlists):
|
||||
} for p in playlists
|
||||
]
|
||||
|
||||
video["chapters"] = chapters
|
||||
|
||||
print_json(video)
|
||||
|
||||
|
||||
|
@ -22,7 +22,7 @@ class Command(NamedTuple):
|
||||
arguments: List[Argument]
|
||||
|
||||
|
||||
CLIENT_WEBSITE = 'https://github.com/ihabunek/twitch-dl'
|
||||
CLIENT_WEBSITE = "https://twitch-dl.bezdomni.net/"
|
||||
|
||||
|
||||
def time(value: str) -> int:
|
||||
@ -233,6 +233,13 @@ COMMANDS = [
|
||||
"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
|
||||
}),
|
||||
],
|
||||
),
|
||||
Command(
|
||||
|
@ -117,7 +117,7 @@ async def download_all(
|
||||
sources: List[str],
|
||||
targets: List[str],
|
||||
workers: int,
|
||||
/, *,
|
||||
*,
|
||||
rate_limit: Optional[int] = None
|
||||
):
|
||||
progress = Progress(len(sources))
|
||||
|
@ -3,6 +3,7 @@ Twitch API access.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
|
||||
from typing import Dict
|
||||
from twitchdl import CLIENT_ID
|
||||
@ -267,7 +268,7 @@ def get_channel_videos(channel_id, limit, sort, type="archive", game_ids=[], aft
|
||||
return response["data"]["user"]["videos"]
|
||||
|
||||
|
||||
def channel_videos_generator(channel_id, max_videos, sort, type, game_ids=None):
|
||||
def channel_videos_generator(channel_id, max_videos, sort, type, game_ids=[]):
|
||||
def _generator(videos, max_videos):
|
||||
for video in videos["edges"]:
|
||||
if max_videos < 1:
|
||||
@ -334,7 +335,7 @@ def get_playlists(video_id, access_token):
|
||||
"""
|
||||
For a given video return a playlist which contains possible video qualities.
|
||||
"""
|
||||
url = "http://usher.twitch.tv/vod/{}".format(video_id)
|
||||
url = "https://usher.ttvnw.net/vod/{}".format(video_id)
|
||||
|
||||
response = httpx.get(url, params={
|
||||
"nauth": access_token['value'],
|
||||
@ -360,3 +361,32 @@ def get_game_id(name):
|
||||
game = response["data"]["game"]
|
||||
if game:
|
||||
return game["id"]
|
||||
|
||||
|
||||
def get_video_chapters(video_id):
|
||||
query = {
|
||||
"operationName": "VideoPlayer_ChapterSelectButtonVideo",
|
||||
"variables":
|
||||
{
|
||||
"includePrivate": False,
|
||||
"videoID": video_id
|
||||
},
|
||||
"extensions":
|
||||
{
|
||||
"persistedQuery":
|
||||
{
|
||||
"version": 1,
|
||||
"sha256Hash": "8d2793384aac3773beab5e59bd5d6f585aedb923d292800119e03d40cd0f9b41"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = gql_post(json.dumps(query))
|
||||
return list(_chapter_nodes(response["data"]["video"]["moments"]))
|
||||
|
||||
|
||||
def _chapter_nodes(collection):
|
||||
for edge in collection["edges"]:
|
||||
node = edge["node"]
|
||||
del node["moments"]
|
||||
yield node
|
||||
|
@ -40,26 +40,29 @@ def format_duration(total_seconds):
|
||||
return "{} sec".format(seconds)
|
||||
|
||||
|
||||
def format_time(total_seconds):
|
||||
def format_time(total_seconds, force_hours=False):
|
||||
total_seconds = int(total_seconds)
|
||||
hours = total_seconds // 3600
|
||||
remainder = total_seconds % 3600
|
||||
minutes = remainder // 60
|
||||
seconds = total_seconds % 60
|
||||
|
||||
if hours:
|
||||
if hours or force_hours:
|
||||
return f"{hours:02}:{minutes:02}:{seconds:02}"
|
||||
|
||||
return f"{minutes:02}:{seconds:02}"
|
||||
|
||||
|
||||
def read_int(msg, min, max, default):
|
||||
msg = msg + " [default {}]: ".format(default)
|
||||
def read_int(msg, min, max, default=None):
|
||||
if default:
|
||||
msg = msg + f" [default {default}]"
|
||||
|
||||
msg += ": "
|
||||
|
||||
while True:
|
||||
try:
|
||||
val = input(msg)
|
||||
if not val:
|
||||
if default and not val:
|
||||
return default
|
||||
if min <= int(val) <= max:
|
||||
return int(val)
|
||||
|
Reference in New Issue
Block a user