mirror of
https://github.com/ihabunek/twitch-dl
synced 2024-08-30 18:32:25 +00:00
Compare commits
78 Commits
Author | SHA1 | Date | |
---|---|---|---|
e0681ab53c | |||
728e631623 | |||
9d19acbe6d | |||
baeaedaa54 | |||
9e2bbd7e39 | |||
dbee7cdc52 | |||
548a9350ba | |||
2380dc5a35 | |||
a7340f178f | |||
838611b834 | |||
cf8d13e80e | |||
6108b15587 | |||
bb2de4af96 | |||
678bf11a8a | |||
69de08e2ba | |||
1cb4598a71 | |||
02e4cdaff6 | |||
c2e9ab9382 | |||
739460bed1 | |||
c92ba5e183 | |||
f4ab045cdc | |||
cdc0495cf1 | |||
c510d5aae4 | |||
2abc56213e | |||
bb16108572 | |||
b982cba566 | |||
f4d442c118 | |||
eecd098f18 | |||
041689bee9 | |||
a245ffb6a4 | |||
ac37f179ef | |||
5b200a2cb7 | |||
bf2a4558f4 | |||
772faa5901 | |||
04ddadef26 | |||
bbed398cf6 | |||
d8863ac695 | |||
4edf299780 | |||
78295a492c | |||
f456d04de6 | |||
8222df3670 | |||
65663d3505 | |||
706e42d197 | |||
3cfa05a3ee | |||
4f62a26c30 | |||
2171a9e08e | |||
15ca684286 | |||
fd56a16c41 | |||
4885c6a3b7 | |||
2cf66c022c | |||
717f634dda | |||
169f15ca30 | |||
58458553bc | |||
cabc8ff327 | |||
d22fd74357 | |||
4241ab5d67 | |||
94e9f6aa80 | |||
b014d94366 | |||
ea01ef3d99 | |||
2118cd8825 | |||
6c28dd2f5e | |||
e3dde90870 | |||
c628757ac0 | |||
5e97b439a7 | |||
07f3a2fa48 | |||
96f13e9cf7 | |||
c9547435df | |||
042d35ba1e | |||
ebc754072d | |||
cb00accd6a | |||
64157c1ef6 | |||
6a8da3b01b | |||
e29d42e9ef | |||
100aa53b84 | |||
e384f26444 | |||
000754af8c | |||
6813bb51b4 | |||
34b0592cf3 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -11,3 +11,5 @@ tmp/
|
||||
/htmlcov
|
||||
/twitch-dl-*.tar.gz
|
||||
/twitch-dl.1.man
|
||||
/bundle
|
||||
/*.pyz
|
||||
|
109
CHANGELOG.md
109
CHANGELOG.md
@ -1,6 +1,115 @@
|
||||
Twitch Downloader change log
|
||||
============================
|
||||
|
||||
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)
|
||||
-------------------
|
||||
|
||||
* Fixed clip download issue (#45)
|
||||
|
||||
1.13.0 (2020-11-10)
|
||||
-------------------
|
||||
|
||||
* Added `clips` command for listing and batch downloading clips (#26)
|
||||
|
||||
1.12.1 (2020-09-29)
|
||||
-------------------
|
||||
|
||||
* Fix bug introduced in previous version which broke joining
|
||||
|
||||
1.12.0 (2020-09-29)
|
||||
-------------------
|
||||
|
||||
* Added `source` as alias for best available quality (#33)
|
||||
* Added `--no-join` option to `download` to skip ffmpeg join (#36)
|
||||
* Added `--overwrite` option to `download` to overwrite target without prompting
|
||||
for confirmation (#37)
|
||||
* Added `--pager` option to `videos`, don't page by default (#30)
|
||||
|
||||
1.11.0 (2020-09-03)
|
||||
-------------------
|
||||
|
||||
* Make downloading more robust, fixes issues with some VODs (#35)
|
||||
* Bundle twitch-dl to a standalone archive, simplifying installation, see
|
||||
installation instructions in README
|
||||
|
||||
1.10.2 (2020-08-11)
|
||||
-------------------
|
||||
|
||||
* Fix version number displayed by `twitch-dl --version` (#29)
|
||||
|
||||
1.10.1 (2020-08-09)
|
||||
-------------------
|
||||
|
||||
* Fix videos incorrectly identified as clips (#28)
|
||||
* Make download command work with video URLs lacking "www" before "twitch.tv"
|
||||
* Print an error when video or clip is not found instead of an exception trace
|
||||
|
||||
1.10.0 (2020-08-07)
|
||||
-------------------
|
||||
|
||||
* Add `--quality` option to `download` command, allows specifying the video
|
||||
quality to download. In this case, twitch-dl will require no user input. (#22)
|
||||
* Fix download of clips which contain numbers in their slug (#24)
|
||||
* Fix URL to video displayed by `videos` command (it was missing /videos/)
|
||||
|
||||
1.9.0 (2020-06-10)
|
||||
------------------
|
||||
|
||||
* **Breaking**: wrongly named `--max_workers` option changed to `--max-workers`.
|
||||
The shorthand option `-w` remains the same.
|
||||
* Fix bug where `videos` command would crash if there was no game info (#21)
|
||||
* Allow unicode characters in filenames, no longer strips e.g. cyrillic script
|
||||
|
||||
1.8.0 (2020-05-17)
|
||||
------------------
|
||||
|
||||
* Fix videos command (#18)
|
||||
* **Breaking**: `videos` command no longer takes the `--offset` parameter due to
|
||||
API changes
|
||||
* Add paging to `videos` command to replace offset
|
||||
* Add `--game` option to `videos` command to filter by game
|
||||
|
||||
1.7.0 (2020-04-25)
|
||||
------------------
|
||||
|
||||
* Support for specifying broadcast type when listing videos (#13)
|
||||
|
||||
1.6.0 (2020-04-11)
|
||||
------------------
|
||||
|
||||
* Support for downloading clips (#15)
|
||||
|
||||
1.5.1 (2020-04-11)
|
||||
------------------
|
||||
|
||||
* Fix VOD naming issue (#12)
|
||||
* Nice console output while downloading
|
||||
|
||||
1.5.0 (2020-04-10)
|
||||
------------------
|
||||
|
||||
* Fix video downloads after Twitch deprecated access token access
|
||||
* Don't print errors when retrying download, only if all fails
|
||||
|
||||
1.4.0 (2019-08-23)
|
||||
------------------
|
||||
|
||||
* Fix usage of deprecated v3 API
|
||||
* Use m3u8 lib for parsing playlists
|
||||
* Add `--keep` option not preserve downloaded VODs
|
||||
|
||||
1.3.1 (2019-08-13)
|
||||
------------------
|
||||
|
||||
|
16
Makefile
16
Makefile
@ -6,7 +6,18 @@ dist :
|
||||
|
||||
clean :
|
||||
find . -name "*pyc" | xargs rm -rf $1
|
||||
rm -rf build dist MANIFEST htmlcov deb_dist twitch-dl*.tar.gz twitch-dl.1.man
|
||||
rm -rf build dist bundle MANIFEST htmlcov deb_dist twitch-dl*.tar.gz twitch-dl.1.man
|
||||
|
||||
bundle:
|
||||
mkdir bundle
|
||||
cp twitchdl/__main__.py bundle
|
||||
pip install . --target=bundle
|
||||
rm -rf bundle/*.dist-info
|
||||
find bundle/ -type d -name "__pycache__" -exec rm -rf {} +
|
||||
python -m zipapp \
|
||||
--python "/usr/bin/env python3" \
|
||||
--output twitch-dl.`git describe`.pyz bundle \
|
||||
--compress
|
||||
|
||||
publish :
|
||||
twine upload dist/*.tar.gz dist/*.whl
|
||||
@ -19,3 +30,6 @@ deb:
|
||||
|
||||
man:
|
||||
scdoc < twitch-dl.1.scd > twitch-dl.1.man
|
||||
|
||||
test:
|
||||
pytest
|
||||
|
208
README.md
208
README.md
@ -1,10 +1,10 @@
|
||||
Twitch Downloader
|
||||
=================
|
||||
|
||||
A simple CLI tool for downloading videos from Twitch.
|
||||
CLI tool for downloading videos from twitch.tv
|
||||
|
||||
Inspired by youtube-dl but improves upon it by using multiple concurrent
|
||||
connections to make the download faster.
|
||||
Inspired by [youtube-dl](https://youtube-dl.org/) but improves upon it by using
|
||||
multiple concurrent connections to make the download faster.
|
||||
|
||||
Resources
|
||||
---------
|
||||
@ -13,43 +13,207 @@ Resources
|
||||
* Issues: https://github.com/ihabunek/twitch-dl/issues
|
||||
* Python package: https://pypi.org/project/twitch-dl/
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* Python 3.5+
|
||||
* [ffmpeg](https://ffmpeg.org/download.html), installed and on the system path
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
### Download standalone archive
|
||||
|
||||
Go to the [latest release](https://github.com/ihabunek/twitch-dl/releases/latest)
|
||||
and download the `twitch-dl.<version>.pyz` archive.
|
||||
|
||||
Run the archive by either:
|
||||
|
||||
a) passing it to python:
|
||||
|
||||
```
|
||||
python3 twitch-dl.1.13.0.pyz --help
|
||||
```
|
||||
|
||||
b) making it executable and invoking it directly (linux specific):
|
||||
|
||||
```
|
||||
chmod +x twitch-dl.1.13.0.pyz
|
||||
./twitch-dl.1.13.0.pyz --help
|
||||
```
|
||||
|
||||
Feel free to rename the archive to something more managable, like `twitch-dl`.
|
||||
|
||||
To upgrade to a newer version, repeat the process with the newer release.
|
||||
|
||||
### From PYPI using pipx
|
||||
|
||||
**pipx** is a tool which installs python apps into isolated environments, which
|
||||
prevents all kinds of problems later so it's the suggested way to install
|
||||
twitch-dl from PYPI.
|
||||
|
||||
Install pipx as described in
|
||||
[pipx install docs](https://pipxproject.github.io/pipx/installation/).
|
||||
|
||||
Install twitch-dl:
|
||||
|
||||
```
|
||||
pipx install twitch-dl
|
||||
```
|
||||
|
||||
Check installation worked:
|
||||
|
||||
```
|
||||
twitch-dl --help
|
||||
```
|
||||
|
||||
If twitch-dl executable is not found, check that the pipx binary location (by
|
||||
default `~/.local/bin`) is in your PATH.
|
||||
|
||||
To upgrade twitch-dl to the latest version:
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```
|
||||
twitch-dl videos bananasaurus_rex
|
||||
```
|
||||
|
||||
Yields (trimmed):
|
||||
Use the `--game` option to specify one or more games to show:
|
||||
|
||||
```
|
||||
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
|
||||
twitch-dl videos --game "doom eternal" --game "cave story" bananasaurus_rex
|
||||
```
|
||||
|
||||
Download a stream by ID or URL:
|
||||
### Downloading videos
|
||||
|
||||
Download a video by ID or URL:
|
||||
|
||||
```
|
||||
twitch-dl download 221837124
|
||||
twitch-dl download https://www.twitch.tv/videos/221837124
|
||||
```
|
||||
|
||||
Specify video quality to download:
|
||||
|
||||
```
|
||||
twitch-dl download -q 720p 221837124
|
||||
```
|
||||
|
||||
Setting quality to `source` will download the best available quality:
|
||||
|
||||
```
|
||||
twitch-dl download -q source 221837124
|
||||
```
|
||||
|
||||
### Listing clips
|
||||
|
||||
List clips for the given period:
|
||||
|
||||
```
|
||||
twitch-dl clips bananasaurus_rex --period last_week
|
||||
```
|
||||
|
||||
Supported periods are: `last_day`, `last_week`, `last_month`, `all_time`.
|
||||
|
||||
For listing a large number of clips, it's nice to page them:
|
||||
|
||||
```
|
||||
twitch-dl clips bananasaurus_rex --period all_time --limit 10 --pager
|
||||
```
|
||||
|
||||
This will show 10 clips at a time and ask to continue.
|
||||
|
||||
### Downloading clips
|
||||
|
||||
Download a clip by slug or URL:
|
||||
|
||||
```
|
||||
twitch-dl download VenomousTameWormHumbleLife
|
||||
twitch-dl download https://www.twitch.tv/bananasaurus_rex/clip/VenomousTameWormHumbleLife
|
||||
```
|
||||
|
||||
Specify clip quality to download:
|
||||
|
||||
```
|
||||
twitch-dl download -q 720 VenomousTameWormHumbleLife
|
||||
```
|
||||
|
||||
Note that twitch names for clip qualities have no trailing "p".
|
||||
|
||||
### Batch downloading clips
|
||||
|
||||
It's possible to download all clips for a given period:
|
||||
|
||||
```
|
||||
twitch-dl clips bananasaurus_rex --period last_week --download
|
||||
```
|
||||
|
||||
Clips are downloaded in source quality.
|
||||
|
||||
A note about clips
|
||||
------------------
|
||||
|
||||
Currently it doesn't seem to be possible to get a list of clips ordered by time
|
||||
of creation, only by view count. Clips with the same view count seem to be
|
||||
returned in random order. This can break paging resulting in duplicate clips
|
||||
listed or clips missed.
|
||||
|
||||
When batch downloading a large number of clips (over 100), it's possible that
|
||||
some will be missed.
|
||||
|
||||
Temporary files
|
||||
---------------
|
||||
|
||||
By default, twitch-dl will download VODs to your systems temp dir (e.g. `/tmp/`
|
||||
on Linux). To change the location where the files are downloaded you can set
|
||||
the `TMP` environment variable, e.g.
|
||||
|
||||
```
|
||||
TMP=/my/tmp/path/ twitch-dl download 221837124
|
||||
```
|
||||
|
||||
Man page
|
||||
--------
|
||||
|
||||
@ -64,6 +228,6 @@ make man
|
||||
License
|
||||
-------
|
||||
|
||||
Copyright 2018 Ivan Habunek <ivan@habunek.com>
|
||||
Copyright 2018-2020 Ivan Habunek <ivan@habunek.com>
|
||||
|
||||
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
@ -1,5 +1,3 @@
|
||||
pytest-cov~=2.4.0
|
||||
pytest~=3.0.0
|
||||
stdeb~=0.8.5
|
||||
twine~=1.8.1
|
||||
wheel~=0.29.0
|
||||
pytest
|
||||
twine
|
||||
wheel
|
||||
|
15
setup.py
15
setup.py
@ -1,13 +1,19 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from setuptools import setup
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
long_description = """
|
||||
Quickly download videos from twitch.tv.
|
||||
|
||||
Works simliarly to youtube-dl but downloads multiple VODs in parallel which
|
||||
makes it faster.
|
||||
"""
|
||||
|
||||
setup(
|
||||
name='twitch-dl',
|
||||
version='1.3.1',
|
||||
version='1.14.1',
|
||||
description='Twitch downloader',
|
||||
long_description="Quickly download videos from Twitch",
|
||||
long_description=long_description.strip(),
|
||||
author='Ivan Habunek',
|
||||
author_email='ivan@habunek.com',
|
||||
url='https://github.com/ihabunek/twitch-dl/',
|
||||
@ -21,9 +27,10 @@ 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",
|
||||
"requests>=2.13,<3.0",
|
||||
],
|
||||
entry_points={
|
||||
|
45
tests/test_download.py
Normal file
45
tests/test_download.py
Normal file
@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
|
||||
from unittest.mock import patch
|
||||
from twitchdl.commands import download
|
||||
from collections import namedtuple
|
||||
|
||||
Args = namedtuple("args", ["video"])
|
||||
|
||||
|
||||
TEST_VIDEO_PATTERNS = [
|
||||
("702689313", "702689313"),
|
||||
("702689313", "https://twitch.tv/videos/702689313"),
|
||||
("702689313", "https://www.twitch.tv/videos/702689313"),
|
||||
]
|
||||
|
||||
TEST_CLIP_PATTERNS = {
|
||||
("AbrasivePlayfulMangoMau5", "AbrasivePlayfulMangoMau5"),
|
||||
("AbrasivePlayfulMangoMau5", "https://clips.twitch.tv/AbrasivePlayfulMangoMau5"),
|
||||
("AbrasivePlayfulMangoMau5", "https://www.twitch.tv/dracul1nx/clip/AbrasivePlayfulMangoMau5"),
|
||||
("AbrasivePlayfulMangoMau5", "https://twitch.tv/dracul1nx/clip/AbrasivePlayfulMangoMau5"),
|
||||
("HungryProudRadicchioDoggo", "HungryProudRadicchioDoggo"),
|
||||
("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"),
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
@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()
|
@ -24,13 +24,13 @@ List recent videos from bananasaurus\_rex's channel:
|
||||
twitch-dl videos bananasaurus_rex
|
||||
```
|
||||
|
||||
Download by URL:
|
||||
Download video by URL:
|
||||
|
||||
```
|
||||
twitch-dl download https://www.twitch.tv/videos/377220226
|
||||
```
|
||||
|
||||
Download by ID:
|
||||
Download video by ID:
|
||||
|
||||
```
|
||||
twitch-dl download 377220226
|
||||
@ -48,6 +48,21 @@ Partial download by setting start and end time (hh:mm or hh:mm:ss):
|
||||
twitch-dl download --start=00:10 --end=02:15 377220226
|
||||
```
|
||||
|
||||
Download clip by URL:
|
||||
|
||||
```
|
||||
twitch-dl download https://www.twitch.tv/bananasaurus_rex/clip/VenomousTameWormHumbleLife
|
||||
```
|
||||
|
||||
Download clip by slug:
|
||||
|
||||
```
|
||||
twitch-dl download VenomousTameWormHumbleLife
|
||||
```
|
||||
|
||||
Note that clips are a single download, and don't benefit from the paralelism
|
||||
used when downloading videos.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
youtube-dl(1)
|
||||
|
@ -1,3 +1,3 @@
|
||||
__version__ = "1.3.1"
|
||||
__version__ = "1.14.1"
|
||||
|
||||
CLIENT_ID = "miwy5zk23vh2he94san0bzj5ks1r0p"
|
||||
CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||
|
3
twitchdl/__main__.py
Normal file
3
twitchdl/__main__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from twitchdl.console import main
|
||||
|
||||
main()
|
@ -1,223 +0,0 @@
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from functools import partial
|
||||
|
||||
from twitchdl import twitch
|
||||
from twitchdl.download import download_file
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.output import print_out
|
||||
from twitchdl.utils import slugify
|
||||
|
||||
|
||||
def read_int(msg, min, max, default):
|
||||
msg = msg + " [default {}]: ".format(default)
|
||||
|
||||
while True:
|
||||
try:
|
||||
val = input(msg)
|
||||
if not val:
|
||||
return default
|
||||
if min <= int(val) <= max:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def format_size(bytes_):
|
||||
if bytes_ < 1024:
|
||||
return str(bytes_)
|
||||
|
||||
kilo = bytes_ / 1024
|
||||
if kilo < 1024:
|
||||
return "{:.1f}K".format(kilo)
|
||||
|
||||
mega = kilo / 1024
|
||||
if mega < 1024:
|
||||
return "{:.1f}M".format(mega)
|
||||
|
||||
return "{:.1f}G".format(mega / 1024)
|
||||
|
||||
|
||||
def format_duration(total_seconds):
|
||||
total_seconds = int(total_seconds)
|
||||
hours = total_seconds // 3600
|
||||
remainder = total_seconds % 3600
|
||||
minutes = remainder // 60
|
||||
seconds = total_seconds % 60
|
||||
|
||||
if hours:
|
||||
return "{} h {} min".format(hours, minutes)
|
||||
|
||||
if minutes:
|
||||
return "{} min {} sec".format(minutes, seconds)
|
||||
|
||||
return "{} sec".format(seconds)
|
||||
|
||||
|
||||
def _print_video(video):
|
||||
published_at = video['published_at'].replace('T', ' @ ').replace('Z', '')
|
||||
length = format_duration(video['length'])
|
||||
name = video['channel']['display_name']
|
||||
|
||||
print_out("\n<bold>{}</bold>".format(video['_id'][1:]))
|
||||
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, limit, offset, sort, **kwargs):
|
||||
videos = twitch.get_channel_videos(channel_name, limit, offset, sort)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _select_quality(playlists):
|
||||
print_out("\nAvailable qualities:")
|
||||
for no, v in playlists.items():
|
||||
print_out("{}) {}".format(no, v[0]))
|
||||
|
||||
keys = list(playlists.keys())
|
||||
no = read_int("Choose quality", min=min(keys), max=max(keys), default=keys[0])
|
||||
|
||||
return playlists[no]
|
||||
|
||||
|
||||
def _print_progress(futures):
|
||||
counter = 1
|
||||
total = len(futures)
|
||||
total_size = 0
|
||||
start_time = datetime.now()
|
||||
|
||||
for future in as_completed(futures):
|
||||
size = future.result()
|
||||
percentage = 100 * counter // total
|
||||
total_size += size
|
||||
duration = (datetime.now() - start_time).seconds
|
||||
speed = total_size // duration if duration else 0
|
||||
remaining = (total - counter) * duration / counter
|
||||
|
||||
msg = "Downloaded VOD {}/{} ({}%) total <cyan>{}B</cyan> at <cyan>{}B/s</cyan> remaining <cyan>{}</cyan>".format(
|
||||
counter, total, percentage, format_size(total_size), format_size(speed), format_duration(remaining))
|
||||
|
||||
print_out("\r" + msg.ljust(80), end='')
|
||||
counter += 1
|
||||
|
||||
|
||||
def _download_files(base_url, directory, filenames, max_workers):
|
||||
urls = [base_url.format(f) for f in filenames]
|
||||
paths = ["/".join([directory, f]) for f in filenames]
|
||||
partials = (partial(download_file, url, path) for url, path in zip(urls, paths))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [executor.submit(fn) for fn in partials]
|
||||
_print_progress(futures)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def _join_vods(directory, paths, target):
|
||||
input_path = "{}/files.txt".format(directory)
|
||||
|
||||
with open(input_path, 'w') as f:
|
||||
for path in paths:
|
||||
f.write('file {}\n'.format(os.path.basename(path)))
|
||||
|
||||
result = subprocess.run([
|
||||
"ffmpeg",
|
||||
"-f", "concat",
|
||||
"-i", input_path,
|
||||
"-c", "copy",
|
||||
target,
|
||||
"-stats",
|
||||
"-loglevel", "warning",
|
||||
])
|
||||
|
||||
result.check_returncode()
|
||||
|
||||
|
||||
def _video_target_filename(video, format):
|
||||
match = re.search(r"^(\d{4})-(\d{2})-(\d{2})T", video['published_at'])
|
||||
date = "".join(match.groups())
|
||||
|
||||
name = "_".join([
|
||||
date,
|
||||
video['_id'][1:],
|
||||
video['channel']['name'],
|
||||
slugify(video['title']),
|
||||
])
|
||||
|
||||
return name + "." + format
|
||||
|
||||
|
||||
def parse_video_id(video_id):
|
||||
"""This can be either a integer ID or an URL to the video on twitch."""
|
||||
if re.search(r"^\d+$", video_id):
|
||||
return int(video_id)
|
||||
|
||||
match = re.search(r"^https://www.twitch.tv/videos/(\d+)(\?.+)?$", video_id)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
|
||||
raise ConsoleError("Invalid video ID given, expected integer ID or Twitch URL")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
print_out("Found: <blue>{}</blue> by <yellow>{}</yellow>".format(
|
||||
video['title'], video['channel']['display_name']))
|
||||
|
||||
print_out("Fetching access token...")
|
||||
access_token = twitch.get_access_token(video_id)
|
||||
|
||||
print_out("Fetching playlists...")
|
||||
playlists = twitch.get_playlists(video_id, access_token)
|
||||
quality, playlist_url = _select_quality(playlists)
|
||||
|
||||
print_out("\nFetching playlist...")
|
||||
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 using {} workers...".format(len(filenames), max_workers))
|
||||
paths = _download_files(base_url, directory, filenames, max_workers)
|
||||
|
||||
print_out("\n\nJoining files...")
|
||||
target = _video_target_filename(video, format)
|
||||
_join_vods(directory, paths, target)
|
||||
|
||||
print_out("\nDeleting vods...")
|
||||
for path in paths:
|
||||
os.unlink(path)
|
||||
|
||||
print_out("\nDownloaded: {}".format(target))
|
11
twitchdl/commands/__init__.py
Normal file
11
twitchdl/commands/__init__.py
Normal 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
114
twitchdl/commands/clips.py
Normal 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>")
|
260
twitchdl/commands/download.py
Normal file
260
twitchdl/commands/download.py
Normal file
@ -0,0 +1,260 @@
|
||||
import m3u8
|
||||
import re
|
||||
import requests
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
|
||||
def _parse_playlists(playlists_m3u8):
|
||||
playlists = m3u8.loads(playlists_m3u8)
|
||||
|
||||
for p in playlists.playlists:
|
||||
name = p.media[0].name if p.media else ""
|
||||
resolution = "x".join(str(r) for r in p.stream_info.resolution)
|
||||
yield name, resolution, p.uri
|
||||
|
||||
|
||||
def _get_playlist_by_name(playlists, quality):
|
||||
if quality == "source":
|
||||
_, _, uri = playlists[0]
|
||||
return uri
|
||||
|
||||
for name, _, uri in playlists:
|
||||
if name == quality:
|
||||
return uri
|
||||
|
||||
available = ", ".join([name for (name, _, _) in playlists])
|
||||
msg = "Quality '{}' not found. Available qualities are: {}".format(quality, available)
|
||||
raise ConsoleError(msg)
|
||||
|
||||
|
||||
def _select_playlist_interactive(playlists):
|
||||
print_out("\nAvailable qualities:")
|
||||
for n, (name, resolution, uri) in enumerate(playlists):
|
||||
print_out("{}) {} [{}]".format(n + 1, name, resolution))
|
||||
|
||||
no = utils.read_int("Choose quality", min=1, max=len(playlists) + 1, default=1)
|
||||
_, _, uri = playlists[no - 1]
|
||||
return uri
|
||||
|
||||
|
||||
def _join_vods(playlist_path, target, overwrite):
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-i", playlist_path,
|
||||
"-c", "copy",
|
||||
target,
|
||||
"-stats",
|
||||
"-loglevel", "warning",
|
||||
]
|
||||
|
||||
if overwrite:
|
||||
command.append("-y")
|
||||
|
||||
print_out("<dim>{}</dim>".format(" ".join(command)))
|
||||
result = subprocess.run(command)
|
||||
if result.returncode != 0:
|
||||
raise ConsoleError("Joining files failed")
|
||||
|
||||
|
||||
def _video_target_filename(video, format):
|
||||
match = re.search(r"^(\d{4})-(\d{2})-(\d{2})T", video['publishedAt'])
|
||||
date = "".join(match.groups())
|
||||
|
||||
name = "_".join([
|
||||
date,
|
||||
video['id'][1:],
|
||||
video['creator']['login'],
|
||||
utils.slugify(video['title']),
|
||||
])
|
||||
|
||||
return name + "." + format
|
||||
|
||||
|
||||
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 _get_vod_paths(playlist, start, end):
|
||||
"""Extract unique VOD paths for download from playlist."""
|
||||
files = []
|
||||
vod_start = 0
|
||||
for segment in playlist.segments:
|
||||
vod_end = vod_start + segment.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 and segment.uri not in files:
|
||||
files.append(segment.uri)
|
||||
|
||||
vod_start = vod_end
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def _crete_temp_dir(base_uri):
|
||||
"""Create a temp dir to store downloads if it doesn't exist."""
|
||||
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
|
||||
|
||||
|
||||
def download(args):
|
||||
video_id = utils.parse_video_identifier(args.video)
|
||||
if video_id:
|
||||
return _download_video(video_id, args)
|
||||
|
||||
clip_slug = utils.parse_clip_identifier(args.video)
|
||||
if clip_slug:
|
||||
return _download_clip(clip_slug, args)
|
||||
|
||||
raise ConsoleError("Invalid input: {}".format(args.video))
|
||||
|
||||
|
||||
def _get_clip_url(clip, args):
|
||||
qualities = clip["videoQualities"]
|
||||
|
||||
# Quality given as an argument
|
||||
if args.quality:
|
||||
if args.quality == "source":
|
||||
return qualities[0]["sourceURL"]
|
||||
|
||||
selected_quality = args.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)
|
||||
raise ConsoleError(msg)
|
||||
|
||||
# Ask user to select quality
|
||||
print_out("\nAvailable qualities:")
|
||||
for n, q in enumerate(qualities):
|
||||
print_out("{}) {} [{} fps]".format(n + 1, q["quality"], q["frameRate"]))
|
||||
print_out()
|
||||
|
||||
no = utils.read_int("Choose quality", min=1, max=len(qualities), default=1)
|
||||
selected_quality = qualities[no - 1]
|
||||
return selected_quality["sourceURL"]
|
||||
|
||||
|
||||
def _download_clip(slug, args):
|
||||
print_out("<dim>Looking up clip...</dim>")
|
||||
clip = twitch.get_clip(slug)
|
||||
|
||||
if not clip:
|
||||
raise ConsoleError("Clip '{}' not found".format(slug))
|
||||
|
||||
print_out("Found: <green>{}</green> by <yellow>{}</yellow>, playing <blue>{}</blue> ({})".format(
|
||||
clip["title"],
|
||||
clip["broadcaster"]["displayName"],
|
||||
clip["game"]["name"],
|
||||
utils.format_duration(clip["durationSeconds"])
|
||||
))
|
||||
|
||||
url = _get_clip_url(clip, args)
|
||||
print_out("<dim>Selected URL: {}</dim>".format(url))
|
||||
|
||||
target = _clip_target_filename(clip)
|
||||
|
||||
print_out("Downloading clip...")
|
||||
download_file(url, target)
|
||||
|
||||
print_out("Downloaded: {}".format(target))
|
||||
|
||||
|
||||
def _download_video(video_id, args):
|
||||
if args.start and args.end and args.end <= args.start:
|
||||
raise ConsoleError("End time must be greater than start time")
|
||||
|
||||
print_out("<dim>Looking up video...</dim>")
|
||||
video = twitch.get_video(video_id)
|
||||
|
||||
print_out("Found: <blue>{}</blue> by <yellow>{}</yellow>".format(
|
||||
video['title'], video['creator']['displayName']))
|
||||
|
||||
print_out("<dim>Fetching access token...</dim>")
|
||||
access_token = twitch.get_access_token(video_id)
|
||||
|
||||
print_out("<dim>Fetching playlists...</dim>")
|
||||
playlists_m3u8 = twitch.get_playlists(video_id, access_token)
|
||||
playlists = list(_parse_playlists(playlists_m3u8))
|
||||
playlist_uri = (_get_playlist_by_name(playlists, args.quality) if args.quality
|
||||
else _select_playlist_interactive(playlists))
|
||||
|
||||
print_out("<dim>Fetching playlist...</dim>")
|
||||
response = requests.get(playlist_uri)
|
||||
response.raise_for_status()
|
||||
playlist = m3u8.loads(response.text)
|
||||
|
||||
base_uri = re.sub("/[^/]+$", "/", playlist_uri)
|
||||
target_dir = _crete_temp_dir(base_uri)
|
||||
vod_paths = _get_vod_paths(playlist, args.start, args.end)
|
||||
|
||||
# Save playlists for debugging purposes
|
||||
with open(path.join(target_dir, "playlists.m3u8"), "w") as f:
|
||||
f.write(playlists_m3u8)
|
||||
with open(path.join(target_dir, "playlist.m3u8"), "w") as f:
|
||||
f.write(response.text)
|
||||
|
||||
print_out("\nDownloading {} VODs using {} workers to {}".format(
|
||||
len(vod_paths), args.max_workers, target_dir))
|
||||
path_map = download_files(base_uri, target_dir, vod_paths, args.max_workers)
|
||||
|
||||
# Make a modified playlist which references downloaded VODs
|
||||
# Keep only the downloaded segments and skip the rest
|
||||
org_segments = playlist.segments.copy()
|
||||
playlist.segments.clear()
|
||||
for segment in org_segments:
|
||||
if segment.uri in path_map:
|
||||
segment.uri = path_map[segment.uri]
|
||||
playlist.segments.append(segment)
|
||||
|
||||
playlist_path = path.join(target_dir, "playlist_downloaded.m3u8")
|
||||
playlist.dump(playlist_path)
|
||||
|
||||
if args.no_join:
|
||||
print_out("\n\n<dim>Skipping joining files...</dim>")
|
||||
print_out("VODs downloaded to:\n<blue>{}</blue>".format(target_dir))
|
||||
return
|
||||
|
||||
print_out("\n\nJoining files...")
|
||||
target = _video_target_filename(video, args.format)
|
||||
_join_vods(playlist_path, target, args.overwrite)
|
||||
|
||||
if args.keep:
|
||||
print_out("\n<dim>Temporary files not deleted: {}</dim>".format(target_dir))
|
||||
else:
|
||||
print_out("\n<dim>Deleting temporary files...</dim>")
|
||||
shutil.rmtree(target_dir)
|
||||
|
||||
print_out("\nDownloaded: <green>{}</green>".format(target))
|
79
twitchdl/commands/info.py
Normal file
79
twitchdl/commands/info.py
Normal 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.video))
|
||||
|
||||
|
||||
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))
|
69
twitchdl/commands/videos.py
Normal file
69
twitchdl/commands/videos.py
Normal 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>")
|
@ -7,6 +7,7 @@ from collections import namedtuple
|
||||
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.output import print_err
|
||||
from twitchdl.twitch import GQLError
|
||||
from . import commands, __version__
|
||||
|
||||
|
||||
@ -32,6 +33,19 @@ def time(value):
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
|
||||
def limit(value):
|
||||
"""Validates the number of videos to fetch."""
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
raise ArgumentTypeError("must be an integer")
|
||||
|
||||
if not 1 <= int(value) <= 100:
|
||||
raise ArgumentTypeError("must be between 1 and 100")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
COMMANDS = [
|
||||
Command(
|
||||
name="videos",
|
||||
@ -41,35 +55,82 @@ COMMANDS = [
|
||||
"help": "channel name",
|
||||
"type": str,
|
||||
}),
|
||||
(["-g", "--game"], {
|
||||
"help": "Show videos of given game (can be given multiple times)",
|
||||
"action": "append",
|
||||
"type": str,
|
||||
}),
|
||||
(["-l", "--limit"], {
|
||||
"help": "Number of videos to fetch (default 10, max 100)",
|
||||
"type": int,
|
||||
"type": limit,
|
||||
"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",
|
||||
}),
|
||||
(["-t", "--type"], {
|
||||
"help": "Broadcast type. (default: archive)",
|
||||
"type": str,
|
||||
"choices": ["archive", "highlight", "upload"],
|
||||
"default": "archive",
|
||||
}),
|
||||
(["-p", "--pager"], {
|
||||
"help": "If there are more results than LIMIT, ask to show next page",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
],
|
||||
),
|
||||
Command(
|
||||
name="clips",
|
||||
description="List clips",
|
||||
arguments=[
|
||||
(["channel_name"], {
|
||||
"help": "channel name",
|
||||
"type": str,
|
||||
}),
|
||||
(["-l", "--limit"], {
|
||||
"help": "Number of videos to fetch (default 10, max 100)",
|
||||
"type": limit,
|
||||
"default": 10,
|
||||
}),
|
||||
(["-P", "--period"], {
|
||||
"help": "Period from which to return clips. (default: 'all_time')",
|
||||
"type": str,
|
||||
"choices": ["last_day", "last_week", "last_month", "all_time"],
|
||||
"default": "all_time",
|
||||
}),
|
||||
(["-j", "--json"], {
|
||||
"help": "Show results as JSON",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["-p", "--pager"], {
|
||||
"help": "If there are more results than LIMIT, ask to show next page",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["-d", "--download"], {
|
||||
"help": "Download all videos in given period (in source quality)",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
],
|
||||
),
|
||||
Command(
|
||||
name="download",
|
||||
description="Download a video",
|
||||
arguments=[
|
||||
(["video_id"], {
|
||||
"help": "video ID",
|
||||
(["video"], {
|
||||
"help": "video ID, clip slug, or URL",
|
||||
"type": str,
|
||||
}),
|
||||
(["-w", "--max_workers"], {
|
||||
(["-w", "--max-workers"], {
|
||||
"help": "maximal number of threads for downloading vods "
|
||||
"concurrently (default 5)",
|
||||
"concurrently (default 20)",
|
||||
"type": int,
|
||||
"default": 20,
|
||||
}),
|
||||
@ -89,8 +150,42 @@ COMMANDS = [
|
||||
"type": str,
|
||||
"default": "mkv",
|
||||
}),
|
||||
(["-k", "--keep"], {
|
||||
"help": "Don't delete downloaded VODs and playlists after merging.",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["-q", "--quality"], {
|
||||
"help": "Video quality, e.g. 720p. Set to 'source' to get best quality.",
|
||||
"type": str,
|
||||
}),
|
||||
(["--no-join"], {
|
||||
"help": "Don't run ffmpeg to join the downloaded vods, implies --keep.",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
}),
|
||||
(["--overwrite"], {
|
||||
"help": "Overwrite the target file if it already exists without prompting.",
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
})
|
||||
],
|
||||
),
|
||||
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 = [
|
||||
@ -140,7 +235,12 @@ def main():
|
||||
return
|
||||
|
||||
try:
|
||||
args.func(**args.__dict__)
|
||||
args.func(args)
|
||||
except ConsoleError as e:
|
||||
print_err(e)
|
||||
sys.exit(1)
|
||||
except GQLError as e:
|
||||
print_err(e)
|
||||
for err in e.errors:
|
||||
print_err("*", err["message"])
|
||||
sys.exit(1)
|
||||
|
@ -1,11 +1,18 @@
|
||||
import os
|
||||
import requests
|
||||
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from requests.exceptions import RequestException
|
||||
from twitchdl.output import print_out
|
||||
from twitchdl.utils import format_size, format_duration
|
||||
|
||||
|
||||
CHUNK_SIZE = 1024
|
||||
CONNECT_TIMEOUT = 5
|
||||
RETRY_COUNT = 5
|
||||
|
||||
|
||||
class DownloadFailed(Exception):
|
||||
@ -25,14 +32,61 @@ def _download(url, path):
|
||||
return size
|
||||
|
||||
|
||||
def download_file(url, path, retries=3):
|
||||
def download_file(url, path, retries=RETRY_COUNT):
|
||||
if os.path.exists(path):
|
||||
return 0
|
||||
return os.path.getsize(path)
|
||||
|
||||
for _ in range(retries):
|
||||
try:
|
||||
return _download(url, path)
|
||||
except RequestException as e:
|
||||
print("Download failed: {}".format(e))
|
||||
except RequestException:
|
||||
pass
|
||||
|
||||
raise DownloadFailed(":(")
|
||||
|
||||
|
||||
def _print_progress(futures):
|
||||
downloaded_count = 0
|
||||
downloaded_size = 0
|
||||
max_msg_size = 0
|
||||
start_time = datetime.now()
|
||||
total_count = len(futures)
|
||||
|
||||
for future in as_completed(futures):
|
||||
size = future.result()
|
||||
downloaded_count += 1
|
||||
downloaded_size += size
|
||||
|
||||
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
|
||||
|
||||
msg = " ".join([
|
||||
"Downloaded VOD {}/{}".format(downloaded_count, total_count),
|
||||
"({}%)".format(percentage),
|
||||
"<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 "",
|
||||
])
|
||||
|
||||
max_msg_size = max(len(msg), max_msg_size)
|
||||
print_out("\r" + msg.ljust(max_msg_size), end="")
|
||||
|
||||
|
||||
def download_files(base_url, target_dir, vod_paths, max_workers):
|
||||
"""
|
||||
Downloads a list of VODs defined by a common `base_url` and a list of
|
||||
`vod_paths`, returning a dict which maps the paths to the downloaded files.
|
||||
"""
|
||||
urls = [base_url + path for path in vod_paths]
|
||||
targets = [os.path.join(target_dir, "{:05d}.ts".format(k)) for k, _ in enumerate(vod_paths)]
|
||||
partials = (partial(download_file, url, path) for url, path in zip(urls, targets))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [executor.submit(fn) for fn in partials]
|
||||
_print_progress(futures)
|
||||
|
||||
return OrderedDict(zip(vod_paths, targets))
|
||||
|
@ -1,18 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
|
||||
from twitchdl import utils
|
||||
|
||||
|
||||
START_CODES = {
|
||||
'bold': '\033[1m',
|
||||
'b': '\033[1m',
|
||||
'dim': '\033[2m',
|
||||
'i': '\033[3m',
|
||||
'u': '\033[4m',
|
||||
'red': '\033[31m',
|
||||
'green': '\033[32m',
|
||||
'yellow': '\033[33m',
|
||||
'blue': '\033[34m',
|
||||
'magenta': '\033[35m',
|
||||
'cyan': '\033[36m',
|
||||
'red': '\033[91m',
|
||||
'green': '\033[92m',
|
||||
'yellow': '\033[93m',
|
||||
'blue': '\033[94m',
|
||||
'magenta': '\033[95m',
|
||||
'cyan': '\033[96m',
|
||||
}
|
||||
|
||||
END_CODE = '\033[0m'
|
||||
@ -47,7 +52,60 @@ def print_out(*args, **kwargs):
|
||||
print(*args, **kwargs)
|
||||
|
||||
|
||||
def print_json(data):
|
||||
print(json.dumps(data))
|
||||
|
||||
|
||||
def print_err(*args, **kwargs):
|
||||
args = ["<red>{}</red>".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_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"]["displayName"]
|
||||
playing = (
|
||||
"playing <blue>{}</blue>".format(video["game"]["name"])
|
||||
if video["game"] else ""
|
||||
)
|
||||
|
||||
# Can't find URL in video object, strange
|
||||
url = "https://www.twitch.tv/videos/{}".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))
|
||||
print_out("<i>{}</i>".format(url))
|
||||
|
||||
|
||||
def print_clip(clip):
|
||||
published_at = clip["createdAt"].replace("T", " @ ").replace("Z", "")
|
||||
length = utils.format_duration(clip["durationSeconds"])
|
||||
channel = clip["broadcaster"]["displayName"]
|
||||
playing = (
|
||||
"playing <blue>{}</blue>".format(clip["game"]["name"])
|
||||
if clip["game"] else ""
|
||||
)
|
||||
|
||||
print_out("Clip <b>{}</b>".format(clip["slug"]))
|
||||
print_out("<green>{}</green>".format(clip["title"]))
|
||||
print_out("<blue>{}</blue> {}".format(channel, playing))
|
||||
print_out(
|
||||
"Published <blue>{}</blue>"
|
||||
" 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)
|
@ -1,64 +0,0 @@
|
||||
import re
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
|
||||
|
||||
def parse_playlists(data):
|
||||
media_pattern = re.compile(r'^#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="(?P<group>\w+)",NAME="(?P<name>\w+)"')
|
||||
|
||||
playlists = OrderedDict()
|
||||
n = 1
|
||||
name = None
|
||||
for line in data.split():
|
||||
match = re.match(media_pattern, line)
|
||||
if match:
|
||||
name = match.group('name')
|
||||
elif line.startswith('http'):
|
||||
playlists[n] = (name, line)
|
||||
n += 1
|
||||
|
||||
return playlists
|
||||
|
||||
|
||||
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)
|
||||
|
||||
match = re.search(r"#EXT-X-TWITCH-TOTAL-SECS:(\d+)(.\d+)?", playlist)
|
||||
total_seconds = int(match.group(1))
|
||||
|
||||
# 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,14 +1,38 @@
|
||||
"""
|
||||
Twitch API access.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
from twitchdl import CLIENT_ID
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.parse import parse_playlists, parse_playlist
|
||||
|
||||
|
||||
def authenticated_get(url, params={}):
|
||||
headers = {'Client-ID': CLIENT_ID}
|
||||
class GQLError(Exception):
|
||||
def __init__(self, errors):
|
||||
super().__init__("GraphQL query failed")
|
||||
self.errors = errors
|
||||
|
||||
|
||||
def authenticated_get(url, params={}, headers={}):
|
||||
headers['Client-ID'] = CLIENT_ID
|
||||
|
||||
response = requests.get(url, params, headers=headers)
|
||||
if 400 <= response.status_code < 500:
|
||||
data = response.json()
|
||||
# TODO: this does not look nice in the console since data["message"]
|
||||
# can contain a JSON encoded object.
|
||||
raise ConsoleError(data["message"])
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def authenticated_post(url, data=None, json=None, headers={}):
|
||||
headers['Client-ID'] = CLIENT_ID
|
||||
|
||||
response = requests.post(url, data=data, json=json, headers=headers)
|
||||
if response.status_code == 400:
|
||||
data = response.json()
|
||||
raise ConsoleError(data["message"])
|
||||
@ -18,54 +42,297 @@ def authenticated_get(url, params={}):
|
||||
return response
|
||||
|
||||
|
||||
def get_video(video_id):
|
||||
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_query(query):
|
||||
url = "https://gql.twitch.tv/gql"
|
||||
response = authenticated_post(url, json={"query": query}).json()
|
||||
|
||||
if "errors" in response:
|
||||
raise GQLError(response["errors"])
|
||||
|
||||
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/%d" % video_id
|
||||
url = "https://api.twitch.tv/kraken/videos/{}".format(video_id)
|
||||
|
||||
return authenticated_get(url).json()
|
||||
return kraken_get(url).json()
|
||||
|
||||
|
||||
def get_channel_videos(channel_name, limit, offset, sort):
|
||||
VIDEO_FIELDS = """
|
||||
id
|
||||
title
|
||||
publishedAt
|
||||
broadcastType
|
||||
lengthSeconds
|
||||
game {
|
||||
name
|
||||
}
|
||||
creator {
|
||||
login
|
||||
displayName
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def get_video(video_id):
|
||||
query = """
|
||||
{{
|
||||
video(id: "{video_id}") {{
|
||||
{fields}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
https://dev.twitch.tv/docs/v5/reference/channels#get-channel-videos
|
||||
"""
|
||||
url = "https://api.twitch.tv/kraken/channels/%s/videos" % channel_name
|
||||
|
||||
return authenticated_get(url, {
|
||||
"broadcast_type": "archive",
|
||||
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
|
||||
url
|
||||
videoQualities {{
|
||||
frameRate
|
||||
quality
|
||||
sourceURL
|
||||
}}
|
||||
game {{
|
||||
id
|
||||
name
|
||||
}}
|
||||
broadcaster {{
|
||||
displayName
|
||||
login
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
response = gql_query(query.format(slug))
|
||||
return response["data"]["clip"]
|
||||
|
||||
|
||||
def get_channel_clips(channel_id, period, limit, after=None):
|
||||
"""
|
||||
List channel clips.
|
||||
|
||||
At the time of writing this:
|
||||
* filtering by game name returns an error
|
||||
* sorting by anything but VIEWS_DESC or TRENDING returns an error
|
||||
* sorting by VIEWS_DESC and TRENDING returns the same results
|
||||
* there is no totalCount
|
||||
"""
|
||||
query = """
|
||||
{{
|
||||
user(login: "{channel_id}") {{
|
||||
clips(first: {limit}, after: "{after}", criteria: {{ period: {period}, sort: VIEWS_DESC }}) {{
|
||||
pageInfo {{
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}}
|
||||
edges {{
|
||||
cursor
|
||||
node {{
|
||||
id
|
||||
slug
|
||||
title
|
||||
createdAt
|
||||
viewCount
|
||||
durationSeconds
|
||||
url
|
||||
videoQualities {{
|
||||
frameRate
|
||||
quality
|
||||
sourceURL
|
||||
}}
|
||||
game {{
|
||||
id
|
||||
name
|
||||
}}
|
||||
broadcaster {{
|
||||
displayName
|
||||
login
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
query = query.format(**{
|
||||
"channel_id": channel_id,
|
||||
"after": after if after else "",
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"sort": sort,
|
||||
}).json()
|
||||
"period": period.upper(),
|
||||
})
|
||||
|
||||
response = gql_query(query)
|
||||
user = response["data"]["user"]
|
||||
if not user:
|
||||
raise ConsoleError("Channel {} not found".format(channel_id))
|
||||
|
||||
return response["data"]["user"]["clips"]
|
||||
|
||||
|
||||
def channel_clips_generator(channel_id, period, limit):
|
||||
cursor = ""
|
||||
while True:
|
||||
clips = get_channel_clips(
|
||||
channel_id, period, limit, after=cursor)
|
||||
|
||||
if not clips["edges"]:
|
||||
break
|
||||
|
||||
has_next = clips["pageInfo"]["hasNextPage"]
|
||||
cursor = clips["edges"][-1]["cursor"] if has_next else None
|
||||
|
||||
yield clips, has_next
|
||||
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
|
||||
def get_channel_videos(channel_id, limit, sort, type="archive", game_ids=[], after=None):
|
||||
query = """
|
||||
{{
|
||||
user(login: "{channel_id}") {{
|
||||
videos(
|
||||
first: {limit},
|
||||
type: {type},
|
||||
sort: {sort},
|
||||
after: "{after}",
|
||||
options: {{
|
||||
gameIDs: {game_ids}
|
||||
}}
|
||||
) {{
|
||||
totalCount
|
||||
pageInfo {{
|
||||
hasNextPage
|
||||
}}
|
||||
edges {{
|
||||
cursor
|
||||
node {{
|
||||
id
|
||||
title
|
||||
publishedAt
|
||||
broadcastType
|
||||
lengthSeconds
|
||||
game {{
|
||||
name
|
||||
}}
|
||||
creator {{
|
||||
login
|
||||
displayName
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
query = query.format(**{
|
||||
"channel_id": channel_id,
|
||||
"game_ids": game_ids,
|
||||
"after": after if after else "",
|
||||
"limit": limit,
|
||||
"sort": sort.upper(),
|
||||
"type": type.upper(),
|
||||
})
|
||||
|
||||
response = gql_query(query)
|
||||
return response["data"]["user"]["videos"]
|
||||
|
||||
|
||||
def channel_videos_generator(channel_id, limit, sort, type, game_ids=None):
|
||||
cursor = ""
|
||||
while True:
|
||||
videos = get_channel_videos(
|
||||
channel_id, limit, sort, type, game_ids=game_ids, after=cursor)
|
||||
|
||||
if not videos["edges"]:
|
||||
break
|
||||
|
||||
has_next = videos["pageInfo"]["hasNextPage"]
|
||||
cursor = videos["edges"][-1]["cursor"] if has_next else None
|
||||
|
||||
yield videos, has_next
|
||||
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
|
||||
def get_access_token(video_id):
|
||||
url = "https://api.twitch.tv/api/vods/%d/access_token" % 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):
|
||||
"""
|
||||
For a given video return a playlist which contains possible video qualities.
|
||||
"""
|
||||
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",
|
||||
})
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.content.decode('utf-8')
|
||||
|
||||
return parse_playlists(data)
|
||||
return response.content.decode('utf-8')
|
||||
|
||||
|
||||
def get_playlist_urls(url, start, end):
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
def get_game_id(name):
|
||||
query = """
|
||||
{{
|
||||
game(name: "{}") {{
|
||||
id
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
data = response.content.decode('utf-8')
|
||||
return parse_playlist(url, data, start, end)
|
||||
response = gql_query(query.format(name.strip()))
|
||||
game = response["data"]["game"]
|
||||
if game:
|
||||
return game["id"]
|
||||
|
@ -2,10 +2,90 @@ import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def _format_size(value, digits, unit):
|
||||
if digits > 0:
|
||||
return "{{:.{}f}}{}".format(digits, unit).format(value)
|
||||
else:
|
||||
return "{{:d}}{}".format(unit).format(value)
|
||||
|
||||
|
||||
def format_size(bytes_, digits=1):
|
||||
if bytes_ < 1024:
|
||||
return _format_size(bytes_, digits, "B")
|
||||
|
||||
kilo = bytes_ / 1024
|
||||
if kilo < 1024:
|
||||
return _format_size(kilo, digits, "kB")
|
||||
|
||||
mega = kilo / 1024
|
||||
if mega < 1024:
|
||||
return _format_size(mega, digits, "MB")
|
||||
|
||||
return _format_size(mega / 1024, digits, "GB")
|
||||
|
||||
|
||||
def format_duration(total_seconds):
|
||||
total_seconds = int(total_seconds)
|
||||
hours = total_seconds // 3600
|
||||
remainder = total_seconds % 3600
|
||||
minutes = remainder // 60
|
||||
seconds = total_seconds % 60
|
||||
|
||||
if hours:
|
||||
return "{} h {} min".format(hours, minutes)
|
||||
|
||||
if minutes:
|
||||
return "{} min {} sec".format(minutes, seconds)
|
||||
|
||||
return "{} sec".format(seconds)
|
||||
|
||||
|
||||
def read_int(msg, min, max, default):
|
||||
msg = msg + " [default {}]: ".format(default)
|
||||
|
||||
while True:
|
||||
try:
|
||||
val = input(msg)
|
||||
if not val:
|
||||
return default
|
||||
if min <= int(val) <= max:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def slugify(value):
|
||||
re_pattern = re.compile(r'[^\w\s-]', flags=re.U)
|
||||
re_spaces = re.compile(r'[-\s]+', flags=re.U)
|
||||
value = str(value)
|
||||
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
|
||||
value = unicodedata.normalize('NFKC', value)
|
||||
value = re_pattern.sub('', value).strip().lower()
|
||||
return re_spaces.sub('-', value)
|
||||
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]+)$",
|
||||
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 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")
|
||||
|
Reference in New Issue
Block a user