mirror of
https://github.com/ihabunek/twitch-dl
synced 2024-08-30 18:32:25 +00:00
Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
900914377b | |||
ad0f0d2a41 | |||
1057fff61a | |||
f1924715ed | |||
ca38b9239f | |||
a0ad66ee69 | |||
e50499351b | |||
0d3c3df2f8 | |||
446b4f9f91 |
27
.github/workflows/test.yml
vendored
Normal file
27
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: Run tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[test]"
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest
|
||||
- name: Validate minimum required version
|
||||
run: |
|
||||
vermin --no-tips twitchdl
|
10
CHANGELOG.md
10
CHANGELOG.md
@ -3,6 +3,16 @@ twitch-dl changelog
|
||||
|
||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||
|
||||
### [2.2.2 (2024-04-23)](https://github.com/ihabunek/twitch-dl/releases/tag/2.2.2)
|
||||
|
||||
* Fix more compat issues Python < 3.10 (#152)
|
||||
|
||||
### [2.2.1 (2024-04-23)](https://github.com/ihabunek/twitch-dl/releases/tag/2.2.1)
|
||||
|
||||
* Fix compat with Python < 3.10 (#152)
|
||||
* Fix division by zero in progress calculation when video duration is reported
|
||||
as 0
|
||||
|
||||
### [2.2.0 (2024-04-10)](https://github.com/ihabunek/twitch-dl/releases/tag/2.2.0)
|
||||
|
||||
* **Requires Python 3.8+**
|
||||
|
@ -1,3 +1,14 @@
|
||||
2.2.2:
|
||||
date: 2024-04-23
|
||||
changes:
|
||||
- "Fix more compat issues Python < 3.10 (#152)"
|
||||
|
||||
2.2.1:
|
||||
date: 2024-04-23
|
||||
changes:
|
||||
- "Fix compat with Python < 3.10 (#152)"
|
||||
- "Fix division by zero in progress calculation when video duration is reported as 0"
|
||||
|
||||
2.2.0:
|
||||
date: 2024-04-10
|
||||
changes:
|
||||
|
@ -3,6 +3,16 @@ twitch-dl changelog
|
||||
|
||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||
|
||||
### [2.2.2 (2024-04-23)](https://github.com/ihabunek/twitch-dl/releases/tag/2.2.2)
|
||||
|
||||
* Fix more compat issues Python < 3.10 (#152)
|
||||
|
||||
### [2.2.1 (2024-04-23)](https://github.com/ihabunek/twitch-dl/releases/tag/2.2.1)
|
||||
|
||||
* Fix compat with Python < 3.10 (#152)
|
||||
* Fix division by zero in progress calculation when video duration is reported
|
||||
as 0
|
||||
|
||||
### [2.2.0 (2024-04-10)](https://github.com/ihabunek/twitch-dl/releases/tag/2.2.0)
|
||||
|
||||
* **Requires Python 3.8+**
|
||||
|
@ -43,6 +43,11 @@ dev = [
|
||||
"vermin",
|
||||
]
|
||||
|
||||
test = [
|
||||
"pytest",
|
||||
"vermin",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://twitch-dl.bezdomni.net/"
|
||||
"Source" = "https://github.com/ihabunek/twitch-dl"
|
||||
@ -53,6 +58,7 @@ twitch-dl = "twitchdl.cli:cli"
|
||||
[tool.pyright]
|
||||
include = ["twitchdl"]
|
||||
typeCheckingMode = "strict"
|
||||
pythonVersion = "3.8"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
@ -2,6 +2,7 @@ import logging
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import click
|
||||
|
||||
@ -30,13 +31,13 @@ json_option = click.option(
|
||||
)
|
||||
|
||||
|
||||
def validate_positive(_ctx: click.Context, _param: click.Parameter, value: int | None):
|
||||
def validate_positive(_ctx: click.Context, _param: click.Parameter, value: Optional[int]):
|
||||
if value is not None and value <= 0:
|
||||
raise click.BadParameter("must be greater than 0")
|
||||
return value
|
||||
|
||||
|
||||
def validate_time(_ctx: click.Context, _param: click.Parameter, value: str) -> int | None:
|
||||
def validate_time(_ctx: click.Context, _param: click.Parameter, value: str) -> Optional[int]:
|
||||
"""Parse a time string (hh:mm or hh:mm:ss) to number of seconds."""
|
||||
if not value:
|
||||
return None
|
||||
@ -56,7 +57,7 @@ def validate_time(_ctx: click.Context, _param: click.Parameter, value: str) -> i
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
|
||||
def validate_rate(_ctx: click.Context, _param: click.Parameter, value: str) -> int | None:
|
||||
def validate_rate(_ctx: click.Context, _param: click.Parameter, value: str) -> Optional[int]:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
@ -143,8 +144,8 @@ def clips(
|
||||
compact: bool,
|
||||
download: bool,
|
||||
json: bool,
|
||||
limit: int | None,
|
||||
pager: int | None,
|
||||
limit: Optional[int],
|
||||
pager: Optional[int],
|
||||
period: ClipsPeriod,
|
||||
):
|
||||
"""List or download clips for given CHANNEL_NAME."""
|
||||
@ -254,20 +255,20 @@ def clips(
|
||||
default=5,
|
||||
)
|
||||
def download(
|
||||
ids: tuple[str, ...],
|
||||
auth_token: str | None,
|
||||
chapter: int | None,
|
||||
ids: Tuple[str, ...],
|
||||
auth_token: Optional[str],
|
||||
chapter: Optional[int],
|
||||
concat: bool,
|
||||
dry_run: bool,
|
||||
end: int | None,
|
||||
end: Optional[int],
|
||||
format: str,
|
||||
keep: bool,
|
||||
no_join: bool,
|
||||
overwrite: bool,
|
||||
output: str,
|
||||
quality: str | None,
|
||||
rate_limit: int | None,
|
||||
start: int | None,
|
||||
quality: Optional[str],
|
||||
rate_limit: Optional[int],
|
||||
start: Optional[int],
|
||||
max_workers: int,
|
||||
):
|
||||
"""Download videos or clips.
|
||||
@ -373,10 +374,10 @@ def videos(
|
||||
channel_name: str,
|
||||
all: bool,
|
||||
compact: bool,
|
||||
games_tuple: tuple[str, ...],
|
||||
games_tuple: Tuple[str, ...],
|
||||
json: bool,
|
||||
limit: int | None,
|
||||
pager: int | None,
|
||||
limit: Optional[int],
|
||||
pager: Optional[int],
|
||||
sort: VideosSort,
|
||||
type: VideosType,
|
||||
):
|
||||
|
@ -1,7 +1,7 @@
|
||||
import re
|
||||
import sys
|
||||
from os import path
|
||||
from typing import Callable, Generator
|
||||
from typing import Callable, Generator, Optional
|
||||
|
||||
import click
|
||||
|
||||
@ -9,7 +9,7 @@ from twitchdl import twitch, utils
|
||||
from twitchdl.commands.download import get_clip_authenticated_url
|
||||
from twitchdl.download import download_file
|
||||
from twitchdl.output import green, print_clip, print_clip_compact, print_json, print_paged, yellow
|
||||
from twitchdl.twitch import Clip
|
||||
from twitchdl.twitch import Clip, ClipsPeriod
|
||||
|
||||
|
||||
def clips(
|
||||
@ -19,9 +19,9 @@ def clips(
|
||||
compact: bool = False,
|
||||
download: bool = False,
|
||||
json: bool = False,
|
||||
limit: int | None = None,
|
||||
pager: int | None = None,
|
||||
period: twitch.ClipsPeriod = "all_time",
|
||||
limit: Optional[int] = None,
|
||||
pager: Optional[int] = None,
|
||||
period: ClipsPeriod = "all_time",
|
||||
):
|
||||
# Set different defaults for limit for compact display
|
||||
default_limit = 40 if compact else 10
|
||||
|
@ -7,6 +7,7 @@ import subprocess
|
||||
import tempfile
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import click
|
||||
@ -28,7 +29,7 @@ from twitchdl.playlists import (
|
||||
from twitchdl.twitch import Chapter, Clip, ClipAccessToken, Video
|
||||
|
||||
|
||||
def download(ids: list[str], args: DownloadOptions):
|
||||
def download(ids: List[str], args: DownloadOptions):
|
||||
for video_id in ids:
|
||||
download_one(video_id, args)
|
||||
|
||||
@ -78,7 +79,7 @@ def _join_vods(playlist_path: str, target: str, overwrite: bool, video: Video):
|
||||
raise ConsoleError("Joining files failed")
|
||||
|
||||
|
||||
def _concat_vods(vod_paths: list[str], target: str):
|
||||
def _concat_vods(vod_paths: List[str], target: str):
|
||||
tool = "type" if platform.system() == "Windows" else "cat"
|
||||
command = [tool] + vod_paths
|
||||
|
||||
@ -88,7 +89,7 @@ def _concat_vods(vod_paths: list[str], target: str):
|
||||
raise ConsoleError(f"Joining files failed: {result.stderr}")
|
||||
|
||||
|
||||
def get_video_placeholders(video: Video, format: str) -> dict[str, str]:
|
||||
def get_video_placeholders(video: Video, format: str) -> Dict[str, str]:
|
||||
date, time = video["publishedAt"].split("T")
|
||||
game = video["game"]["name"] if video["game"] else "Unknown"
|
||||
|
||||
@ -251,7 +252,7 @@ def _download_video(video_id: str, args: DownloadOptions) -> None:
|
||||
click.echo(f"Output: {blue(target)}")
|
||||
|
||||
if not args.overwrite and path.exists(target):
|
||||
response = click.prompt("File exists. Overwrite? [Y/n]: ", default="Y", show_default=False)
|
||||
response = click.prompt("File exists. Overwrite? [Y/n]", default="Y", show_default=False)
|
||||
if response.lower().strip() != "y":
|
||||
raise click.Abort()
|
||||
args.overwrite = True
|
||||
@ -350,7 +351,7 @@ def _determine_time_range(video_id: str, args: DownloadOptions):
|
||||
return None, None
|
||||
|
||||
|
||||
def _choose_chapter_interactive(chapters: list[Chapter]):
|
||||
def _choose_chapter_interactive(chapters: List[Chapter]):
|
||||
click.echo("\nChapters:")
|
||||
for index, chapter in enumerate(chapters):
|
||||
duration = utils.format_time(chapter["durationMilliseconds"] // 1000)
|
||||
|
@ -1,3 +1,5 @@
|
||||
from typing import List
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
|
||||
@ -5,6 +7,7 @@ from twitchdl import twitch, utils
|
||||
from twitchdl.commands.download import get_video_placeholders
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
from twitchdl.output import bold, print_clip, print_json, print_log, print_table, print_video
|
||||
from twitchdl.playlists import parse_playlists
|
||||
from twitchdl.twitch import Chapter, Clip, Video
|
||||
|
||||
|
||||
@ -48,13 +51,13 @@ def info(id: str, *, json: bool = False):
|
||||
raise ConsoleError(f"Invalid input: {id}")
|
||||
|
||||
|
||||
def video_info(video: Video, playlists, chapters: list[Chapter]):
|
||||
def video_info(video: Video, playlists: str, chapters: List[Chapter]):
|
||||
click.echo()
|
||||
print_video(video)
|
||||
|
||||
click.echo("Playlists:")
|
||||
for p in m3u8.loads(playlists).playlists:
|
||||
click.echo(f"{bold(p.stream_info.video)} {p.uri}")
|
||||
for p in parse_playlists(playlists):
|
||||
click.echo(f"{bold(p.name)} {p.url}")
|
||||
|
||||
if chapters:
|
||||
click.echo()
|
||||
@ -70,7 +73,7 @@ def video_info(video: Video, playlists, chapters: list[Chapter]):
|
||||
print_table(["Placeholder", "Value"], placeholders)
|
||||
|
||||
|
||||
def video_json(video, playlists, chapters):
|
||||
def video_json(video: Video, playlists: str, chapters: List[Chapter]):
|
||||
playlists = m3u8.loads(playlists).playlists
|
||||
|
||||
video["playlists"] = [
|
||||
|
@ -1,4 +1,5 @@
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
|
||||
import click
|
||||
|
||||
@ -12,10 +13,10 @@ def videos(
|
||||
*,
|
||||
all: bool,
|
||||
compact: bool,
|
||||
games: list[str],
|
||||
games: List[str],
|
||||
json: bool,
|
||||
limit: int | None,
|
||||
pager: int | None,
|
||||
limit: Optional[int],
|
||||
pager: Optional[int],
|
||||
sort: twitch.VideosSort,
|
||||
type: twitch.VideosType,
|
||||
):
|
||||
@ -66,7 +67,7 @@ def videos(
|
||||
)
|
||||
|
||||
|
||||
def _get_game_ids(names: list[str]) -> list[str]:
|
||||
def _get_game_ids(names: List[str]) -> List[str]:
|
||||
if not names:
|
||||
return []
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from twitchdl.exceptions import ConsoleError
|
||||
|
@ -1,25 +1,25 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadOptions:
|
||||
auth_token: str | None
|
||||
chapter: int | None
|
||||
auth_token: Optional[str]
|
||||
chapter: Optional[int]
|
||||
concat: bool
|
||||
dry_run: bool
|
||||
end: int | None
|
||||
end: Optional[int]
|
||||
format: str
|
||||
keep: bool
|
||||
no_join: bool
|
||||
overwrite: bool
|
||||
output: str
|
||||
quality: str | None
|
||||
rate_limit: int | None
|
||||
start: int | None
|
||||
quality: Optional[str]
|
||||
rate_limit: Optional[int]
|
||||
start: Optional[int]
|
||||
max_workers: int
|
||||
|
||||
|
||||
# Type for annotating decoded JSON
|
||||
# TODO: make data classes for common structs
|
||||
Data = dict[str, Any]
|
||||
Data = Mapping[str, Any]
|
||||
|
@ -1,6 +1,6 @@
|
||||
import json
|
||||
from itertools import islice
|
||||
from typing import Any, Callable, Generator, TypeVar
|
||||
from typing import Any, Callable, Generator, List, Optional, TypeVar
|
||||
|
||||
import click
|
||||
|
||||
@ -25,12 +25,12 @@ def print_log(message: Any):
|
||||
click.secho(message, err=True, dim=True)
|
||||
|
||||
|
||||
def print_table(headers: list[str], data: list[list[str]]):
|
||||
def print_table(headers: List[str], data: List[List[str]]):
|
||||
widths = [[len(cell) for cell in row] for row in data + [headers]]
|
||||
widths = [max(width) for width in zip(*widths)]
|
||||
underlines = ["-" * width for width in widths]
|
||||
|
||||
def print_row(row: list[str]):
|
||||
def print_row(row: List[str]):
|
||||
for idx, cell in enumerate(row):
|
||||
width = widths[idx]
|
||||
click.echo(cell.ljust(width), nl=False)
|
||||
@ -49,7 +49,7 @@ def print_paged(
|
||||
generator: Generator[T, Any, Any],
|
||||
print_fn: Callable[[T], None],
|
||||
page_size: int,
|
||||
total_count: int | None = None,
|
||||
total_count: Optional[int] = None,
|
||||
):
|
||||
iterator = iter(generator)
|
||||
page = list(islice(iterator, page_size))
|
||||
|
@ -3,7 +3,7 @@ Parse and manipulate m3u8 playlists.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Generator, OrderedDict
|
||||
from typing import Generator, List, Optional, OrderedDict
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
@ -15,7 +15,7 @@ from twitchdl.output import bold, dim
|
||||
@dataclass
|
||||
class Playlist:
|
||||
name: str
|
||||
resolution: str | None
|
||||
resolution: Optional[str]
|
||||
url: str
|
||||
|
||||
|
||||
@ -29,11 +29,15 @@ class Vod:
|
||||
"""Segment duration in seconds"""
|
||||
|
||||
|
||||
def parse_playlists(playlists_m3u8: str):
|
||||
def parse_playlists(playlists_m3u8: str) -> List[Playlist]:
|
||||
def _parse(source: str) -> Generator[Playlist, None, None]:
|
||||
document = load_m3u8(source)
|
||||
|
||||
for p in document.playlists:
|
||||
from pprint import pp
|
||||
|
||||
pp(p.__dict__)
|
||||
pp(p.stream_info.__dict__)
|
||||
if p.stream_info.resolution:
|
||||
name = p.media[0].name
|
||||
resolution = "x".join(str(r) for r in p.stream_info.resolution)
|
||||
@ -53,9 +57,9 @@ def load_m3u8(playlist_m3u8: str) -> m3u8.M3U8:
|
||||
|
||||
def enumerate_vods(
|
||||
document: m3u8.M3U8,
|
||||
start: int | None = None,
|
||||
end: int | None = None,
|
||||
) -> list[Vod]:
|
||||
start: Optional[int] = None,
|
||||
end: Optional[int] = None,
|
||||
) -> List[Vod]:
|
||||
"""Extract VODs for download from document."""
|
||||
vods = []
|
||||
vod_start = 0
|
||||
@ -78,8 +82,8 @@ def enumerate_vods(
|
||||
|
||||
def make_join_playlist(
|
||||
playlist: m3u8.M3U8,
|
||||
vods: list[Vod],
|
||||
targets: list[str],
|
||||
vods: List[Vod],
|
||||
targets: List[str],
|
||||
) -> m3u8.Playlist:
|
||||
"""
|
||||
Make a modified playlist which references downloaded VODs
|
||||
@ -97,7 +101,7 @@ def make_join_playlist(
|
||||
return playlist
|
||||
|
||||
|
||||
def select_playlist(playlists: list[Playlist], quality: str | None) -> Playlist:
|
||||
def select_playlist(playlists: List[Playlist], quality: Optional[str]) -> Playlist:
|
||||
return (
|
||||
select_playlist_by_name(playlists, quality)
|
||||
if quality is not None
|
||||
@ -105,7 +109,7 @@ def select_playlist(playlists: list[Playlist], quality: str | None) -> Playlist:
|
||||
)
|
||||
|
||||
|
||||
def select_playlist_by_name(playlists: list[Playlist], quality: str) -> Playlist:
|
||||
def select_playlist_by_name(playlists: List[Playlist], quality: str) -> Playlist:
|
||||
if quality == "source":
|
||||
return playlists[0]
|
||||
|
||||
@ -118,7 +122,7 @@ def select_playlist_by_name(playlists: list[Playlist], quality: str) -> Playlist
|
||||
raise click.ClickException(msg)
|
||||
|
||||
|
||||
def select_playlist_interactive(playlists: list[Playlist]) -> Playlist:
|
||||
def select_playlist_interactive(playlists: List[Playlist]) -> Playlist:
|
||||
click.echo("\nAvailable qualities:")
|
||||
for n, playlist in enumerate(playlists):
|
||||
if playlist.resolution:
|
||||
|
@ -127,7 +127,7 @@ class Progress:
|
||||
size = last_sample.downloaded - first_sample.downloaded
|
||||
duration = last_sample.timestamp - first_sample.timestamp
|
||||
|
||||
return size / duration
|
||||
return size / duration if duration > 0 else None
|
||||
|
||||
def print(self):
|
||||
now = time.time()
|
||||
@ -136,17 +136,20 @@ class Progress:
|
||||
if now - self.last_printed < 0.1:
|
||||
return
|
||||
|
||||
progress = " ".join(
|
||||
[
|
||||
f"Downloaded {self.vod_downloaded_count}/{self.vod_count} VODs",
|
||||
f"{blue(self.progress_perc)}%",
|
||||
f"of ~{blue(format_size(self.estimated_total))}" if self.estimated_total else "",
|
||||
f"at {blue(format_size(self.speed))}/s" if self.speed else "",
|
||||
f"ETA {blue(format_time(self.remaining_time))}"
|
||||
if self.remaining_time is not None
|
||||
else "",
|
||||
]
|
||||
)
|
||||
click.echo(f"\rDownloaded {self.vod_downloaded_count}/{self.vod_count} VODs", nl=False)
|
||||
click.secho(f" {self.progress_perc}%", fg="blue", nl=False)
|
||||
|
||||
if self.estimated_total is not None:
|
||||
total = f"~{format_size(self.estimated_total)}"
|
||||
click.echo(f" of {blue(total)}", nl=False)
|
||||
|
||||
if self.speed is not None:
|
||||
speed = f"{format_size(self.speed)}/s"
|
||||
click.echo(f" at {blue(speed)}", nl=False)
|
||||
|
||||
if self.remaining_time is not None:
|
||||
click.echo(f" ETA {blue(format_time(self.remaining_time))}", nl=False)
|
||||
|
||||
click.echo(" ", nl=False)
|
||||
|
||||
click.echo(f"\r{progress} ", nl=False)
|
||||
self.last_printed = now
|
||||
|
@ -3,7 +3,7 @@ Twitch API access.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Generator, Literal, TypedDict
|
||||
from typing import Dict, Generator, List, Literal, Mapping, Optional, Tuple, TypedDict
|
||||
|
||||
import click
|
||||
import httpx
|
||||
@ -41,7 +41,7 @@ class VideoQuality(TypedDict):
|
||||
class ClipAccessToken(TypedDict):
|
||||
id: str
|
||||
playbackAccessToken: AccessToken
|
||||
videoQualities: list[VideoQuality]
|
||||
videoQualities: List[VideoQuality]
|
||||
|
||||
|
||||
class Clip(TypedDict):
|
||||
@ -52,7 +52,7 @@ class Clip(TypedDict):
|
||||
viewCount: int
|
||||
durationSeconds: int
|
||||
url: str
|
||||
videoQualities: list[VideoQuality]
|
||||
videoQualities: List[VideoQuality]
|
||||
game: Game
|
||||
broadcaster: User
|
||||
|
||||
@ -80,7 +80,7 @@ class Chapter(TypedDict):
|
||||
|
||||
|
||||
class GQLError(click.ClickException):
|
||||
def __init__(self, errors: list[str]):
|
||||
def __init__(self, errors: List[str]):
|
||||
message = "GraphQL query failed."
|
||||
for error in errors:
|
||||
message += f"\n* {error}"
|
||||
@ -163,7 +163,7 @@ CLIP_FIELDS = """
|
||||
"""
|
||||
|
||||
|
||||
def get_video(video_id: str) -> Video | None:
|
||||
def get_video(video_id: str) -> Optional[Video]:
|
||||
query = f"""
|
||||
{{
|
||||
video(id: "{video_id}") {{
|
||||
@ -176,7 +176,7 @@ def get_video(video_id: str) -> Video | None:
|
||||
return response["data"]["video"]
|
||||
|
||||
|
||||
def get_clip(slug: str) -> Clip | None:
|
||||
def get_clip(slug: str) -> Optional[Clip]:
|
||||
query = f"""
|
||||
{{
|
||||
clip(slug: "{slug}") {{
|
||||
@ -209,7 +209,12 @@ def get_clip_access_token(slug: str) -> ClipAccessToken:
|
||||
return response["data"]["clip"]
|
||||
|
||||
|
||||
def get_channel_clips(channel_id: str, period: ClipsPeriod, limit: int, after: str | None = None):
|
||||
def get_channel_clips(
|
||||
channel_id: str,
|
||||
period: ClipsPeriod,
|
||||
limit: int,
|
||||
after: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
List channel clips.
|
||||
|
||||
@ -294,8 +299,8 @@ def get_channel_videos(
|
||||
limit: int,
|
||||
sort: str,
|
||||
type: str = "archive",
|
||||
game_ids: list[str] | None = None,
|
||||
after: str | None = None,
|
||||
game_ids: Optional[List[str]] = None,
|
||||
after: Optional[str] = None,
|
||||
):
|
||||
game_ids = game_ids or []
|
||||
|
||||
@ -339,8 +344,8 @@ def channel_videos_generator(
|
||||
max_videos: int,
|
||||
sort: VideosSort,
|
||||
type: VideosType,
|
||||
game_ids: list[str] | None = None,
|
||||
) -> tuple[int, Generator[Video, None, None]]:
|
||||
game_ids: Optional[List[str]] = None,
|
||||
) -> Tuple[int, Generator[Video, None, None]]:
|
||||
game_ids = game_ids or []
|
||||
|
||||
def _generator(videos: Data, max_videos: int) -> Generator[Video, None, None]:
|
||||
@ -364,7 +369,7 @@ def channel_videos_generator(
|
||||
return videos["totalCount"], _generator(videos, max_videos)
|
||||
|
||||
|
||||
def get_access_token(video_id: str, auth_token: str | None = None) -> AccessToken:
|
||||
def get_access_token(video_id: str, auth_token: Optional[str] = None) -> AccessToken:
|
||||
query = f"""
|
||||
{{
|
||||
videoPlaybackAccessToken(
|
||||
@ -381,7 +386,7 @@ def get_access_token(video_id: str, auth_token: str | None = None) -> AccessToke
|
||||
}}
|
||||
"""
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
headers: Mapping[str, str] = {}
|
||||
if auth_token is not None:
|
||||
headers["authorization"] = f"OAuth {auth_token}"
|
||||
|
||||
@ -438,7 +443,7 @@ def get_game_id(name: str):
|
||||
return game["id"]
|
||||
|
||||
|
||||
def get_video_chapters(video_id: str) -> list[Chapter]:
|
||||
def get_video_chapters(video_id: str) -> List[Chapter]:
|
||||
query = {
|
||||
"operationName": "VideoPlayer_ChapterSelectButtonVideo",
|
||||
"variables": {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Optional, Union
|
||||
|
||||
import click
|
||||
|
||||
@ -11,7 +12,7 @@ def _format_size(value: float, digits: int, unit: str):
|
||||
return f"{int(value)}{unit}"
|
||||
|
||||
|
||||
def format_size(bytes_: int | float, digits: int = 1):
|
||||
def format_size(bytes_: Union[int, float], digits: int = 1):
|
||||
if bytes_ < 1024:
|
||||
return _format_size(bytes_, digits, "B")
|
||||
|
||||
@ -26,7 +27,7 @@ def format_size(bytes_: int | float, digits: int = 1):
|
||||
return _format_size(mega / 1024, digits, "GB")
|
||||
|
||||
|
||||
def format_duration(total_seconds: int | float) -> str:
|
||||
def format_duration(total_seconds: Union[int, float]) -> str:
|
||||
total_seconds = int(total_seconds)
|
||||
hours = total_seconds // 3600
|
||||
remainder = total_seconds % 3600
|
||||
@ -42,7 +43,7 @@ def format_duration(total_seconds: int | float) -> str:
|
||||
return f"{seconds} sec"
|
||||
|
||||
|
||||
def format_time(total_seconds: int | float, force_hours: bool = False) -> str:
|
||||
def format_time(total_seconds: Union[int, float], force_hours: bool = False) -> str:
|
||||
total_seconds = int(total_seconds)
|
||||
hours = total_seconds // 3600
|
||||
remainder = total_seconds % 3600
|
||||
@ -55,7 +56,7 @@ def format_time(total_seconds: int | float, force_hours: bool = False) -> str:
|
||||
return f"{minutes:02}:{seconds:02}"
|
||||
|
||||
|
||||
def read_int(msg: str, min: int, max: int, default: int | None = None) -> int:
|
||||
def read_int(msg: str, min: int, max: int, default: Optional[int] = None) -> int:
|
||||
while True:
|
||||
try:
|
||||
val = click.prompt(msg, default=default, type=int)
|
||||
@ -93,7 +94,7 @@ CLIP_PATTERNS = [
|
||||
]
|
||||
|
||||
|
||||
def parse_video_identifier(identifier: str) -> str | None:
|
||||
def parse_video_identifier(identifier: str) -> Optional[str]:
|
||||
"""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)
|
||||
@ -101,7 +102,7 @@ def parse_video_identifier(identifier: str) -> str | None:
|
||||
return match.group("id")
|
||||
|
||||
|
||||
def parse_clip_identifier(identifier: str) -> str | None:
|
||||
def parse_clip_identifier(identifier: str) -> Optional[str]:
|
||||
"""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)
|
||||
|
203
video_before.json
Normal file
203
video_before.json
Normal file
@ -0,0 +1,203 @@
|
||||
{
|
||||
"id": "2115833882",
|
||||
"title": "Games I Feel Like Speedrun Marathon !newyt !Slender !Merch",
|
||||
"description": null,
|
||||
"publishedAt": "2024-04-10T05:01:12Z",
|
||||
"broadcastType": "ARCHIVE",
|
||||
"lengthSeconds": 30163,
|
||||
"game": {
|
||||
"id": "21063",
|
||||
"name": "Saw"
|
||||
},
|
||||
"creator": {
|
||||
"login": "ecdycis",
|
||||
"displayName": "Ecdycis"
|
||||
},
|
||||
"playlists": [
|
||||
{
|
||||
"bandwidth": 6395968,
|
||||
"resolution": [
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
"codecs": "avc1.64002A,mp4a.40.2",
|
||||
"video": "chunked",
|
||||
"uri": "https://d2nvs31859zcd8.cloudfront.net/3104d817048588f268fa_ecdycis_43996750059_1712725267/chunked/index-muted-GKGIUOE29B.m3u8"
|
||||
},
|
||||
{
|
||||
"bandwidth": 3430341,
|
||||
"resolution": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"codecs": "avc1.4D0020,mp4a.40.2",
|
||||
"video": "720p60",
|
||||
"uri": "https://d2nvs31859zcd8.cloudfront.net/3104d817048588f268fa_ecdycis_43996750059_1712725267/720p60/index-muted-GKGIUOE29B.m3u8"
|
||||
},
|
||||
{
|
||||
"bandwidth": 1448243,
|
||||
"resolution": [
|
||||
852,
|
||||
480
|
||||
],
|
||||
"codecs": "avc1.4D001F,mp4a.40.2",
|
||||
"video": "480p30",
|
||||
"uri": "https://d2nvs31859zcd8.cloudfront.net/3104d817048588f268fa_ecdycis_43996750059_1712725267/480p30/index-muted-GKGIUOE29B.m3u8"
|
||||
},
|
||||
{
|
||||
"bandwidth": 215544,
|
||||
"resolution": null,
|
||||
"codecs": "mp4a.40.2",
|
||||
"video": "audio_only",
|
||||
"uri": "https://d2nvs31859zcd8.cloudfront.net/3104d817048588f268fa_ecdycis_43996750059_1712725267/audio_only/index-muted-GKGIUOE29B.m3u8"
|
||||
},
|
||||
{
|
||||
"bandwidth": 709051,
|
||||
"resolution": [
|
||||
640,
|
||||
360
|
||||
],
|
||||
"codecs": "avc1.4D001E,mp4a.40.2",
|
||||
"video": "360p30",
|
||||
"uri": "https://d2nvs31859zcd8.cloudfront.net/3104d817048588f268fa_ecdycis_43996750059_1712725267/360p30/index-muted-GKGIUOE29B.m3u8"
|
||||
},
|
||||
{
|
||||
"bandwidth": 288844,
|
||||
"resolution": [
|
||||
284,
|
||||
160
|
||||
],
|
||||
"codecs": "avc1.4D000C,mp4a.40.2",
|
||||
"video": "160p30",
|
||||
"uri": "https://d2nvs31859zcd8.cloudfront.net/3104d817048588f268fa_ecdycis_43996750059_1712725267/160p30/index-muted-GKGIUOE29B.m3u8"
|
||||
}
|
||||
],
|
||||
"chapters": [
|
||||
{
|
||||
"id": "6049e803f47a0b9cf64ce95adcf3d96d",
|
||||
"durationMilliseconds": 4891000,
|
||||
"positionMilliseconds": 0,
|
||||
"type": "GAME_CHANGE",
|
||||
"description": "Saw",
|
||||
"subDescription": "",
|
||||
"thumbnailURL": "",
|
||||
"video": {
|
||||
"id": "2115833882",
|
||||
"lengthSeconds": 30163,
|
||||
"__typename": "Video"
|
||||
},
|
||||
"__typename": "VideoMoment",
|
||||
"game": {
|
||||
"id": "21063",
|
||||
"displayName": "Saw",
|
||||
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/21063_IGDB-40x53.jpg",
|
||||
"__typename": "Game"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c3be4ce5a7bae802d7a6df02baf62a85",
|
||||
"durationMilliseconds": 6235000,
|
||||
"positionMilliseconds": 4891000,
|
||||
"type": "GAME_CHANGE",
|
||||
"description": "Resident Evil 7: Biohazard",
|
||||
"subDescription": "",
|
||||
"thumbnailURL": "",
|
||||
"video": {
|
||||
"id": "2115833882",
|
||||
"lengthSeconds": 30163,
|
||||
"__typename": "Video"
|
||||
},
|
||||
"__typename": "VideoMoment",
|
||||
"game": {
|
||||
"id": "492934",
|
||||
"displayName": "Resident Evil 7: Biohazard",
|
||||
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/492934_IGDB-40x53.jpg",
|
||||
"__typename": "Game"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bd584f17bc1f91fc11ed14dcec5e4742",
|
||||
"durationMilliseconds": 3401000,
|
||||
"positionMilliseconds": 11126000,
|
||||
"type": "GAME_CHANGE",
|
||||
"description": "Silent Hill: Homecoming",
|
||||
"subDescription": "",
|
||||
"thumbnailURL": "",
|
||||
"video": {
|
||||
"id": "2115833882",
|
||||
"lengthSeconds": 30163,
|
||||
"__typename": "Video"
|
||||
},
|
||||
"__typename": "VideoMoment",
|
||||
"game": {
|
||||
"id": "18864",
|
||||
"displayName": "Silent Hill: Homecoming",
|
||||
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/18864_IGDB-40x53.jpg",
|
||||
"__typename": "Game"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "9f4be8bc7b5bf398213bc602a4b39c4d",
|
||||
"durationMilliseconds": 3490000,
|
||||
"positionMilliseconds": 14527000,
|
||||
"type": "GAME_CHANGE",
|
||||
"description": "Silent Hill 2",
|
||||
"subDescription": "",
|
||||
"thumbnailURL": "",
|
||||
"video": {
|
||||
"id": "2115833882",
|
||||
"lengthSeconds": 30163,
|
||||
"__typename": "Video"
|
||||
},
|
||||
"__typename": "VideoMoment",
|
||||
"game": {
|
||||
"id": "9891",
|
||||
"displayName": "Silent Hill 2",
|
||||
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/9891_IGDB-40x53.jpg",
|
||||
"__typename": "Game"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7a42d537681decc10660c33f9071a37f",
|
||||
"durationMilliseconds": 7241000,
|
||||
"positionMilliseconds": 18017000,
|
||||
"type": "GAME_CHANGE",
|
||||
"description": "The Darkness",
|
||||
"subDescription": "",
|
||||
"thumbnailURL": "",
|
||||
"video": {
|
||||
"id": "2115833882",
|
||||
"lengthSeconds": 30163,
|
||||
"__typename": "Video"
|
||||
},
|
||||
"__typename": "VideoMoment",
|
||||
"game": {
|
||||
"id": "8448",
|
||||
"displayName": "The Darkness",
|
||||
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/8448_IGDB-40x53.jpg",
|
||||
"__typename": "Game"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "64418186f436d80b1359d2e6686222ef",
|
||||
"durationMilliseconds": 4905000,
|
||||
"positionMilliseconds": 25258000,
|
||||
"type": "GAME_CHANGE",
|
||||
"description": "Silent Hill 4: The Room",
|
||||
"subDescription": "",
|
||||
"thumbnailURL": "",
|
||||
"video": {
|
||||
"id": "2115833882",
|
||||
"lengthSeconds": 30163,
|
||||
"__typename": "Video"
|
||||
},
|
||||
"__typename": "VideoMoment",
|
||||
"game": {
|
||||
"id": "5804",
|
||||
"displayName": "Silent Hill 4: The Room",
|
||||
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/5804_IGDB-40x53.jpg",
|
||||
"__typename": "Game"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user