Implement channel watching using the new method

This commit is contained in:
DevilXD committed 2024-06-22 18:16:14 +02:00
1 parent c7f8d56946
commit b47b2089c0
5 files changed
+91 -153

No files matched your search

+46 -70
View File
@@ -1,14 +1,11 @@
from __future__ import annotations
import re
import asyncio
import logging
from base64 import b64encode
from functools import cached_property
from typing import Any, SupportsInt, TYPE_CHECKING
from utils import invalidate_cache, json_minify, Game
from exceptions import MinerException, RequestException
from utils import Game
from exceptions import MinerException
from constants import CALL, GQL_OPERATIONS, ONLINE_DELAY, URLType
if TYPE_CHECKING:
@@ -21,7 +18,9 @@ logger = logging.getLogger("TwitchDrops")
class Stream:
__slots__ = ("channel", "broadcast_id", "viewers", "drops_enabled", "game", "title")
__slots__ = (
"channel", "broadcast_id", "viewers", "drops_enabled", "game", "title", "_stream_url"
)
def __init__(
self,
@@ -38,6 +37,7 @@ class Stream:
self.drops_enabled: bool = False
self.game: Game | None = Game(game) if game else None
self.title: str = title
self._stream_url: URLType | None = None
@classmethod
def from_get_stream(cls, channel: Channel, data: JsonType) -> Stream:
@@ -70,6 +70,29 @@ class Stream:
return self.broadcast_id == other.broadcast_id
return NotImplemented
async def get_stream_url(self) -> URLType:
if self._stream_url is not None:
return self._stream_url
# get the stream playback access token from GQL
playback_token_response: JsonType = await self.channel._twitch.gql_request(
GQL_OPERATIONS["PlaybackAccessToken"].with_variables({"login": self.channel._login})
)
token_data: JsonType = playback_token_response["data"]["streamPlaybackAccessToken"]
token_value = token_data["value"]
token_signature = token_data["signature"]
# using the token, query Twitch for a list of all available stream qualities
async with self.channel._twitch.request(
"GET",
URLType(
"https://usher.ttvnw.net/api/channel/hls/"
f"{self.channel._login}.m3u8?sig={token_signature}&token={token_value}"
),
) as qualities_response:
available_qualities: str = await qualities_response.text()
# pick the last URL from the list, usually with the lowest quality stream
self._stream_url = URLType(available_qualities.strip().split("\n")[-1])
return self._stream_url
class Channel:
def __init__(
@@ -86,7 +109,6 @@ class Channel:
self.id: int = int(id)
self._login: str = login
self._display_name: str | None = display_name
self._spade_url: URLType | None = None
self.points: int | None = None
self._stream: Stream | None = None
self._pending_stream_up: asyncio.Task[Any] | None = None
@@ -215,34 +237,6 @@ class Channel:
self._pending_stream_up = None
self._gui_channels.remove(self)
async def get_spade_url(self) -> URLType:
"""
To get this monstrous thing, you have to walk a chain of requests.
Streamer page (HTML) --parse-> Streamer Settings (JavaScript) --parse-> Spade URL
For mobile view, spade_url is available immediately from the page, skipping step #2.
"""
SETTINGS_PATTERN: str = (
r'src="(https://[\w.]+/config/settings\.[0-9a-f]{32}\.js)"'
)
SPADE_PATTERN: str = (
r'"spade_?url": ?"(https://video-edge-[.\w\-/]+\.ts(?:\?allow_stream=true)?)"'
)
async with self._twitch.request("GET", self.url) as response1:
streamer_html: str = await response1.text(encoding="utf8")
match = re.search(SPADE_PATTERN, streamer_html, re.I)
if not match:
match = re.search(SETTINGS_PATTERN, streamer_html, re.I)
if not match:
raise MinerException("Error while spade_url extraction: step #1")
streamer_settings = match.group(1)
async with self._twitch.request("GET", streamer_settings) as response2:
settings_js: str = await response2.text(encoding="utf8")
match = re.search(SPADE_PATTERN, settings_js, re.I)
if not match:
raise MinerException("Error while spade_url extraction: step #2")
return URLType(match.group(1))
async def get_stream(self) -> Stream | None:
try:
response: JsonType = await self._twitch.gql_request(
@@ -283,7 +277,6 @@ class Channel:
"""
old_stream = self._stream
self._stream = await self.get_stream()
invalidate_cache(self, "_payload")
if trigger_events:
self._twitch.on_channel_update(self, old_stream, self._stream)
return self._stream is not None
@@ -328,7 +321,6 @@ class Channel:
if self.online:
old_stream = self._stream
self._stream = None
invalidate_cache(self, "_payload")
self._twitch.on_channel_update(self, old_stream, self._stream)
needs_display = False
if needs_display:
@@ -354,42 +346,26 @@ class Channel:
# so if we're not calling it, we need to do it ourselves
self.display()
@cached_property
def _payload(self) -> JsonType:
assert self._stream is not None
payload = [
{
"event": "minute-watched",
"properties": {
"broadcast_id": str(self._stream.broadcast_id),
"channel_id": str(self.id),
"channel": self._login,
"hidden": False,
"live": True,
"location": "channel",
"logged_in": True,
"muted": False,
"player": "site",
"user_id": self._twitch._auth_state.user_id,
}
}
]
return {"data": (b64encode(json_minify(payload).encode("utf8"))).decode("utf8")}
async def send_watch(self) -> bool:
"""
This uses the encoded payload on spade url to simulate watching the stream.
Optimally, send every 60 seconds to advance drops.
"""
if not self.online:
return False
if self._spade_url is None:
self._spade_url = await self.get_spade_url()
logger.debug(f"Sending minute-watched to {self.name}")
try:
async with self._twitch.request(
"POST", self._spade_url, data=self._payload
) as response:
return response.status == 204
except RequestException:
if self._stream is None:
return False
# get the stream url
stream_url = await self._stream.get_stream_url()
# fetch a list of chunks available to download for the stream
# NOTE: the CDN is configured to forcibly disconnect shortly after serving the list,
# if we don't do it yourselves. Lets help it by actually doing it ourselves instead.
async with self._twitch.request(
"GET", stream_url, headers={"Connection": "close"}
) as chunks_response:
available_chunks: str = await chunks_response.text()
# the list contains ~10-13 chunks of the stream at 2s intervals,
# let's pick the last chunk URL available
stream_chunk_url: URLType = URLType(available_chunks.strip().split("\n")[-1])
# sending a HEAD request is enough to advance the drops,
# without downloading the actual stream data
async with self._twitch.request("HEAD", stream_chunk_url) as head_response:
return head_response.status == 200