twitch-dl/twitchdl/twitch.py

385 lines
9.1 KiB
Python
Raw Normal View History

"""
Twitch API access.
"""
import httpx
2022-11-20 08:41:55 +00:00
import json
2024-03-28 11:35:11 +00:00
import click
2018-01-25 10:09:20 +00:00
2022-08-20 09:35:07 +00:00
from typing import Dict
2018-01-25 10:09:20 +00:00
from twitchdl import CLIENT_ID
2019-08-12 13:14:13 +00:00
from twitchdl.exceptions import ConsoleError
2018-01-25 10:09:20 +00:00
2024-03-28 11:35:11 +00:00
class GQLError(click.ClickException):
def __init__(self, errors: list[str]):
message = "GraphQL query failed."
for error in errors:
message += f"\n* {error}"
super().__init__(message)
2020-05-17 12:35:33 +00:00
2020-04-11 14:07:17 +00:00
def authenticated_post(url, data=None, json=None, headers={}):
headers['Client-ID'] = CLIENT_ID
response = httpx.post(url, data=data, 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-03-28 11:35:11 +00:00
def gql_post(query: str):
url = "https://gql.twitch.tv/gql"
2024-03-28 11:35:11 +00:00
response = authenticated_post(url, data=query)
gql_raise_on_error(response)
return response.json()
2022-08-20 09:35:07 +00:00
def gql_query(query: str, headers: Dict[str, str] = {}):
2020-05-17 11:48:48 +00:00
url = "https://gql.twitch.tv/gql"
2024-03-28 11:35:11 +00:00
response = authenticated_post(url, json={"query": query}, headers=headers)
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 {
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
}
"""
2024-03-28 11:06:50 +00:00
def get_video(video_id: str):
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"]
2024-03-28 11:06:50 +00:00
def get_clip(slug: str):
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-03-28 11:06:50 +00:00
def get_clip_access_token(slug: str):
query = f"""
{{
"operationName": "VideoAccessToken_Clip",
"variables": {{
"slug": "{slug}"
}},
"extensions": {{
"persistedQuery": {{
"version": 1,
"sha256Hash": "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11"
}}
}}
}}
"""
2024-03-28 11:06:50 +00:00
response = gql_post(query.strip())
return response["data"]["clip"]
2024-03-28 11:06:50 +00:00
def get_channel_clips(channel_id: str, period: str, limit: int, after: str | None= 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"]
def channel_clips_generator(channel_id, period, limit):
def _generator(clips, limit):
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)
def channel_clips_generator_old(channel_id, period, limit):
2020-09-03 06:49:41 +00:00
cursor = ""
while True:
clips = get_channel_clips(
channel_id, period, limit, after=cursor)
if not clips["edges"]:
break
has_next = clips["pageInfo"]["hasNextPage"]
cursor = clips["edges"][-1]["cursor"] if has_next else None
yield clips, has_next
if not cursor:
break
2024-03-28 11:06:50 +00:00
def get_channel_videos(
channel_id: str,
limit: int,
sort: str,
type: str = "archive",
game_ids: list[str] | None = None,
after: str | None = None
):
game_ids = game_ids or []
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: {{
gameIDs: {game_ids}
}}
) {{
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
def channel_videos_generator(channel_id, max_videos, sort, type, game_ids=[]):
def _generator(videos, max_videos):
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
2022-06-25 02:14:21 +00:00
def get_access_token(video_id, auth_token=None):
2024-03-28 11:06:50 +00:00
query = f"""
{{
videoPlaybackAccessToken(
id: {video_id},
params: {{
platform: "web",
playerBackend: "mediaplayer",
playerType: "site"
}}
) {{
signature
value
}}
}}
"""
2018-01-25 10:09:20 +00:00
2022-06-25 02:14:21 +00:00
headers = {}
if auth_token is not None:
headers['authorization'] = f'OAuth {auth_token}'
try:
response = gql_query(query, headers=headers)
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
def get_playlists(video_id, access_token):
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
response = httpx.get(url, params={
"nauth": access_token['value'],
"nauthsig": access_token['signature'],
2022-02-05 12:57:49 +00:00
"allow_audio_only": "true",
2018-01-25 10:09:20 +00:00
"allow_source": "true",
"player": "twitchweb",
})
response.raise_for_status()
2019-08-23 10:36:05 +00:00
return response.content.decode('utf-8')
2020-05-17 12:35:33 +00:00
def get_game_id(name):
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-03-28 11:06:50 +00:00
def get_video_chapters(video_id: str):
2022-11-20 08:41:55 +00:00
query = {
"operationName": "VideoPlayer_ChapterSelectButtonVideo",
"variables":
{
"includePrivate": False,
"videoID": video_id
},
"extensions":
{
"persistedQuery":
{
"version": 1,
"sha256Hash": "8d2793384aac3773beab5e59bd5d6f585aedb923d292800119e03d40cd0f9b41"
}
}
}
response = gql_post(json.dumps(query))
return list(_chapter_nodes(response["data"]["video"]["moments"]))
def _chapter_nodes(collection):
for edge in collection["edges"]:
node = edge["node"]
del node["moments"]
yield node