diff --git a/channel.py b/channel.py index 581d219..14ff010 100644 --- a/channel.py +++ b/channel.py @@ -9,7 +9,7 @@ from typing import Any, SupportsInt, TYPE_CHECKING from utils import invalidate_cache, json_minify, Game from exceptions import MinerException, RequestException -from constants import BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, DROPS_ENABLED_TAG, URLType +from constants import BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, URLType if TYPE_CHECKING: from twitch import Twitch @@ -31,12 +31,11 @@ class Stream: game: JsonType | None, viewers: int, title: str, - tags: list[JsonType], ): self.channel: Channel = channel self.broadcast_id = int(id) self.viewers: int = viewers - self.drops_enabled: bool = any(t["id"] == DROPS_ENABLED_TAG for t in tags) + self.drops_enabled: bool = False self.game: Game | None = Game(game) if game else None self.title: str = title @@ -50,21 +49,20 @@ class Stream: game=settings["game"], viewers=stream["viewersCount"], title=settings["title"], - tags=stream["tags"] or [], ) @classmethod - def from_directory(cls, channel: Channel, data: JsonType) -> Stream: + def from_directory( + cls, channel: Channel, data: JsonType, *, drops_enabled: bool = False + ) -> Stream: self = cls( channel, id=data["id"], game=data["game"], # has to be there since we searched with it viewers=data["viewersCount"], title=data["title"], - tags=[], ) - # HACK: we assume this is always the case for directory streams, since we search by the tag - # self.drops_enabled = True + self.drops_enabled = drops_enabled return self def __eq__(self, other: object) -> bool: @@ -109,12 +107,14 @@ class Channel: ) @classmethod - def from_directory(cls, twitch: Twitch, data: JsonType) -> Channel: + def from_directory( + cls, twitch: Twitch, data: JsonType, *, drops_enabled: bool = False + ) -> Channel: channel = data["broadcaster"] self = cls( twitch, id=channel["id"], login=channel["login"], display_name=channel["displayName"] ) - self._stream = Stream.from_directory(self, data) + self._stream = Stream.from_directory(self, data, drops_enabled=drops_enabled) return self @classmethod diff --git a/constants.py b/constants.py index 460eed8..0c94030 100644 --- a/constants.py +++ b/constants.py @@ -48,6 +48,30 @@ def _resource_path(relative_path: Path | str) -> Path: return base_path.joinpath(relative_path) +def _merge_vars(base_vars: JsonType, vars: JsonType) -> None: + # NOTE: This modifies base in place + for k, v in vars.items(): + if k not in base_vars: + base_vars[k] = v + elif isinstance(v, dict): + if isinstance(base_vars[k], dict): + _merge_vars(base_vars[k], v) + elif base_vars[k] is Ellipsis: + # unspecified base, use the passed in var + base_vars[k] = v + else: + raise RuntimeError(f"Var is a dict, base is not: '{k}'") + elif isinstance(base_vars[k], dict): + raise RuntimeError(f"Base is a dict, var is not: '{k}'") + else: + # simple overwrite + base_vars[k] = v + # ensure none of the vars are ellipsis (unset value) + for k, v in base_vars.items(): + if v is Ellipsis: + raise RuntimeError(f"Unspecified variable: '{k}'") + + # Base Paths # NOTE: pyinstaller will set this to its own executable when building, # detect this to use __file__ and main.py redirection instead @@ -88,7 +112,6 @@ PING_TIMEOUT = timedelta(seconds=10) ONLINE_DELAY = timedelta(seconds=120) WATCH_INTERVAL = timedelta(seconds=59) # Strings -DROPS_ENABLED_TAG = "c2542d6d-cd10-4532-919b-3d19f30a768b" WINDOW_TITLE = f"Twitch Drops Miner v{__version__} (by DevilXD)" # Logging FILE_FORMATTER = logging.Formatter( @@ -202,7 +225,7 @@ class GQLOperation(JsonType): modified = deepcopy(self) if "variables" in self: existing_variables: JsonType = modified["variables"] - existing_variables.update(variables) + _merge_vars(existing_variables, variables) else: modified["variables"] = variables return modified @@ -284,15 +307,18 @@ GQL_OPERATIONS: dict[str, GQLOperation] = { # returns live channels for a particular game "GameDirectory": GQLOperation( "DirectoryPage_Game", - "df4bb6cc45055237bfaf3ead608bbafb79815c7100b6ee126719fac3762ddf8b", + "3c9a94ee095c735e43ed3ad6ce6d4cbd03c4c6f754b31de54993e0d48fd54e30", variables={ "limit": ..., # limit of channels returned - "name": ..., # game name + "slug": ..., # game slug + "imageWidth": 50, "options": { + "broadcasterLanguages": [], + "freeformTags": None, "includeRestricted": ["SUB_ONLY_LIVE"], "recommendationsContext": {"platform": "web"}, "sort": "RELEVANCE", - "tags": [], # list of tag IDs + "tags": [], "requestID": "JIRA-VXP-2397", }, "sortTypeIsRecency": False, diff --git a/twitch.py b/twitch.py index f3d0f82..2f71e57 100644 --- a/twitch.py +++ b/twitch.py @@ -65,7 +65,6 @@ from constants import ( GQL_OPERATIONS, MAX_CHANNELS, WATCH_INTERVAL, - # DROPS_ENABLED_TAG, State, ClientType, WebsocketTopic, @@ -1537,7 +1536,7 @@ class Twitch: if ( "message" in error_dict and error_dict["message"] in ( - "service error", + # "service error", "service unavailable", "service timeout", "context deadline exceeded", @@ -1558,8 +1557,7 @@ class Twitch: merged = {} for key in set(chain(primary_data.keys(), secondary_data.keys())): in_primary = key in primary_data - in_secondary = key in secondary_data - if in_primary and in_secondary: + if in_primary and key in secondary_data: vp = primary_data[key] vs = secondary_data[key] if not isinstance(vp, type(vs)) or not isinstance(vs, type(vp)): @@ -1693,15 +1691,15 @@ class Twitch: response = await self.gql_request( GQL_OPERATIONS["GameDirectory"].with_variables({ "limit": limit, - "name": game.name, + "slug": game.slug, "options": { "includeRestricted": ["SUB_ONLY_LIVE"], - # "tags": [DROPS_ENABLED_TAG], + "systemFilters": ["DROPS_ENABLED"], }, }) ) return [ - Channel.from_directory(self, stream_channel_data["node"]) + Channel.from_directory(self, stream_channel_data["node"], drops_enabled=True) for stream_channel_data in response["data"]["game"]["streams"]["edges"] ] diff --git a/utils.py b/utils.py index 91ca34e..86adbed 100644 --- a/utils.py +++ b/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations import io import os +import re import sys import json import random @@ -402,3 +403,10 @@ class Game: def __hash__(self) -> int: return self.id + + @property + def slug(self) -> str: + """ + Converts the game name into a slug, useable for the GQL API. + """ + return re.sub(r'(?![- ])\W', '', self.name.lower().replace(' ', '-'))