Compare commits

..

4 Commits
2.2.1 ... test

Author SHA1 Message Date
900914377b Add testing on github 2024-04-24 08:09:26 +02:00
ad0f0d2a41 Update changelog 2024-04-23 18:14:22 +02:00
1057fff61a Hopefully fix python 3.8 compat 2024-04-23 18:13:40 +02:00
f1924715ed Apply formatting 2024-04-23 17:35:46 +02:00
16 changed files with 304 additions and 41 deletions

27
.github/workflows/test.yml vendored Normal file
View 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

View File

@ -3,9 +3,13 @@ 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 compat with Python < 3.10 (#152)
* Fix division by zero in progress calculation when video duration is reported
as 0

View File

@ -1,7 +1,12 @@
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 compat with Python < 3.10 (#152)"
- "Fix division by zero in progress calculation when video duration is reported as 0"
2.2.0:

View File

@ -3,9 +3,13 @@ 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 compat with Python < 3.10 (#152)
* Fix division by zero in progress calculation when video duration is reported
as 0

View File

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

View File

@ -2,7 +2,7 @@ import logging
import platform
import re
import sys
from typing import Optional
from typing import Optional, Tuple
import click
@ -255,7 +255,7 @@ def clips(
default=5,
)
def download(
ids: tuple[str, ...],
ids: Tuple[str, ...],
auth_token: Optional[str],
chapter: Optional[int],
concat: bool,
@ -374,7 +374,7 @@ def videos(
channel_name: str,
all: bool,
compact: bool,
games_tuple: tuple[str, ...],
games_tuple: Tuple[str, ...],
json: bool,
limit: Optional[int],
pager: Optional[int],

View File

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

View File

@ -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"] = [

View File

@ -1,5 +1,5 @@
import sys
from typing import Optional
from typing import List, Optional
import click
@ -13,7 +13,7 @@ def videos(
*,
all: bool,
compact: bool,
games: list[str],
games: List[str],
json: bool,
limit: Optional[int],
pager: Optional[int],
@ -67,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 []

View File

@ -1,4 +1,5 @@
import os
import httpx
from twitchdl.exceptions import ConsoleError

View File

@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Any, Optional
from typing import Any, Mapping, Optional
@dataclass
@ -22,4 +22,4 @@ class DownloadOptions:
# Type for annotating decoded JSON
# TODO: make data classes for common structs
Data = dict[str, Any]
Data = Mapping[str, Any]

View File

@ -1,6 +1,6 @@
import json
from itertools import islice
from typing import Any, Callable, Generator, Optional, 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)

View File

@ -3,7 +3,7 @@ Parse and manipulate m3u8 playlists.
"""
from dataclasses import dataclass
from typing import Generator, Optional, OrderedDict
from typing import Generator, List, Optional, OrderedDict
import click
import m3u8
@ -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)
@ -55,7 +59,7 @@ def enumerate_vods(
document: m3u8.M3U8,
start: Optional[int] = None,
end: Optional[int] = None,
) -> list[Vod]:
) -> 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: Optional[str]) -> 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: Optional[str]) -> Playli
)
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:

View File

@ -3,7 +3,7 @@ Twitch API access.
"""
import json
from typing import Dict, Generator, Literal, TypedDict, Optional
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}"
@ -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: Optional[str] = None):
def get_channel_clips(
channel_id: str,
period: ClipsPeriod,
limit: int,
after: Optional[str] = None,
):
"""
List channel clips.
@ -294,7 +299,7 @@ def get_channel_videos(
limit: int,
sort: str,
type: str = "archive",
game_ids: Optional[list[str]] = 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: Optional[list[str]] = 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]:
@ -381,7 +386,7 @@ def get_access_token(video_id: str, auth_token: Optional[str] = None) -> AccessT
}}
"""
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": {

View File

@ -1,6 +1,6 @@
import re
from typing import Optional, Union
import unicodedata
from typing import Optional, Union
import click

203
video_before.json Normal file
View 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"
}
}
]
}