Compare commits

..

16 Commits
1.0.0 ... 1.3.0

Author SHA1 Message Date
3c99e9975b Bump version, changelog 2019-08-13 12:31:44 +02:00
f807d4324b Style the url in video list 2019-08-13 12:29:42 +02:00
68a8b70948 Add offset and sort options to videos command
fixes #7
2019-08-13 12:25:25 +02:00
932b7750b9 Print video URL 2019-08-12 15:21:57 +02:00
aa5f17cbdb Show errors returned via HTTP 400 2019-08-12 15:14:13 +02:00
0fff0a4de1 Exit with nonzero code on error 2019-08-12 13:47:48 +02:00
9dc67a7ff1 Bump version, add changelog 2019-07-05 13:15:59 +02:00
3e7f310e36 Add --version option to print program version 2019-07-05 13:14:22 +02:00
cbb0d6cfbd Allow specifying the output format
i.e. the output file extension passed to ffmpeg
2019-07-05 13:04:09 +02:00
46d2654cfa Bump to stable 2019-06-06 11:48:28 +02:00
0f54c527be Remove redundant bdist_wheel setting
py3 is the default
2019-06-06 11:47:04 +02:00
8133d93436 Don't make universal wheels (py2 not supported) 2019-06-06 11:44:56 +02:00
9345dd966f Bump version, add changelog 2019-06-06 11:10:21 +02:00
e9bd706194 Allow limiting download by start and end time 2019-06-06 11:06:33 +02:00
357379a6a1 Update Makefile 2019-06-06 09:28:23 +02:00
0c88de3862 Bump version 2019-04-30 13:47:16 +02:00
11 changed files with 179 additions and 32 deletions

24
CHANGELOG.md Normal file
View 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

View File

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

View File

@ -1,2 +0,0 @@
[bdist_wheel]
universal=1

View File

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

View File

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

View File

@ -1,3 +1,3 @@
__version__ = "0.1.0"
__version__ = "1.3.0"
CLIENT_ID = "miwy5zk23vh2he94san0bzj5ks1r0p"

View File

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

View File

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

View File

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

View File

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

View File

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