twitch-dl/twitchdl/twitch.py

495 lines
12 KiB
Python
Raw Normal View History

"""
Twitch API access.
"""
2024-04-24 06:41:11 +00:00
import logging
import time
from typing import Any, Dict, Generator, List, Literal, Mapping, Optional, Tuple, TypedDict, Union
2024-04-04 06:20:10 +00:00
2024-03-28 11:35:11 +00:00
import click
2024-04-04 06:20:10 +00:00
import httpx
2018-01-25 10:09:20 +00:00
from twitchdl import CLIENT_ID
2024-03-30 06:52:43 +00:00
from twitchdl.entities import Data
2019-08-12 13:14:13 +00:00
from twitchdl.exceptions import ConsoleError
2018-01-25 10:09:20 +00:00
2024-04-01 07:40:54 +00:00
ClipsPeriod = Literal["last_day", "last_week", "last_month", "all_time"]
VideosSort = Literal["views", "time"]
VideosType = Literal["archive", "highlight", "upload"]
2024-04-03 06:14:34 +00:00
class AccessToken(TypedDict):
signature: str
value: str
2024-04-02 07:37:53 +00:00
class User(TypedDict):
login: str
displayName: str
class Game(TypedDict):
id: str
name: str
2024-04-02 07:44:20 +00:00
class VideoQuality(TypedDict):
frameRate: str
quality: str
sourceURL: str
2024-04-04 06:36:10 +00:00
class ClipAccessToken(TypedDict):
id: str
playbackAccessToken: AccessToken
2024-04-23 16:09:30 +00:00
videoQualities: List[VideoQuality]
2024-04-04 06:36:10 +00:00
2024-04-02 07:44:20 +00:00
class Clip(TypedDict):
id: str
slug: str
title: str
createdAt: str
viewCount: int
durationSeconds: int
url: str
2024-04-23 16:09:30 +00:00
videoQualities: List[VideoQuality]
2024-04-02 07:44:20 +00:00
game: Game
broadcaster: User
2024-04-02 07:37:53 +00:00
class Video(TypedDict):
id: str
title: str
description: str
publishedAt: str
broadcastType: str
lengthSeconds: int
game: Game
creator: User
2024-04-01 07:40:54 +00:00
2024-04-03 06:52:51 +00:00
class Chapter(TypedDict):
id: str
durationMilliseconds: int
positionMilliseconds: int
type: str
description: str
subDescription: str
thumbnailURL: str
game: Game
2024-03-28 11:35:11 +00:00
class GQLError(click.ClickException):
2024-04-23 16:09:30 +00:00
def __init__(self, errors: List[str]):
2024-03-28 11:35:11 +00:00
message = "GraphQL query failed."
for error in errors:
message += f"\n* {error}"
super().__init__(message)
2020-05-17 12:35:33 +00:00
2024-04-23 16:27:19 +00:00
Content = Union[str, bytes]
Headers = Dict[str, str]
2024-04-23 16:24:44 +00:00
def authenticated_post(
url: str,
2024-04-23 16:27:19 +00:00
*,
json: Any = None,
content: Optional[Content] = None,
auth_token: Optional[str] = None,
2024-04-23 16:24:44 +00:00
):
2024-04-23 16:27:19 +00:00
headers = {"Client-ID": CLIENT_ID}
if auth_token is not None:
headers["authorization"] = f"OAuth {auth_token}"
2020-04-11 14:07:17 +00:00
2024-04-24 06:41:11 +00:00
response = request("POST", url, content=content, json=json, headers=headers)
2020-04-11 14:07:17 +00:00
if response.status_code == 400:
data = response.json()
raise ConsoleError(data["message"])
response.raise_for_status()
return response
2024-04-24 06:41:11 +00:00
def request(
method: str,
url: str,
json: Any = None,
content: Optional[Content] = None,
headers: Optional[Mapping[str, str]] = None,
):
with httpx.Client() as client:
request = client.build_request(method, url, json=json, content=content, headers=headers)
log_request(request)
start = time.time()
response = client.send(request)
duration = time.time() - start
log_response(response, duration)
return response
logger = logging.getLogger(__name__)
def log_request(request: httpx.Request):
logger.debug(f"--> {request.method} {request.url}")
if request.content:
2024-04-24 12:11:22 +00:00
logger.debug(f"--> {request.content}")
2024-04-24 06:41:11 +00:00
def log_response(response: httpx.Response, duration: float):
request = response.request
duration_ms = int(1000 * duration)
logger.debug(f"<-- {request.method} {request.url} HTTP {response.status_code} {duration_ms}ms")
2024-04-24 12:11:22 +00:00
if response.content:
logger.debug(f"<-- {response.content}")
2024-04-24 06:41:11 +00:00
2024-08-24 18:08:01 +00:00
def gql_persisted_query(query: Data):
url = "https://gql.twitch.tv/gql"
2024-08-24 18:08:01 +00:00
response = authenticated_post(url, json=query)
2024-03-28 11:35:11 +00:00
gql_raise_on_error(response)
return response.json()
2024-04-23 16:27:19 +00:00
def gql_query(query: str, auth_token: Optional[str] = None):
2020-05-17 11:48:48 +00:00
url = "https://gql.twitch.tv/gql"
2024-04-23 16:27:19 +00:00
response = authenticated_post(url, json={"query": query}, auth_token=auth_token)
2024-03-28 11:35:11 +00:00
gql_raise_on_error(response)
return response.json()
2020-05-17 12:35:33 +00:00
2024-03-28 11:35:11 +00:00
def gql_raise_on_error(response: httpx.Response):
data = response.json()
if "errors" in data:
errors = [e["message"] for e in data["errors"]]
raise GQLError(errors)
2020-05-17 11:48:48 +00:00
2021-01-14 20:38:56 +00:00
VIDEO_FIELDS = """
id
title
2024-03-29 08:22:50 +00:00
description
2021-01-14 20:38:56 +00:00
publishedAt
broadcastType
lengthSeconds
game {
2024-04-02 07:37:53 +00:00
id
2021-01-14 20:38:56 +00:00
name
}
creator {
login
displayName
}
"""
2021-04-25 15:30:13 +00:00
CLIP_FIELDS = """
id
slug
title
createdAt
viewCount
durationSeconds
url
videoQualities {
frameRate
quality
sourceURL
}
game {
id
name
}
broadcaster {
displayName
login
}
"""
def get_video(video_id: str) -> Optional[Video]:
2024-03-28 11:06:50 +00:00
query = f"""
2021-01-14 20:38:56 +00:00
{{
video(id: "{video_id}") {{
2024-03-28 11:06:50 +00:00
{VIDEO_FIELDS}
2021-01-14 20:38:56 +00:00
}}
}}
"""
response = gql_query(query)
return response["data"]["video"]
def get_clip(slug: str) -> Optional[Clip]:
2024-03-28 11:06:50 +00:00
query = f"""
2020-04-11 14:07:17 +00:00
{{
2024-03-28 11:06:50 +00:00
clip(slug: "{slug}") {{
{CLIP_FIELDS}
2020-04-11 14:07:17 +00:00
}}
}}
"""
2024-03-28 11:06:50 +00:00
response = gql_query(query)
2020-05-17 11:48:48 +00:00
return response["data"]["clip"]
2020-04-11 14:07:17 +00:00
2024-04-04 06:36:10 +00:00
def get_clip_access_token(slug: str) -> ClipAccessToken:
2024-08-24 18:08:01 +00:00
query = {
"operationName": "VideoAccessToken_Clip",
2024-08-24 18:08:01 +00:00
"variables": {"slug": slug},
"extensions": {
"persistedQuery": {
"version": 1,
2024-08-24 18:08:01 +00:00
"sha256Hash": "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11",
}
},
}
2024-08-24 18:08:01 +00:00
response = gql_persisted_query(query)
2024-04-04 06:36:10 +00:00
return response["data"]["clip"]
2024-04-23 15:35:46 +00:00
def get_channel_clips(
channel_id: str,
period: ClipsPeriod,
limit: int,
after: Optional[str] = None,
):
2020-09-03 06:49:41 +00:00
"""
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
"""
2024-03-28 11:06:50 +00:00
query = f"""
2020-09-03 06:49:41 +00:00
{{
user(login: "{channel_id}") {{
2024-03-28 11:06:50 +00:00
clips(first: {limit}, after: "{after or ''}", criteria: {{ period: {period.upper()}, sort: VIEWS_DESC }}) {{
2020-09-03 06:49:41 +00:00
pageInfo {{
hasNextPage
hasPreviousPage
}}
edges {{
cursor
node {{
2024-03-28 11:06:50 +00:00
{CLIP_FIELDS}
2020-09-03 06:49:41 +00:00
}}
}}
}}
}}
}}
"""
response = gql_query(query)
2020-11-10 11:40:11 +00:00
user = response["data"]["user"]
if not user:
2024-03-28 11:06:50 +00:00
raise ConsoleError(f"Channel {channel_id} not found")
2020-11-10 11:40:11 +00:00
2020-09-03 06:49:41 +00:00
return response["data"]["user"]["clips"]
2024-04-02 07:44:20 +00:00
def channel_clips_generator(
channel_id: str,
period: ClipsPeriod,
2024-04-04 06:20:10 +00:00
limit: int,
2024-04-02 07:44:20 +00:00
) -> Generator[Clip, None, None]:
def _generator(clips: Data, limit: int) -> Generator[Clip, None, None]:
for clip in clips["edges"]:
if limit < 1:
return
yield clip["node"]
limit -= 1
has_next = clips["pageInfo"]["hasNextPage"]
if limit < 1 or not has_next:
return
req_limit = min(limit, 100)
cursor = clips["edges"][-1]["cursor"]
clips = get_channel_clips(channel_id, period, req_limit, cursor)
yield from _generator(clips, limit)
req_limit = min(limit, 100)
clips = get_channel_clips(channel_id, period, req_limit)
return _generator(clips, limit)
2024-03-28 11:06:50 +00:00
def get_channel_videos(
channel_id: str,
limit: int,
sort: str,
type: str = "archive",
2024-04-23 16:09:30 +00:00
game_ids: Optional[List[str]] = None,
after: Optional[str] = None,
2024-03-28 11:06:50 +00:00
):
game_ids = game_ids or []
2024-04-24 12:31:50 +00:00
game_ids_str = f"[{','.join(game_ids)}]"
2024-03-28 11:06:50 +00:00
query = f"""
{{
2020-05-17 12:41:11 +00:00
user(login: "{channel_id}") {{
videos(
first: {limit},
2024-03-28 11:06:50 +00:00
type: {type.upper()},
sort: {sort.upper()},
after: "{after or ''}",
2020-05-17 12:41:11 +00:00
options: {{
2024-04-24 12:31:50 +00:00
gameIDs: {game_ids_str}
2020-05-17 12:41:11 +00:00
}}
) {{
totalCount
pageInfo {{
hasNextPage
}}
edges {{
cursor
node {{
2024-03-28 11:06:50 +00:00
{VIDEO_FIELDS}
2020-05-17 12:41:11 +00:00
}}
}}
}}
}}
}}
2018-01-25 10:09:20 +00:00
"""
2020-05-17 11:48:48 +00:00
response = gql_query(query)
2021-03-22 06:15:37 +00:00
if not response["data"]["user"]:
2024-03-28 11:06:50 +00:00
raise ConsoleError(f"Channel {channel_id} not found")
2021-03-22 06:15:37 +00:00
return response["data"]["user"]["videos"]
2018-01-25 10:09:20 +00:00
2024-03-30 06:52:43 +00:00
def channel_videos_generator(
channel_id: str,
max_videos: int,
sort: VideosSort,
type: VideosType,
2024-04-23 16:09:30 +00:00
game_ids: Optional[List[str]] = None,
2024-04-24 06:10:56 +00:00
) -> Tuple[int, Generator[Video, None, None]]:
2024-03-30 06:52:43 +00:00
game_ids = game_ids or []
2024-04-02 07:37:53 +00:00
def _generator(videos: Data, max_videos: int) -> Generator[Video, None, None]:
for video in videos["edges"]:
if max_videos < 1:
return
yield video["node"]
max_videos -= 1
2020-05-17 12:35:33 +00:00
2020-05-17 12:41:11 +00:00
has_next = videos["pageInfo"]["hasNextPage"]
if max_videos < 1 or not has_next:
return
2020-05-17 11:35:51 +00:00
limit = min(max_videos, 100)
cursor = videos["edges"][-1]["cursor"]
videos = get_channel_videos(channel_id, limit, sort, type, game_ids, cursor)
yield from _generator(videos, max_videos)
2020-05-17 11:35:51 +00:00
limit = min(max_videos, 100)
videos = get_channel_videos(channel_id, limit, sort, type, game_ids)
return videos["totalCount"], _generator(videos, max_videos)
2020-05-17 11:35:51 +00:00
def get_access_token(video_id: str, auth_token: Optional[str] = None) -> AccessToken:
2024-03-28 11:06:50 +00:00
query = f"""
{{
videoPlaybackAccessToken(
2024-05-17 23:52:55 +00:00
id: "{video_id}",
params: {{
platform: "web",
playerBackend: "mediaplayer",
playerType: "site"
}}
) {{
signature
value
}}
}}
"""
2018-01-25 10:09:20 +00:00
try:
2024-04-23 16:27:19 +00:00
response = gql_query(query, auth_token=auth_token)
return response["data"]["videoPlaybackAccessToken"]
except httpx.HTTPStatusError as error:
# Provide a more useful error message when server returns HTTP 401
# Unauthorized while using a user-provided auth token.
if error.response.status_code == 401:
if auth_token:
raise ConsoleError("Unauthorized. The provided auth token is not valid.")
else:
raise ConsoleError(
"Unauthorized. This video may be subscriber-only. See docs:\n"
"https://twitch-dl.bezdomni.net/commands/download.html#downloading-subscriber-only-vods"
)
raise
2018-01-25 10:09:20 +00:00
2024-04-06 08:15:26 +00:00
def get_playlists(video_id: str, access_token: AccessToken) -> str:
2019-08-23 10:36:05 +00:00
"""
For a given video return a playlist which contains possible video qualities.
"""
2024-03-28 11:06:50 +00:00
url = f"https://usher.ttvnw.net/vod/{video_id}"
2018-01-25 10:09:20 +00:00
2024-04-04 06:20:10 +00:00
response = httpx.get(
url,
params={
"nauth": access_token["value"],
"nauthsig": access_token["signature"],
"allow_audio_only": "true",
"allow_source": "true",
"player": "twitchweb",
},
)
2018-01-25 10:09:20 +00:00
response.raise_for_status()
2024-04-03 06:52:51 +00:00
return response.content.decode("utf-8")
2020-05-17 12:35:33 +00:00
2024-04-03 06:14:34 +00:00
def get_game_id(name: str):
2024-03-28 11:06:50 +00:00
query = f"""
2020-05-17 12:35:33 +00:00
{{
2024-03-28 11:06:50 +00:00
game(name: "{name.strip()}") {{
2020-05-17 12:35:33 +00:00
id
}}
}}
"""
2024-03-28 11:06:50 +00:00
response = gql_query(query)
2020-05-17 12:35:33 +00:00
game = response["data"]["game"]
if game:
return game["id"]
2022-11-20 08:41:55 +00:00
2024-04-23 16:09:30 +00:00
def get_video_chapters(video_id: str) -> List[Chapter]:
2022-11-20 08:41:55 +00:00
query = {
"operationName": "VideoPlayer_ChapterSelectButtonVideo",
2024-04-04 06:20:10 +00:00
"variables": {
2022-11-20 08:41:55 +00:00
"includePrivate": False,
2024-04-04 06:20:10 +00:00
"videoID": video_id,
2022-11-20 08:41:55 +00:00
},
2024-04-04 06:20:10 +00:00
"extensions": {
"persistedQuery": {
2022-11-20 08:41:55 +00:00
"version": 1,
2024-04-04 06:20:10 +00:00
"sha256Hash": "8d2793384aac3773beab5e59bd5d6f585aedb923d292800119e03d40cd0f9b41",
2022-11-20 08:41:55 +00:00
}
2024-04-04 06:20:10 +00:00
},
2022-11-20 08:41:55 +00:00
}
2024-08-24 18:08:01 +00:00
response = gql_persisted_query(query)
2022-11-20 08:41:55 +00:00
return list(_chapter_nodes(response["data"]["video"]["moments"]))
2024-04-03 06:52:51 +00:00
def _chapter_nodes(moments: Data) -> Generator[Chapter, None, None]:
for edge in moments["edges"]:
2022-11-20 08:41:55 +00:00
node = edge["node"]
2024-04-03 06:52:51 +00:00
node["game"] = node["details"]["game"]
del node["details"]
2022-11-20 08:41:55 +00:00
del node["moments"]
yield node