mirror of
https://github.com/ihabunek/twitch-dl
synced 2024-08-30 18:32:25 +00:00
Compare commits
16 Commits
Author | SHA1 | Date | |
---|---|---|---|
3c99e9975b | |||
f807d4324b | |||
68a8b70948 | |||
932b7750b9 | |||
aa5f17cbdb | |||
0fff0a4de1 | |||
9dc67a7ff1 | |||
3e7f310e36 | |||
cbb0d6cfbd | |||
46d2654cfa | |||
0f54c527be | |||
8133d93436 | |||
9345dd966f | |||
e9bd706194 | |||
357379a6a1 | |||
0c88de3862 |
24
CHANGELOG.md
Normal file
24
CHANGELOG.md
Normal file
@ -0,0 +1,24 @@
|
||||
Twitch Downloader change log
|
||||
============================
|
||||
|
||||
1.3.0 (2019-08-13)
|
||||
------------------
|
||||
|
||||
* Add `--sort` and `--offset` options to `videos` command, allows paging (#7)
|
||||
* Show video URL in `videos` command output
|
||||
|
||||
1.2.0 (2019-07-05)
|
||||
------------------
|
||||
|
||||
* Add `--format` option to `download` command for specifying the output format (#6)
|
||||
* Add `--version` option for printing program version
|
||||
|
||||
1.1.0 (2019-06-06)
|
||||
------------------
|
||||
|
||||
* Allow limiting download by start and end time
|
||||
|
||||
1.0.0 (2019-04-30)
|
||||
------------------
|
||||
|
||||
* Initial release
|
13
Makefile
13
Makefile
@ -1,22 +1,15 @@
|
||||
default : clean dist
|
||||
|
||||
dist :
|
||||
@echo "\nMaking source"
|
||||
@echo "-------------"
|
||||
@python setup.py sdist
|
||||
|
||||
@echo "\nMaking wheel"
|
||||
@echo "-------------"
|
||||
@python setup.py bdist_wheel --universal
|
||||
|
||||
@echo "\nDone."
|
||||
python setup.py sdist --formats=gztar,zip
|
||||
python setup.py bdist_wheel
|
||||
|
||||
clean :
|
||||
find . -name "*pyc" | xargs rm -rf $1
|
||||
rm -rf build dist MANIFEST htmlcov deb_dist twitch-dl*.tar.gz twitch-dl.1.man
|
||||
|
||||
publish :
|
||||
twine upload dist/*
|
||||
twine upload dist/*.tar.gz dist/*.whl
|
||||
|
||||
coverage:
|
||||
py.test --cov=toot --cov-report html tests/
|
||||
|
7
setup.py
7
setup.py
@ -5,20 +5,21 @@ from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='twitch-dl',
|
||||
version='0.1.0',
|
||||
version='1.3.0',
|
||||
description='Twitch downloader',
|
||||
long_description="A simple script for downloading videos from Twitch",
|
||||
long_description="Quickly download videos from Twitch",
|
||||
author='Ivan Habunek',
|
||||
author_email='ivan@habunek.com',
|
||||
url='https://github.com/ihabunek/twitch-dl/',
|
||||
keywords='twitch vod video download',
|
||||
license='GPLv3',
|
||||
classifiers=[
|
||||
'Development Status :: 4 - Beta',
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
],
|
||||
packages=['twitchdl'],
|
||||
python_requires='>=3.5',
|
||||
|
@ -36,6 +36,18 @@ Download by ID:
|
||||
twitch-dl download 377220226
|
||||
```
|
||||
|
||||
Specify output format:
|
||||
|
||||
```
|
||||
twitch-dl download --format=avi 377220226
|
||||
```
|
||||
|
||||
Partial download by setting start and end time (hh:mm or hh:mm:ss):
|
||||
|
||||
```
|
||||
twitch-dl download --start=00:10 --end=02:15 377220226
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
youtube-dl(1)
|
||||
|
@ -1,3 +1,3 @@
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
CLIENT_ID = "miwy5zk23vh2he94san0bzj5ks1r0p"
|
||||
|
@ -69,12 +69,21 @@ def _print_video(video):
|
||||
print_out("<green>{}</green>".format(video["title"]))
|
||||
print_out("<cyan>{}</cyan> playing <cyan>{}</cyan>".format(name, video['game']))
|
||||
print_out("Published <cyan>{}</cyan> Length: <cyan>{}</cyan> ".format(published_at, length))
|
||||
print_out("<i>{}</i>".format(video["url"]))
|
||||
|
||||
|
||||
def videos(channel_name, **kwargs):
|
||||
videos = twitch.get_channel_videos(channel_name)
|
||||
def videos(channel_name, limit, offset, sort, **kwargs):
|
||||
videos = twitch.get_channel_videos(channel_name, limit, offset, sort)
|
||||
|
||||
print("Found {} videos".format(videos["_total"]))
|
||||
count = len(videos['videos'])
|
||||
if not count:
|
||||
print_out("No videos found")
|
||||
return
|
||||
|
||||
first = offset + 1
|
||||
last = offset + len(videos['videos'])
|
||||
total = videos["_total"]
|
||||
print_out("<yellow>Showing videos {}-{} of {}</yellow>".format(first, last, total))
|
||||
|
||||
for video in videos['videos']:
|
||||
_print_video(video)
|
||||
@ -170,9 +179,12 @@ def parse_video_id(video_id):
|
||||
raise ConsoleError("Invalid video ID given, expected integer ID or Twitch URL")
|
||||
|
||||
|
||||
def download(video_id, max_workers, format='mkv', **kwargs):
|
||||
def download(video_id, max_workers, format='mkv', start=None, end=None, **kwargs):
|
||||
video_id = parse_video_id(video_id)
|
||||
|
||||
if start and end and end <= start:
|
||||
raise ConsoleError("End time must be greater than start time")
|
||||
|
||||
print_out("Looking up video...")
|
||||
video = twitch.get_video(video_id)
|
||||
|
||||
@ -187,14 +199,17 @@ def download(video_id, max_workers, format='mkv', **kwargs):
|
||||
quality, playlist_url = _select_quality(playlists)
|
||||
|
||||
print_out("\nFetching playlist...")
|
||||
base_url, filenames = twitch.get_playlist_urls(playlist_url)
|
||||
base_url, filenames = twitch.get_playlist_urls(playlist_url, start, end)
|
||||
|
||||
if not filenames:
|
||||
raise ConsoleError("No vods matched, check your start and end times")
|
||||
|
||||
# Create a temp dir to store downloads if it doesn't exist
|
||||
directory = '{}/twitch-dl/{}/{}'.format(tempfile.gettempdir(), video_id, quality)
|
||||
pathlib.Path(directory).mkdir(parents=True, exist_ok=True)
|
||||
print_out("Download dir: {}".format(directory))
|
||||
|
||||
print_out("Downloading VODs with {} workers...".format(max_workers))
|
||||
print_out("Downloading {} VODs using {} workers...".format(len(filenames), max_workers))
|
||||
paths = _download_files(base_url, directory, filenames, max_workers)
|
||||
|
||||
print_out("\n\nJoining files...")
|
||||
|
@ -1,17 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from argparse import ArgumentParser
|
||||
import sys
|
||||
|
||||
from argparse import ArgumentParser, ArgumentTypeError
|
||||
from collections import namedtuple
|
||||
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.output import print_err
|
||||
from . import commands
|
||||
from . import commands, __version__
|
||||
|
||||
|
||||
Command = namedtuple("Command", ["name", "description", "arguments"])
|
||||
|
||||
CLIENT_WEBSITE = 'https://github.com/ihabunek/twitch-dl'
|
||||
|
||||
|
||||
def time(value):
|
||||
"""Parse a time string (hh:mm or hh:mm:ss) to number of seconds."""
|
||||
parts = [int(p) for p in value.split(":")]
|
||||
|
||||
if not 2 <= len(parts) <= 3:
|
||||
raise ArgumentTypeError()
|
||||
|
||||
hours = parts[0]
|
||||
minutes = parts[1]
|
||||
seconds = parts[2] if len(parts) > 2 else 0
|
||||
|
||||
if hours < 0 or not (0 <= minutes <= 59) or not (0 <= seconds <= 59):
|
||||
raise ArgumentTypeError()
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
|
||||
COMMANDS = [
|
||||
Command(
|
||||
name="videos",
|
||||
@ -21,6 +41,22 @@ COMMANDS = [
|
||||
"help": "channel name",
|
||||
"type": str,
|
||||
}),
|
||||
(["-l", "--limit"], {
|
||||
"help": "Number of videos to fetch (default 10, max 100)",
|
||||
"type": int,
|
||||
"default": 10,
|
||||
}),
|
||||
(["-o", "--offset"], {
|
||||
"help": "Offset for pagination of results. (default 0)",
|
||||
"type": int,
|
||||
"default": 0,
|
||||
}),
|
||||
(["-s", "--sort"], {
|
||||
"help": "Sorting order of videos. (default: time)",
|
||||
"type": str,
|
||||
"choices": ["views", "time"],
|
||||
"default": "time",
|
||||
}),
|
||||
],
|
||||
),
|
||||
Command(
|
||||
@ -32,10 +68,27 @@ COMMANDS = [
|
||||
"type": str,
|
||||
}),
|
||||
(["-w", "--max_workers"], {
|
||||
"help": "maximal number of threads for downloading vods concurrently (default 5)",
|
||||
"help": "maximal number of threads for downloading vods "
|
||||
"concurrently (default 5)",
|
||||
"type": int,
|
||||
"default": 20,
|
||||
}),
|
||||
(["-s", "--start"], {
|
||||
"help": "Download video from this time (hh:mm or hh:mm:ss)",
|
||||
"type": time,
|
||||
"default": None,
|
||||
}),
|
||||
(["-e", "--end"], {
|
||||
"help": "Download video up to this time (hh:mm or hh:mm:ss)",
|
||||
"type": time,
|
||||
"default": None,
|
||||
}),
|
||||
(["-f", "--format"], {
|
||||
"help": "Video format to convert into, passed to ffmpeg as the "
|
||||
"target file extension (default: mkv)",
|
||||
"type": str,
|
||||
"default": "mkv",
|
||||
}),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -58,6 +111,8 @@ def get_parser():
|
||||
description = "A script for downloading videos from Twitch"
|
||||
|
||||
parser = ArgumentParser(prog='twitch-dl', description=description, epilog=CLIENT_WEBSITE)
|
||||
parser.add_argument("--version", help="show version number", action='store_true')
|
||||
|
||||
subparsers = parser.add_subparsers(title="commands")
|
||||
|
||||
for command in COMMANDS:
|
||||
@ -76,6 +131,10 @@ def main():
|
||||
parser = get_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
print("twitch-dl v{}".format(__version__))
|
||||
return
|
||||
|
||||
if "func" not in args:
|
||||
parser.print_help()
|
||||
return
|
||||
@ -84,3 +143,4 @@ def main():
|
||||
args.func(**args.__dict__)
|
||||
except ConsoleError as e:
|
||||
print_err(e)
|
||||
sys.exit(1)
|
||||
|
@ -5,6 +5,8 @@ import re
|
||||
|
||||
START_CODES = {
|
||||
'bold': '\033[1m',
|
||||
'i': '\033[3m',
|
||||
'u': '\033[4m',
|
||||
'red': '\033[31m',
|
||||
'green': '\033[32m',
|
||||
'yellow': '\033[33m',
|
||||
|
@ -1,6 +1,8 @@
|
||||
import re
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
|
||||
|
||||
def parse_playlists(data):
|
||||
@ -20,9 +22,43 @@ def parse_playlists(data):
|
||||
return playlists
|
||||
|
||||
|
||||
def parse_playlist(url, data):
|
||||
def _get_files(playlist, start, end):
|
||||
matches = re.findall(r"#EXTINF:(\d+)(\.\d+)?,.*?\s+(\d+.ts)", playlist)
|
||||
vod_start = 0
|
||||
for m in matches:
|
||||
filename = m[2]
|
||||
vod_duration = int(m[0])
|
||||
vod_end = vod_start + vod_duration
|
||||
|
||||
# `vod_end > start` is used here becuase it's better to download a bit
|
||||
# more than a bit less, similar for the end condition
|
||||
start_condition = not start or vod_end > start
|
||||
end_condition = not end or vod_start < end
|
||||
|
||||
if start_condition and end_condition:
|
||||
yield filename
|
||||
|
||||
vod_start = vod_end
|
||||
|
||||
|
||||
def parse_playlist(url, playlist, start, end):
|
||||
base_url = re.sub("/[^/]+$", "/{}", url)
|
||||
|
||||
filenames = [line for line in data.split() if re.match(r"\d+\.ts", line)]
|
||||
match = re.search(r"#EXT-X-TWITCH-TOTAL-SECS:(\d+)(.\d+)?", playlist)
|
||||
total_seconds = int(match.group(1))
|
||||
|
||||
return base_url, filenames
|
||||
# Now that video duration is known, validate start and end max values
|
||||
if start and start > total_seconds:
|
||||
raise ConsoleError("Start time {} greater than video duration {}".format(
|
||||
timedelta(seconds=start),
|
||||
timedelta(seconds=total_seconds)
|
||||
))
|
||||
|
||||
if end and end > total_seconds:
|
||||
raise ConsoleError("End time {} greater than video duration {}".format(
|
||||
timedelta(seconds=end),
|
||||
timedelta(seconds=total_seconds)
|
||||
))
|
||||
|
||||
files = list(_get_files(playlist, start, end))
|
||||
return base_url, files
|
||||
|
@ -1,6 +1,7 @@
|
||||
import requests
|
||||
|
||||
from twitchdl import CLIENT_ID
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.parse import parse_playlists, parse_playlist
|
||||
|
||||
|
||||
@ -8,6 +9,10 @@ def authenticated_get(url, params={}):
|
||||
headers = {'Client-ID': CLIENT_ID}
|
||||
|
||||
response = requests.get(url, params, headers=headers)
|
||||
if response.status_code == 400:
|
||||
data = response.json()
|
||||
raise ConsoleError(data["message"])
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response
|
||||
@ -22,7 +27,7 @@ def get_video(video_id):
|
||||
return authenticated_get(url).json()
|
||||
|
||||
|
||||
def get_channel_videos(channel_name, limit=20):
|
||||
def get_channel_videos(channel_name, limit, offset, sort):
|
||||
"""
|
||||
https://dev.twitch.tv/docs/v5/reference/channels#get-channel-videos
|
||||
"""
|
||||
@ -31,6 +36,8 @@ def get_channel_videos(channel_name, limit=20):
|
||||
return authenticated_get(url, {
|
||||
"broadcast_type": "archive",
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"sort": sort,
|
||||
}).json()
|
||||
|
||||
|
||||
@ -56,10 +63,9 @@ def get_playlists(video_id, access_token):
|
||||
return parse_playlists(data)
|
||||
|
||||
|
||||
def get_playlist_urls(url):
|
||||
def get_playlist_urls(url, start, end):
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.content.decode('utf-8')
|
||||
|
||||
return parse_playlist(url, data)
|
||||
return parse_playlist(url, data, start, end)
|
||||
|
Reference in New Issue
Block a user