twitch-dl/twitchdl/commands/download.py

317 lines
9.7 KiB
Python
Raw Normal View History

2022-08-14 09:02:29 +00:00
import asyncio
2024-04-04 06:20:10 +00:00
import platform
2018-01-25 10:09:20 +00:00
import re
2019-08-23 10:36:05 +00:00
import shutil
2018-01-25 10:09:20 +00:00
import subprocess
import tempfile
2020-04-11 14:07:17 +00:00
from pathlib import Path
2024-08-28 08:32:40 +00:00
from typing import List, Optional
2024-04-04 06:20:10 +00:00
from urllib.parse import urlencode, urlparse
import click
import httpx
2018-01-25 10:09:20 +00:00
2020-04-11 11:08:42 +00:00
from twitchdl import twitch, utils
2024-04-02 07:44:20 +00:00
from twitchdl.entities import DownloadOptions
from twitchdl.exceptions import ConsoleError
2024-08-28 09:07:25 +00:00
from twitchdl.http import download_all, download_file
2024-08-28 08:32:40 +00:00
from twitchdl.naming import clip_filename, video_filename
2024-04-06 08:15:26 +00:00
from twitchdl.output import blue, bold, green, print_log, yellow
from twitchdl.playlists import (
enumerate_vods,
load_m3u8,
make_join_playlist,
parse_playlists,
select_playlist,
)
2024-08-28 08:32:40 +00:00
from twitchdl.twitch import Chapter, ClipAccessToken, Video
2018-01-25 10:09:20 +00:00
2024-04-23 16:09:30 +00:00
def download(ids: List[str], args: DownloadOptions):
2024-04-28 06:02:01 +00:00
if not ids:
print_log("No IDs to downlad given")
return
2024-03-23 09:50:42 +00:00
for video_id in ids:
download_one(video_id, args)
def download_one(video: str, args: DownloadOptions):
video_id = utils.parse_video_identifier(video)
if video_id:
return _download_video(video_id, args)
clip_slug = utils.parse_clip_identifier(video)
if clip_slug:
return _download_clip(clip_slug, args)
2024-03-28 11:06:50 +00:00
raise ConsoleError(f"Invalid input: {video}")
2024-03-23 09:50:42 +00:00
2024-08-30 06:05:16 +00:00
def _join_vods(playlist_path: Path, target: Path, overwrite: bool, video: Video):
2024-03-29 08:22:50 +00:00
description = video["description"] or ""
description = description.strip()
2024-08-28 08:59:23 +00:00
command: List[str] = [
2018-01-25 10:09:20 +00:00
"ffmpeg",
2024-04-04 06:20:10 +00:00
"-i",
2024-08-28 08:59:23 +00:00
str(playlist_path),
2024-04-04 06:20:10 +00:00
"-c",
"copy",
"-metadata",
f"artist={video['creator']['displayName']}",
"-metadata",
f"title={video['title']}",
"-metadata",
f"description={description}",
"-metadata",
"encoded_by=twitch-dl",
2018-01-25 10:09:20 +00:00
"-stats",
2024-04-04 06:20:10 +00:00
"-loglevel",
"warning",
2024-03-28 11:06:50 +00:00
f"file:{target}",
]
2018-01-25 10:09:20 +00:00
2020-09-29 08:57:09 +00:00
if overwrite:
command.append("-y")
2024-04-04 06:20:10 +00:00
click.secho(f"{' '.join(command)}", dim=True)
result = subprocess.run(command)
if result.returncode != 0:
raise ConsoleError("Joining files failed")
2018-01-25 10:09:20 +00:00
2024-04-04 06:20:10 +00:00
2024-08-30 06:05:16 +00:00
def _concat_vods(vod_paths: List[Path], target: Path):
2024-03-29 07:24:45 +00:00
tool = "type" if platform.system() == "Windows" else "cat"
2024-08-28 08:59:23 +00:00
command = [tool] + [str(p) for p in vod_paths]
2024-03-29 07:24:45 +00:00
with open(target, "wb") as target_file:
result = subprocess.run(command, stdout=target_file)
if result.returncode != 0:
raise ConsoleError(f"Joining files failed: {result.stderr}")
2018-01-25 10:09:20 +00:00
2024-08-28 08:59:23 +00:00
def _crete_temp_dir(base_uri: str) -> Path:
2019-08-23 10:36:05 +00:00
"""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)
2024-08-28 08:59:23 +00:00
return temp_dir
2019-08-23 10:36:05 +00:00
2024-04-27 18:37:25 +00:00
def _get_clip_url(access_token: ClipAccessToken, quality: Optional[str]) -> str:
2024-04-04 06:36:10 +00:00
qualities = access_token["videoQualities"]
# Quality given as an argument
if quality:
if quality == "source":
2020-09-29 06:26:40 +00:00
return qualities[0]["sourceURL"]
selected_quality = 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])
2024-03-28 11:06:50 +00:00
msg = f"Quality '{quality}' not found. Available qualities are: {available}"
raise ConsoleError(msg)
# Ask user to select quality
2024-03-30 14:36:53 +00:00
click.echo("\nAvailable qualities:")
for n, q in enumerate(qualities):
2024-03-30 14:36:53 +00:00
click.echo(f"{n + 1}) {bold(q['quality'])} [{q['frameRate']} fps]")
click.echo()
no = utils.read_int("Choose quality", min=1, max=len(qualities), default=1)
selected_quality = qualities[no - 1]
return selected_quality["sourceURL"]
2024-04-27 18:37:25 +00:00
def get_clip_authenticated_url(slug: str, quality: Optional[str]):
2024-03-30 14:36:53 +00:00
print_log("Fetching access token...")
access_token = twitch.get_clip_access_token(slug)
if not access_token:
2024-03-28 11:06:50 +00:00
raise ConsoleError(f"Access token not found for slug '{slug}'")
url = _get_clip_url(access_token, quality)
2024-04-04 06:20:10 +00:00
query = urlencode(
{
2024-04-04 06:36:10 +00:00
"sig": access_token["playbackAccessToken"]["signature"],
"token": access_token["playbackAccessToken"]["value"],
2024-04-04 06:20:10 +00:00
}
)
2024-03-28 11:06:50 +00:00
return f"{url}?{query}"
2024-03-23 09:50:42 +00:00
def _download_clip(slug: str, args: DownloadOptions) -> None:
2024-03-30 14:36:53 +00:00
print_log("Looking up clip...")
2020-04-11 14:07:17 +00:00
clip = twitch.get_clip(slug)
if not clip:
2024-03-28 11:06:50 +00:00
raise ConsoleError(f"Clip '{slug}' not found")
title = clip["title"]
user = clip["broadcaster"]["displayName"]
game = clip["game"]["name"] if clip["game"] else "Unknown"
duration = utils.format_duration(clip["durationSeconds"])
2024-03-30 14:36:53 +00:00
click.echo(f"Found: {green(title)} by {yellow(user)}, playing {blue(game)} ({duration})")
2020-04-11 14:07:17 +00:00
2024-08-28 08:59:23 +00:00
target = Path(clip_filename(clip, args.output))
2024-03-30 14:36:53 +00:00
click.echo(f"Target: {blue(target)}")
2022-01-23 08:14:40 +00:00
2024-08-28 08:59:23 +00:00
if not args.overwrite and target.exists():
2024-03-30 14:36:53 +00:00
response = click.prompt("File exists. Overwrite? [Y/n]", default="Y", show_default=False)
if response.lower().strip() != "y":
raise click.Abort()
args.overwrite = True
url = get_clip_authenticated_url(slug, args.quality)
2024-03-30 14:36:53 +00:00
print_log(f"Selected URL: {url}")
2023-11-30 17:15:55 +00:00
2024-03-30 14:36:53 +00:00
if args.dry_run:
click.echo("Dry run, clip not downloaded.")
else:
print_log("Downloading clip...")
2023-11-30 17:15:55 +00:00
download_file(url, target)
2024-03-30 14:36:53 +00:00
click.echo(f"Downloaded: {blue(target)}")
2020-04-11 14:07:17 +00:00
2024-04-06 08:15:26 +00:00
def _download_video(video_id: str, args: DownloadOptions) -> None:
if args.start and args.end and args.end <= args.start:
raise ConsoleError("End time must be greater than start time")
2024-03-30 14:36:53 +00:00
print_log("Looking up video...")
2018-01-25 10:09:20 +00:00
video = twitch.get_video(video_id)
2021-04-25 11:02:07 +00:00
if not video:
2024-03-28 11:06:50 +00:00
raise ConsoleError(f"Video {video_id} not found")
2021-04-25 11:02:07 +00:00
2024-04-06 08:15:26 +00:00
click.echo(f"Found: {blue(video['title'])} by {yellow(video['creator']['displayName'])}")
2019-04-30 11:34:54 +00:00
2024-08-28 08:59:23 +00:00
target = Path(video_filename(video, args.format, args.output))
2024-03-30 14:36:53 +00:00
click.echo(f"Output: {blue(target)}")
2022-01-23 08:14:40 +00:00
2024-08-28 08:59:23 +00:00
if not args.overwrite and target.exists():
2024-04-10 06:47:38 +00:00
response = click.prompt("File exists. Overwrite? [Y/n]", default="Y", show_default=False)
2024-03-30 14:36:53 +00:00
if response.lower().strip() != "y":
raise click.Abort()
args.overwrite = True
# Chapter select or manual offset
start, end = _determine_time_range(video_id, args)
2024-03-30 14:36:53 +00:00
print_log("Fetching access token...")
2022-06-25 07:59:31 +00:00
access_token = twitch.get_access_token(video_id, auth_token=args.auth_token)
2018-01-25 10:09:20 +00:00
2024-03-30 14:36:53 +00:00
print_log("Fetching playlists...")
2024-04-06 08:15:26 +00:00
playlists_text = twitch.get_playlists(video_id, access_token)
playlists = parse_playlists(playlists_text)
playlist = select_playlist(playlists, args.quality)
2018-01-25 10:09:20 +00:00
2024-03-30 14:36:53 +00:00
print_log("Fetching playlist...")
2024-04-06 08:15:26 +00:00
vods_text = http_get(playlist.url)
vods_m3u8 = load_m3u8(vods_text)
vods = enumerate_vods(vods_m3u8, start, end)
if args.dry_run:
click.echo("Dry run, video not downloaded.")
return
2024-04-06 08:15:26 +00:00
base_uri = re.sub("/[^/]+$", "/", playlist.url)
2019-08-23 10:36:05 +00:00
target_dir = _crete_temp_dir(base_uri)
2018-01-25 10:09:20 +00:00
# Save playlists for debugging purposes
2024-08-28 08:59:23 +00:00
with open(target_dir / "playlists.m3u8", "w") as f:
2024-04-06 08:15:26 +00:00
f.write(playlists_text)
2024-08-28 08:59:23 +00:00
with open(target_dir / "playlist.m3u8", "w") as f:
2024-04-06 08:15:26 +00:00
f.write(vods_text)
2024-04-06 08:15:26 +00:00
click.echo(f"\nDownloading {len(vods)} VODs using {args.max_workers} workers to {target_dir}")
2022-08-14 09:02:29 +00:00
2024-04-06 08:15:26 +00:00
sources = [base_uri + vod.path for vod in vods]
2024-08-28 08:59:23 +00:00
targets = [target_dir / f"{vod.index:05d}.ts" for vod in vods]
asyncio.run(
download_all(
zip(sources, targets),
args.max_workers,
rate_limit=args.rate_limit,
count=len(vods),
)
)
2018-01-25 10:09:20 +00:00
2024-04-06 08:15:26 +00:00
join_playlist = make_join_playlist(vods_m3u8, vods, targets)
2024-08-28 08:59:23 +00:00
join_playlist_path = target_dir / "playlist_downloaded.m3u8"
2024-04-06 08:15:26 +00:00
join_playlist.dump(join_playlist_path) # type: ignore
2024-03-30 14:36:53 +00:00
click.echo()
2024-03-29 07:24:45 +00:00
if args.no_join:
2024-03-30 14:36:53 +00:00
print_log("Skipping joining files...")
click.echo(f"VODs downloaded to:\n{blue(target_dir)}")
return
2024-03-29 07:24:45 +00:00
if args.concat:
2024-03-30 14:36:53 +00:00
print_log("Concating files...")
2024-03-29 07:24:45 +00:00
_concat_vods(targets, target)
else:
2024-03-30 14:36:53 +00:00
print_log("Joining files...")
2024-04-06 08:15:26 +00:00
_join_vods(join_playlist_path, target, args.overwrite, video)
2018-01-25 10:09:20 +00:00
2024-03-30 14:36:53 +00:00
click.echo()
if args.keep:
2024-03-30 14:36:53 +00:00
click.echo(f"Temporary files not deleted: {target_dir}")
else:
2024-03-30 14:36:53 +00:00
print_log("Deleting temporary files...")
2019-08-23 10:36:05 +00:00
shutil.rmtree(target_dir)
2018-01-25 10:09:20 +00:00
2024-03-30 14:36:53 +00:00
click.echo(f"\nDownloaded: {green(target)}")
2024-04-06 08:15:26 +00:00
def http_get(url: str) -> str:
response = httpx.get(url)
response.raise_for_status()
return response.text
2024-04-03 06:52:51 +00:00
def _determine_time_range(video_id: str, args: DownloadOptions):
if args.start or args.end:
return args.start, args.end
if args.chapter is not None:
2024-03-30 14:36:53 +00:00
print_log("Fetching chapters...")
chapters = twitch.get_video_chapters(video_id)
if not chapters:
raise ConsoleError("This video has no chapters")
if args.chapter == 0:
chapter = _choose_chapter_interactive(chapters)
else:
try:
chapter = chapters[args.chapter - 1]
except IndexError:
2024-04-04 06:20:10 +00:00
raise ConsoleError(
f"Chapter {args.chapter} does not exist. This video has {len(chapters)} chapters."
)
2024-03-30 14:36:53 +00:00
click.echo(f'Chapter selected: {blue(chapter["description"])}\n')
start = chapter["positionMilliseconds"] // 1000
duration = chapter["durationMilliseconds"] // 1000
return start, start + duration
return None, None
2024-04-23 16:09:30 +00:00
def _choose_chapter_interactive(chapters: List[Chapter]):
2024-03-30 14:36:53 +00:00
click.echo("\nChapters:")
for index, chapter in enumerate(chapters):
duration = utils.format_time(chapter["durationMilliseconds"] // 1000)
2024-03-30 14:36:53 +00:00
click.echo(f'{index + 1}) {bold(chapter["description"])} ({duration})')
index = utils.read_int("Select a chapter", 1, len(chapters))
chapter = chapters[index - 1]
return chapter