Files
TwitchDropsMiner/channel.py
2021-12-03 17:48:51 +01:00

137 lines
4.7 KiB
Python

from __future__ import annotations
import re
import json
import logging
from copy import copy
from base64 import b64encode
from datetime import datetime, timezone
from typing import Any, Optional, Dict, TYPE_CHECKING
from inventory import Game
from exceptions import MinerException
from constants import BASE_URL, GQL_OPERATIONS
if TYPE_CHECKING:
from twitch import Twitch
logger = logging.getLogger("TwitchDrops")
class Stream:
def __init__(self, channel: Channel, data: Dict[str, Any]):
self._twitch = channel._twitch
self.channel = channel
stream = data["stream"]
self.broadcast_id = int(stream["id"])
self.viewer_count = stream["viewersCount"]
self.drops_enabled = any(tag["localizedName"] == "Drops Enabled" for tag in stream["tags"])
settings = data["broadcastSettings"]
self.game: Game = Game(settings["game"])
self.title = settings["title"]
self._timestamp = datetime.now(timezone.utc)
class Channel:
async def __new__(cls, *args, **kwargs):
"""
Enables __init__ to be async.
The instance is returned after initialization completes.
"""
self = super().__new__(cls)
await self.__init__(*args, **kwargs)
return self
async def __init__(self, twitch: Twitch, channel_name: str): # type: ignore
self._twitch: Twitch = twitch
self.id: int = 0 # temp, to be filled by get_stream
self.name: str = channel_name
self.url: str = f"{BASE_URL}/{channel_name}"
self._spade_url: str = await self.get_spade_url()
self.stream: Optional[Stream] = None
await self.get_stream()
@property
def online(self) -> bool:
"""
Returns True if the streamer is online and is currently streaming, False otherwise.
"""
return self.stream is not None
async def get_spade_url(self) -> str:
"""
To get this monstrous thing, you have to walk a chain of requests.
Streamer page (HTML) --parse-> Streamer Settings (JavaScript) --parse-> Spade URL
"""
async with self._twitch._session.get(self.url) as response:
streamer_html = await response.text(encoding="utf8")
match = re.search(
r'src="(https://static\.twitchcdn\.net/config/settings\.[0-9a-f]{32}\.js)"',
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._session.get(streamer_settings) as response:
settings_js = await response.text(encoding="utf8")
match = re.search(
r'"spade_url": ?"(https://video-edge-[.\w\-/]+\.ts)"', settings_js, re.I
)
if not match:
raise MinerException("Error while spade_url extraction: step #2")
return match.group(1)
async def get_stream(self) -> Optional[Stream]:
op = copy(GQL_OPERATIONS["GetStreamInfo"].with_variables({"channel": self.name}))
response = await self._twitch.gql_request(op)
if response:
stream_data = response["data"]["user"]
self.id = int(stream_data["id"]) # fill channel_id
if stream_data["stream"]:
self.stream = Stream(self, stream_data)
else:
self.stream = None
return self.stream
async def check_online(self) -> bool:
stream = await self.get_stream()
if stream is None:
return False
return True
def set_offline(self):
# to be called externally, if we receive an event about this happening
self.stream = None
def _encode_payload(self):
assert self.stream is not None
assert self._twitch._user_id is not None
payload = [
{
"event": "minute-watched",
"properties": {
"channel_id": self.id,
"broadcast_id": self.stream.broadcast_id,
"player": "site",
"user_id": self._twitch._user_id,
}
}
]
json_event = json.dumps(payload, separators=(",", ":"))
return {"data": (b64encode(json_event.encode("utf8"))).decode("utf8")}
async def _send_watch(self):
"""
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
logger.debug(f"Sending minute-watched to {self.name}")
async with self._twitch._session.post(
self._spade_url, data=self._encode_payload()
) as response:
return response.status == 204