twitch-dl/twitchdl/commands/download.py

277 lines
8.4 KiB
Python
Raw Normal View History

2019-08-23 10:36:05 +00:00
import m3u8
2018-01-25 10:09:20 +00:00
import re
2019-08-23 10:36:05 +00:00
import requests
import shutil
2018-01-25 10:09:20 +00:00
import subprocess
import tempfile
from os import path
2020-04-11 14:07:17 +00:00
from pathlib import Path
2019-08-23 10:36:05 +00:00
from urllib.parse import urlparse
2018-01-25 10:09:20 +00:00
2020-04-11 11:08:42 +00:00
from twitchdl import twitch, utils
2020-04-11 14:07:17 +00:00
from twitchdl.download import download_file, download_files
from twitchdl.exceptions import ConsoleError
2021-01-07 08:34:14 +00:00
from twitchdl.output import print_out
2018-01-25 10:09:20 +00:00
def _parse_playlists(playlists_m3u8):
playlists = m3u8.loads(playlists_m3u8)
for p in playlists.playlists:
2019-08-23 10:36:05 +00:00
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):
2020-09-29 06:26:40 +00:00
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):
2019-08-23 10:36:05 +00:00
print_out("{}) {} [{}]".format(n + 1, name, resolution))
2018-01-25 10:09:20 +00:00
2020-04-11 11:08:42 +00:00
no = utils.read_int("Choose quality", min=1, max=len(playlists) + 1, default=1)
_, _, uri = playlists[no - 1]
return uri
2018-01-25 10:09:20 +00:00
2020-09-29 10:21:13 +00:00
def _join_vods(playlist_path, target, overwrite):
command = [
2018-01-25 10:09:20 +00:00
"ffmpeg",
"-i", playlist_path,
2018-01-25 10:09:20 +00:00
"-c", "copy",
target,
"-stats",
"-loglevel", "warning",
]
2018-01-25 10:09:20 +00:00
2020-09-29 08:57:09 +00:00
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")
2018-01-25 10:09:20 +00:00
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'],
2020-04-11 11:08:42 +00:00
utils.slugify(video['title']),
])
return name + "." + format
2018-01-25 10:09:20 +00:00
2020-11-10 08:21:37 +00:00
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"]["channel"]["name"],
utils.slugify(clip["title"]),
])
return "{}.{}".format(name, ext)
def _get_vod_paths(playlist, start, end):
"""Extract unique VOD paths for download from playlist."""
files = []
2019-08-23 10:36:05 +00:00
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)
2019-08-23 10:36:05 +00:00
vod_start = vod_end
return files
2019-08-23 10:36:05 +00:00
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
2019-08-23 10:36:05 +00:00
2020-04-11 14:07:17 +00:00
VIDEO_PATTERNS = [
r"^(?P<id>\d+)?$",
r"^https://(www.)?twitch.tv/videos/(?P<id>\d+)(\?.+)?$",
2020-04-11 14:07:17 +00:00
]
CLIP_PATTERNS = [
2020-08-07 14:34:36 +00:00
r"^(?P<slug>[A-Za-z0-9]+)$",
r"^https://(www.)?twitch.tv/\w+/clip/(?P<slug>[A-Za-z0-9]+)(\?.+)?$",
2020-08-07 14:34:36 +00:00
r"^https://clips.twitch.tv/(?P<slug>[A-Za-z0-9]+)(\?.+)?$",
2020-04-11 14:07:17 +00:00
]
def download(args):
2020-04-11 14:07:17 +00:00
for pattern in VIDEO_PATTERNS:
match = re.match(pattern, args.video)
2020-04-11 14:07:17 +00:00
if match:
video_id = match.group('id')
return _download_video(video_id, args)
2020-04-11 14:07:17 +00:00
for pattern in CLIP_PATTERNS:
match = re.match(pattern, args.video)
if match:
clip_slug = match.group('slug')
return _download_clip(clip_slug, args)
raise ConsoleError("Invalid video: {}".format(args.video))
2020-04-11 14:07:17 +00:00
def _get_clip_url(clip, args):
qualities = clip["videoQualities"]
# Quality given as an argument
if args.quality:
2020-09-29 06:26:40 +00:00
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):
2020-05-17 12:32:37 +00:00
print_out("<dim>Looking up clip...</dim>")
2020-04-11 14:07:17 +00:00
clip = twitch.get_clip(slug)
if not clip:
raise ConsoleError("Clip '{}' not found".format(slug))
2020-04-11 14:07:17 +00:00
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))
2020-04-11 14:07:17 +00:00
2020-11-10 08:21:37 +00:00
target = _clip_target_filename(clip)
2020-04-11 14:07:17 +00:00
print_out("Downloading clip...")
2020-11-10 08:21:37 +00:00
download_file(url, target)
2020-04-11 14:07:17 +00:00
2020-11-10 08:21:37 +00:00
print_out("Downloaded: {}".format(target))
2020-04-11 14:07:17 +00:00
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")
2020-05-17 12:32:37 +00:00
print_out("<dim>Looking up video...</dim>")
2018-01-25 10:09:20 +00:00
video = twitch.get_video(video_id)
2019-04-30 11:34:54 +00:00
print_out("Found: <blue>{}</blue> by <yellow>{}</yellow>".format(
video['title'], video['channel']['display_name']))
2020-05-17 12:32:37 +00:00
print_out("<dim>Fetching access token...</dim>")
2018-01-25 10:09:20 +00:00
access_token = twitch.get_access_token(video_id)
2020-05-17 12:32:37 +00:00
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))
2018-01-25 10:09:20 +00:00
print_out("<dim>Fetching playlist...</dim>")
response = requests.get(playlist_uri)
2019-08-23 10:36:05 +00:00
response.raise_for_status()
playlist = m3u8.loads(response.text)
base_uri = re.sub("/[^/]+$", "/", playlist_uri)
2019-08-23 10:36:05 +00:00
target_dir = _crete_temp_dir(base_uri)
vod_paths = _get_vod_paths(playlist, args.start, args.end)
2018-01-25 10:09:20 +00:00
# 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)
2019-08-23 10:36:05 +00:00
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)
2018-01-25 10:09:20 +00:00
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
2019-04-30 11:34:54 +00:00
print_out("\n\nJoining files...")
target = _video_target_filename(video, args.format)
2020-09-29 08:57:09 +00:00
_join_vods(playlist_path, target, args.overwrite)
2018-01-25 10:09:20 +00:00
if args.keep:
print_out("\n<dim>Temporary files not deleted: {}</dim>".format(target_dir))
else:
print_out("\n<dim>Deleting temporary files...</dim>")
2019-08-23 10:36:05 +00:00
shutil.rmtree(target_dir)
2018-01-25 10:09:20 +00:00
2020-09-03 08:35:03 +00:00
print_out("\nDownloaded: <green>{}</green>".format(target))