Huge GUI update

This commit is contained in:
DevilXD committed 2021-12-29 13:55:05 +01:00
1 parent bddbff7c35
commit c8d3b992d7
10 files changed
+1460 -453

No files matched your search

+1 -1
View File
@@ -2,6 +2,6 @@ __pycache__
.mypy_cache .mypy_cache
/build /build
/dist /dist
/cookies.pickle /cookies.jar
/settings.json /settings.json
/*.spec /*.spec
+8
View File
@@ -38,5 +38,13 @@
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": false "justMyCode": false
}, },
{
"name": "Debug GUI",
"type": "python",
"request": "launch",
"program": "gui.py",
"console": "integratedTerminal",
"justMyCode": false
},
] ]
} }
+103 -19
View File
@@ -4,13 +4,16 @@ import re
import json import json
import asyncio import asyncio
import logging import logging
from time import time
from base64 import b64encode from base64 import b64encode
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional, TYPE_CHECKING from typing import Any, Optional, TYPE_CHECKING
from inventory import Game from inventory import Game
from exceptions import MinerException from exceptions import MinerException
from constants import JsonType, BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, DROPS_ENABLED_TAG from constants import (
JsonType, BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, WATCH_INTERVAL, DROPS_ENABLED_TAG
)
if TYPE_CHECKING: if TYPE_CHECKING:
from twitch import Twitch from twitch import Twitch
@@ -21,17 +24,17 @@ logger = logging.getLogger("TwitchDrops")
class Stream: class Stream:
def __init__(self, channel: Channel, data: JsonType): def __init__(self, channel: Channel, data: JsonType):
self._twitch = channel._twitch self._twitch: Twitch = channel._twitch
self.channel = channel self.channel: Channel = channel
stream = data["stream"] stream = data["stream"]
self.broadcast_id = int(stream["id"]) self.broadcast_id = int(stream["id"])
self.viewer_count = stream["viewersCount"] self.viewers: int = stream["viewersCount"]
self.drops_enabled = any(tag["id"] == DROPS_ENABLED_TAG for tag in stream["tags"]) self.drops_enabled: bool = any(tag["id"] == DROPS_ENABLED_TAG for tag in stream["tags"])
settings = data["broadcastSettings"] settings = data["broadcastSettings"]
self.game: Optional[Game] = None self.game: Optional[Game] = None
if settings["game"] is not None: if settings["game"] is not None:
self.game = Game(settings["game"]) self.game = Game(settings["game"])
self.title = settings["title"] self.title: str = settings["title"]
self._timestamp = datetime.now(timezone.utc) self._timestamp = datetime.now(timezone.utc)
@classmethod @classmethod
@@ -40,7 +43,7 @@ class Stream:
self._twitch = channel._twitch self._twitch = channel._twitch
self.channel = channel self.channel = channel
self.broadcast_id = data["id"] self.broadcast_id = data["id"]
self.viewer_count = data["viewersCount"] self.viewers = data["viewersCount"]
self.drops_enabled = any(tag["id"] == DROPS_ENABLED_TAG for tag in data["tags"]) self.drops_enabled = any(tag["id"] == DROPS_ENABLED_TAG for tag in data["tags"])
self.game = Game(data["game"]) self.game = Game(data["game"])
self.title = data["title"] self.title = data["title"]
@@ -64,7 +67,8 @@ class Channel:
self.name: str = channel_name self.name: str = channel_name
self.url: str = f"{BASE_URL}/{channel_name}" self.url: str = f"{BASE_URL}/{channel_name}"
self._spade_url: Optional[str] = None self._spade_url: Optional[str] = None
self.stream: Optional[Stream] = None self.points: Optional[int] = None
self._stream: Optional[Stream] = None
self._pending_stream_up: Optional[asyncio.Task[Any]] = None self._pending_stream_up: Optional[asyncio.Task[Any]] = None
await self.get_stream() await self.get_stream()
@@ -77,10 +81,14 @@ class Channel:
self.name = channel["displayName"] self.name = channel["displayName"]
self.url = f"{BASE_URL}/{self.name}" self.url = f"{BASE_URL}/{self.name}"
self._spade_url = None self._spade_url = None
self.stream = Stream.from_directory(self, data) self.points = None
self._stream = Stream.from_directory(self, data)
self._pending_stream_up = None self._pending_stream_up = None
return self return self
def __repr__(self) -> str:
return f"Channel({self.name}, {self.id})"
def __eq__(self, other: object): def __eq__(self, other: object):
if isinstance(other, self.__class__): if isinstance(other, self.__class__):
return self.id == other.id return self.id == other.id
@@ -89,12 +97,17 @@ class Channel:
def __hash__(self) -> int: def __hash__(self) -> int:
return hash((self.__class__.__name__, self.id)) return hash((self.__class__.__name__, self.id))
@property
def iid(self) -> str:
# this is responsible for the ID/Key of the columns inside channel list
return str(self.id)
@property @property
def online(self) -> bool: def online(self) -> bool:
""" """
Returns True if the streamer is online and is currently streaming, False otherwise. Returns True if the streamer is online and is currently streaming, False otherwise.
""" """
return self.stream is not None return self._stream is not None
@property @property
def pending_online(self) -> bool: def pending_online(self) -> bool:
@@ -105,6 +118,39 @@ class Channel:
""" """
return self._pending_stream_up is not None return self._pending_stream_up is not None
@property
def game(self) -> Optional[Game]:
if self._stream is not None and self._stream.game is not None:
return self._stream.game
return None
@property
def viewers(self) -> Optional[int]:
if self._stream is not None:
return self._stream.viewers
return None
@viewers.setter
def viewers(self, value: int):
if self._stream is not None:
self._stream.viewers = value
self.display()
@property
def drops_enabled(self) -> Optional[bool]:
if self._stream is not None:
return self._stream.drops_enabled
return None
def display(self):
self._twitch.gui.channels.display(self)
def remove(self):
if self._pending_stream_up is not None:
self._pending_stream_up.cancel()
self._pending_stream_up = None
self._twitch.gui.channels.remove(self)
async def get_spade_url(self) -> str: async def get_spade_url(self) -> str:
""" """
To get this monstrous thing, you have to walk a chain of requests. To get this monstrous thing, you have to walk a chain of requests.
@@ -137,10 +183,10 @@ class Channel:
stream_data = response["data"]["user"] stream_data = response["data"]["user"]
self.id = int(stream_data["id"]) # fill channel_id self.id = int(stream_data["id"]) # fill channel_id
if stream_data["stream"]: if stream_data["stream"]:
self.stream = Stream(self, stream_data) self._stream = Stream(self, stream_data)
else: else:
self.stream = None self._stream = None
return self.stream return self._stream
async def check_online(self) -> bool: async def check_online(self) -> bool:
stream = await self.get_stream() stream = await self.get_stream()
@@ -149,9 +195,11 @@ class Channel:
return True return True
async def _online_delay(self): async def _online_delay(self):
self.display()
await asyncio.sleep(ONLINE_DELAY.total_seconds()) await asyncio.sleep(ONLINE_DELAY.total_seconds())
await self.get_stream() await self.check_online()
self._pending_stream_up = None self._pending_stream_up = None
self.display()
def set_online(self): def set_online(self):
if self.online or self.pending_online: if self.online or self.pending_online:
@@ -160,22 +208,42 @@ class Channel:
# stream-up is sent before the stream actually goes online, so just wait a bit # stream-up is sent before the stream actually goes online, so just wait a bit
# and check if it's actually online by then # and check if it's actually online by then
self._pending_stream_up = asyncio.create_task(self._online_delay()) self._pending_stream_up = asyncio.create_task(self._online_delay())
# display is called from within the task
def set_offline(self): def set_offline(self):
# to be called externally, if we receive an event about this happening # to be called externally, if we receive an event about this happening
if self._pending_stream_up is not None: if self._pending_stream_up is not None:
self._pending_stream_up.cancel() self._pending_stream_up.cancel()
self._pending_stream_up = None self._pending_stream_up = None
self.stream = None self._stream = None
self.display()
async def claim_bonus(self):
# this also fills out the 'points' attribute
response = await self._twitch.gql_request(
GQL_OPERATIONS["ChannelPointsContext"].with_variables({"channelLogin": self.name})
)
channel_data: JsonType = response["data"]["community"]["channel"]
self.points = channel_data["self"]["communityPoints"]["balance"]
claim_available: JsonType = (
channel_data["self"]["communityPoints"]["availableClaim"]
)
if claim_available:
await self._twitch.claim_points(channel_data["id"], claim_available["id"])
logger.info("Claimed bonus points")
else:
# calling claim_points is going to refresh the display, so if we're not calling it,
# we need to do it ourselves
self.display()
def _encode_payload(self): def _encode_payload(self):
assert self.stream is not None assert self._stream is not None
payload = [ payload = [
{ {
"event": "minute-watched", "event": "minute-watched",
"properties": { "properties": {
"channel_id": self.id, "channel_id": self.id,
"broadcast_id": self.stream.broadcast_id, "broadcast_id": self._stream.broadcast_id,
"player": "site", "player": "site",
"user_id": self._twitch._user_id, "user_id": self._twitch._user_id,
} }
@@ -184,17 +252,33 @@ class Channel:
json_event = json.dumps(payload, separators=(",", ":")) json_event = json.dumps(payload, separators=(",", ":"))
return {"data": (b64encode(json_event.encode("utf8"))).decode("utf8")} return {"data": (b64encode(json_event.encode("utf8"))).decode("utf8")}
async def _send_watch(self): async def _send_watch(self) -> bool:
""" """
This uses the encoded payload on spade url to simulate watching the stream. This uses the encoded payload on spade url to simulate watching the stream.
Optimally, send every 60 seconds to advance drops. Optimally, send every 60 seconds to advance drops.
""" """
if not self.online: if not self.online:
return return False
if self._spade_url is None: if self._spade_url is None:
self._spade_url = await self.get_spade_url() self._spade_url = await self.get_spade_url()
logger.debug(f"Sending minute-watched to {self.name}") logger.debug(f"Sending minute-watched to {self.name}")
self._twitch._last_watch = time()
async with self._twitch._session.post( async with self._twitch._session.post(
self._spade_url, data=self._encode_payload() self._spade_url, data=self._encode_payload()
) as response: ) as response:
return response.status == 204 return response.status == 204
async def watch_loop(self):
# last_watch is a timestamp of the last time we've sent a watch payload
# We need this because watch_loop can be cancelled and rescheduled multiple times
# in quick succession, and apparently Twitch doesn't like that very much
interval = WATCH_INTERVAL.total_seconds()
await asyncio.sleep(self._twitch._last_watch + interval - time())
i = 0
while True:
await self._send_watch()
if i == 0:
# ensure every 30 minutes that we don't have unclaimed points bonus
await self.claim_bonus()
i = (i + 1) % 30
await asyncio.sleep(interval)
+16 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from copy import copy from copy import copy
from enum import Enum, auto
from datetime import timedelta from datetime import timedelta
from typing import Any, Optional, Dict, Literal, Callable from typing import Any, Optional, Dict, Literal, Callable
@@ -23,15 +24,22 @@ USER_AGENT = (
) )
# Paths # Paths
SETTINGS_PATH = "settings.json" SETTINGS_PATH = "settings.json"
COOKIES_PATH = "cookies.pickle" COOKIES_PATH = "cookies.jar"
# Intervals and Delays # Intervals and Delays
PING_INTERVAL = timedelta(minutes=3) PING_INTERVAL = timedelta(minutes=3)
PING_TIMEOUT = timedelta(seconds=10) PING_TIMEOUT = timedelta(seconds=10)
ONLINE_DELAY = timedelta(seconds=30) ONLINE_DELAY = timedelta(seconds=60)
WATCH_INTERVAL = timedelta(seconds=58.5)
# Tags # Tags
DROPS_ENABLED_TAG = "c2542d6d-cd10-4532-919b-3d19f30a768b" DROPS_ENABLED_TAG = "c2542d6d-cd10-4532-919b-3d19f30a768b"
# Strings
TERMINATED_STR = "Application Terminated.\nClose the console window to exit the application."
class State(Enum):
INVENTORY_FETCH = auto()
GAME_SELECT = auto()
CHANNEL_FETCH = auto()
CHANNEL_CLEANUP = auto()
CHANNEL_SWITCH = auto()
class GQLOperation(JsonType): class GQLOperation(JsonType):
@@ -83,6 +91,10 @@ GQL_OPERATIONS: Dict[str, GQLOperation] = {
"Inventory", "Inventory",
"e0765ebaa8e8eeb4043cc6dfeab3eac7f682ef5f724b81367e6e55c7aef2be4c", "e0765ebaa8e8eeb4043cc6dfeab3eac7f682ef5f724b81367e6e55c7aef2be4c",
), ),
"CurrentDrop": GQLOperation(
"DropCurrentSessionContext",
"2e4b3630b91552eb05b76a94b6850eb25fe42263b7cf6d06bee6d156dd247c1c",
),
"ViewerDropsDashboard": GQLOperation( "ViewerDropsDashboard": GQLOperation(
"ViewerDropsDashboard", "ViewerDropsDashboard",
"c4d61d7b71d03b324914d3cf8ca0bc23fe25dacf54120cc954321b9704a3f4e2", "c4d61d7b71d03b324914d3cf8ca0bc23fe25dacf54120cc954321b9704a3f4e2",
+827
View File
@@ -0,0 +1,827 @@
from __future__ import annotations
import asyncio
import logging
import tkinter as tk
from math import log10, ceil
from tkinter.font import Font
from collections import namedtuple, OrderedDict
from tkinter import Tk, ttk, StringVar, DoubleVar
from typing import Any, Optional, List, Dict, Set, TypedDict, Iterable, NoReturn, TYPE_CHECKING
from version import __version__
from constants import WS_TOPICS_LIMIT, MAX_WEBSOCKETS, State
if TYPE_CHECKING:
from twitch import Twitch
from channel import Channel
from inventory import Game, TimedDrop
digits = ceil(log10(WS_TOPICS_LIMIT))
WS_FONT = ("Courier New", 10)
class TKOutputHandler(logging.Handler):
def __init__(self, output: GUIManager):
super().__init__()
self._output = output
def emit(self, record):
self._output.print(self.format(record))
class PlaceholderEntry(ttk.Entry):
def __init__(
self,
master,
*args,
placeholder: str,
placeholdercolor: str = "grey60",
**kwargs,
):
super().__init__(master, *args, **kwargs)
self._show: str = kwargs.get("show", '')
self._text_color: str = kwargs.get("foreground", '')
self._ph_color: str = placeholdercolor
self._ph_text: str = placeholder
self.bind("<FocusIn>", self._focus_in)
self.bind("<FocusOut>", self._focus_out)
self._ph: bool = False
self._focus_out(None)
def _focus_in(self, event):
"""
On focus in, if we've had a placeholder, clear the box and set normal text colour and show.
"""
if self._ph:
self._ph = False
self.config(foreground=self._text_color, show=self._show)
self.delete(0, "end")
def _focus_out(self, event):
"""
On focus out, if we're empty, insert a placeholder,
set placeholder text color and make sure it's shown.
If we're not empty, leave the box as is.
"""
if not super().get():
self._ph = True
self.config(foreground=self._ph_color, show='')
self.insert(0, self._ph_text)
def _store_option(self, options: Dict[str, Any], attr: str, name: str):
value = options.get(name)
if value is not None:
setattr(self, attr, value)
def configure(self, *args, **kwargs):
if args:
options = args[0]
if kwargs:
options = kwargs
self._store_option(options, "_show", "show")
self._store_option(options, "_ph_text", "placeholder")
self._store_option(options, "_text_color", "foreground")
self._store_option(options, "_ph_color", "placeholdercolor")
super().configure(*args, *kwargs)
def get(self):
if self._ph:
return ''
return super().get()
def clear(self):
self.delete(0, "end")
self._ph = True
self.config(foreground=self._ph_color, show='')
self.insert(0, self._ph_text)
class _WSEntry(TypedDict):
status: str
topics: int
class WebsocketStatus:
def __init__(self, manager: GUIManager, master: tk.Misc):
self._status_var = StringVar()
self._topics_var = StringVar()
frame = ttk.LabelFrame(master, text="Websocket Status", padding=(4, 0, 4, 4))
frame.grid(column=0, row=0, sticky="nsew", padx=2)
ttk.Label(
frame,
text='\n'.join(f"Websocket #{i}:" for i in range(1, MAX_WEBSOCKETS + 1)),
font=WS_FONT,
).grid(column=0, row=0)
ttk.Label(
frame,
textvariable=self._status_var,
width=16,
justify="left",
font=WS_FONT,
).grid(column=1, row=0)
ttk.Label(
frame,
textvariable=self._topics_var,
width=(digits * 2 + 1),
justify="right",
font=WS_FONT,
).grid(column=2, row=0)
self._items: Dict[int, Optional[_WSEntry]] = {i: None for i in range(MAX_WEBSOCKETS)}
self._update()
def update(self, idx: int, status: Optional[str] = None, topics: Optional[int] = None):
if status is None and topics is None:
raise TypeError("You need to provide at least one of: status, topics")
entry = self._items.get(idx)
if entry is None:
entry = self._items[idx] = _WSEntry(status="Disconnected", topics=0)
if status is not None:
entry["status"] = status
if topics is not None:
entry["topics"] = topics
self._update()
def remove(self, idx: int):
if idx in self._items:
del self._items[idx]
def _update(self):
status_lines: List[str] = []
topic_lines: List[str] = []
for idx in range(MAX_WEBSOCKETS):
item = self._items.get(idx)
if item is None:
status_lines.append('')
topic_lines.append('')
else:
status_lines.append(item["status"])
topic_lines.append(f"{item['topics']:>{digits}}/{WS_TOPICS_LIMIT}")
self._status_var.set('\n'.join(status_lines))
self._topics_var.set('\n'.join(topic_lines))
LoginData = namedtuple("LoginData", ["username", "password", "token"])
class LoginForm:
def __init__(self, manager: GUIManager, master: tk.Misc):
self._manager = manager
self._var = StringVar()
frame = ttk.LabelFrame(master, text="Login Status", padding=(4, 0, 4, 4))
frame.grid(column=1, row=0, sticky="nsew", padx=2)
frame.columnconfigure(0, weight=2)
frame.columnconfigure(1, weight=1)
frame.rowconfigure(4, weight=1)
ttk.Label(frame, text=("Status:\nUser ID:")).grid(column=0, row=0)
ttk.Label(frame, textvariable=self._var, justify="center").grid(column=1, row=0)
self._login_entry = PlaceholderEntry(frame, placeholder="Login")
self._login_entry.grid(column=0, row=1, columnspan=2)
self._pass_entry = PlaceholderEntry(frame, placeholder="Password", show='•')
self._pass_entry.grid(column=0, row=2, columnspan=2)
self._token_entry = PlaceholderEntry(frame, placeholder="2FA Code")
self._token_entry.grid(column=0, row=3, columnspan=2)
self._confirm = asyncio.Event()
self._button = ttk.Button(frame, text="Login", command=self._confirm.set, state="disabled")
self._button.grid(column=0, row=4, columnspan=2)
self.update("Logged out", None)
def clear(self, login: bool = False, password: bool = False, token: bool = False):
clear_all = not login and not password and not token
if login or clear_all:
self._login_entry.clear()
if password or clear_all:
self._pass_entry.clear()
if token or clear_all:
self._token_entry.clear()
async def ask_login(self) -> LoginData:
self._manager.print("Please log in.")
self._confirm.clear()
self._button.config(state="normal")
await self._confirm.wait()
self._button.config(state="disabled")
data = LoginData(self._login_entry.get(), self._pass_entry.get(), self._token_entry.get())
return data
def update(self, status: str, user_id: Optional[int]):
if user_id is not None:
user_str = str(user_id)
else:
user_str = "-"
self._var.set(f"{status}\n{user_str}")
class GameSelector:
def __init__(self, manager: GUIManager, master: tk.Misc):
self._manager = manager
self._var = StringVar()
frame = ttk.LabelFrame(master, text="Game Selector", padding=(4, 0, 4, 4))
frame.grid(column=1, row=1, sticky="nsew", padx=2)
frame.columnconfigure(0, weight=1)
self._list = tk.Listbox(
frame,
height=5,
selectmode="single",
activestyle="none",
exportselection=False,
highlightthickness=0,
)
self._list.pack(fill="both", expand=True)
self._selection: Optional[str] = self._manager._twitch._options.game
self._games: OrderedDict[str, Game] = OrderedDict()
self._list.bind("<<ListboxSelect>>", self._on_select)
@property
def selected(self) -> Optional[str]:
return self._selection
def set_games(self, games: Iterable[Game]):
self._games.clear()
self._games.update((str(g), g) for g in sorted(games, key=lambda g: g.name))
self._list.delete(0, "end")
self._list.insert("end", *self._games.keys())
self._list.config(width=0) # autoadjust listbox width
if self._selection is not None:
selected_index: Optional[int] = next(
(
i
for i, str_game in enumerate(self._games.keys())
if str_game == self._selection
),
None,
)
if selected_index is not None:
# reselect the currently selected item
self._list.selection_set(selected_index)
else:
# the game we've had selected isn't there anymore - clear selection
self._selection = None
def _on_select(self, event):
current = self._list.curselection()
if not current:
# can happen when the user clicks on an empty list
self._selection = None
else:
self._selection = self._list.get(current[0])
def get_selection(self) -> Game:
if self._selection is None:
if not self._games:
raise RuntimeError("No games to select from")
# select and return the first game from the list
self._list.selection_set(0)
first_game = next(iter(self._games.values()))
self._selection = str(first_game)
return first_game
return self._games[self._selection]
def get_next_selection(self) -> Optional[Game]:
current = self._list.curselection()
if not current:
return self.get_selection()
game_name = self._list.get(current[0]+1)
if game_name:
return self._games[game_name]
else:
# this was the last game on the list
return None
class _BaseVars(TypedDict):
progress: DoubleVar
percentage: StringVar
remaining: StringVar
minutes: int
class _CampaignVars(_BaseVars):
name: StringVar
class _DropVars(_BaseVars):
rewards: StringVar
class _ProgressVars(TypedDict):
campaign: _CampaignVars
drop: _DropVars
seconds: int
class CampaignProgress:
BAR_LENGTH = 240
def __init__(self, manager: GUIManager, master: tk.Misc):
self._vars: _ProgressVars = {
"campaign": {
"name": StringVar(), # campaign name
"progress": DoubleVar(), # controls the progress bar
"percentage": StringVar(), # percentage display string
"remaining": StringVar(), # time remaining string
"minutes": 0, # remaining minutes
},
"drop": {
"rewards": StringVar(), # drop rewards
"progress": DoubleVar(), # as above
"percentage": StringVar(), # as above
"remaining": StringVar(), # as above
"minutes": 0, # as above
},
"seconds": 1, # remaining seconds (common for both campaign and drop)
}
self._frame = frame = ttk.LabelFrame(
master, text="Campaign Progress", padding=(4, 0, 4, 4)
)
frame.grid(column=0, row=1, sticky="nsew", padx=2)
frame.columnconfigure(0, weight=2)
frame.columnconfigure(1, weight=1)
ttk.Label(frame, text="Campaign:").grid(column=0, row=0, columnspan=2)
ttk.Label(
frame, textvariable=self._vars["campaign"]["name"]
).grid(column=0, row=1, columnspan=2)
ttk.Label(frame, text="Progress:").grid(column=0, row=2, rowspan=2)
ttk.Label(frame, textvariable=self._vars["campaign"]["percentage"]).grid(column=1, row=2)
ttk.Label(frame, textvariable=self._vars["campaign"]["remaining"]).grid(column=1, row=3)
ttk.Progressbar(
frame,
mode="determinate",
length=self.BAR_LENGTH,
maximum=1,
variable=self._vars["campaign"]["progress"],
).grid(column=0, row=4, columnspan=2)
ttk.Separator(
frame, orient="horizontal"
).grid(row=5, columnspan=2, sticky="ew", pady=(4, 0))
ttk.Label(frame, text="Drop:").grid(column=0, row=6, columnspan=2)
ttk.Label(
frame, textvariable=self._vars["drop"]["rewards"]
).grid(column=0, row=7, columnspan=2)
ttk.Label(frame, text="Progress:").grid(column=0, row=8, rowspan=2)
ttk.Label(frame, textvariable=self._vars["drop"]["percentage"]).grid(column=1, row=8)
ttk.Label(frame, textvariable=self._vars["drop"]["remaining"]).grid(column=1, row=9)
ttk.Progressbar(
frame,
mode="determinate",
length=self.BAR_LENGTH,
maximum=1,
variable=self._vars["drop"]["progress"],
).grid(column=0, row=10, columnspan=2)
self._timer_task: Optional[asyncio.Task[None]] = None
self._update_time()
def _update_time(self) -> bool:
# read vars
minutes_changed: bool = False
seconds: int = self._vars["seconds"]
drop_vars: _DropVars = self._vars["drop"]
campaign_vars: _CampaignVars = self._vars["campaign"]
drop_minutes: int = drop_vars["minutes"]
campaign_minutes: int = campaign_vars["minutes"]
# handle seconds
if seconds <= 0:
if drop_minutes > 0:
drop_minutes -= 1
minutes_changed = True
if campaign_minutes > 0:
campaign_minutes -= 1
minutes_changed = True
if minutes_changed:
seconds = 60
if seconds > 0:
seconds -= 1
# display time
hours, minutes = divmod(drop_minutes, 60)
drop_vars["remaining"].set(f"{hours:>2}:{minutes:02}:{seconds:02} remaining")
hours, minutes = divmod(campaign_minutes, 60)
campaign_vars["remaining"].set(f"{hours:>2}:{minutes:02}:{seconds:02} remaining")
# store back
self._vars["seconds"] = seconds
if minutes_changed:
drop_vars["minutes"] = drop_minutes
campaign_vars["minutes"] = campaign_minutes
# if there's no time left, stop the loop
if campaign_minutes + drop_minutes + seconds > 0:
return True
return False
async def _timer_loop(self):
run = self._update_time()
while run:
await asyncio.sleep(1)
run = self._update_time()
self._timer_task = None
def start_timer(self):
if self._timer_task is None:
self._vars["seconds"] = 1
self._timer_task = asyncio.create_task(self._timer_loop())
def stop_timer(self):
if self._timer_task is not None:
self._timer_task.cancel()
self._timer_task = None
def restart_timer(self):
self.stop_timer()
self.start_timer()
def update(self, drop: TimedDrop):
# campaign update
campaign = drop.campaign
vars_campaign = self._vars["campaign"]
vars_campaign["name"].set(campaign.name)
vars_campaign["progress"].set(campaign.progress)
vars_campaign["percentage"].set(
f"{campaign.progress:6.1%} ({campaign.claimed_drops}/{campaign.total_drops})"
)
vars_campaign["minutes"] = campaign.remaining_minutes
# drop update
vars_drop = self._vars["drop"]
vars_drop["rewards"].set(drop.rewards_text())
vars_drop["progress"].set(drop.progress)
vars_drop["percentage"].set(f"{drop.progress:6.1%}")
vars_drop["minutes"] = drop.remaining_minutes
# reschedule our seconds update timer
self.restart_timer()
class ConsoleOutput:
def __init__(self, manager: GUIManager, master: tk.Misc):
frame = ttk.LabelFrame(master, text="Output", padding=(4, 0, 4, 4))
frame.grid(column=0, row=2, columnspan=2, sticky="nsew", padx=2)
frame.rowconfigure(0, weight=1) # let the frame expand
frame.columnconfigure(0, weight=1)
master.rowconfigure(2, weight=1) # tell master frame that the containing row can expand
xscroll = ttk.Scrollbar(frame, orient="horizontal")
yscroll = ttk.Scrollbar(frame, orient="vertical")
self._text = tk.Text(
frame,
exportselection=False,
height=10,
width=52,
wrap="none",
state="disabled",
xscrollcommand=xscroll.set,
yscrollcommand=yscroll.set,
)
xscroll.config(command=self._text.xview)
yscroll.config(command=self._text.yview)
self._text.grid(column=0, row=0, sticky="nsew")
xscroll.grid(column=0, row=1, sticky="ew")
yscroll.grid(column=1, row=0, sticky="ns")
def print(self, *values, sep: str = ' ', end: str = '\n'):
self._text.config(state="normal")
self._text.insert("end", f"{sep.join(values)}{end}")
self._text.see("end") # scroll to the newly added line
self._text.config(state="disabled")
class Buttons(TypedDict):
frame: ttk.Frame
cleanup: ttk.Button
switch: ttk.Button
load_points: ttk.Button
class ChannelList:
def __init__(self, manager: GUIManager, master: tk.Misc):
self._manager = manager
frame = ttk.LabelFrame(master, text="Channels", padding=(4, 0, 4, 4))
frame.grid(column=2, row=0, rowspan=3, sticky="nsew", padx=2)
frame.rowconfigure(1, weight=1)
frame.columnconfigure(0, weight=1)
# tell master frame that the containing column can expand
master.columnconfigure(2, weight=1)
buttons_frame = ttk.Frame(frame)
self._buttons: Buttons = {
"frame": buttons_frame,
"cleanup": ttk.Button(
buttons_frame,
text="Cleanup",
command=manager._twitch.state_change(State.CHANNEL_CLEANUP),
),
"switch": ttk.Button(
buttons_frame,
text="Switch",
state="disabled",
command=manager._twitch.state_change(State.CHANNEL_SWITCH),
),
"load_points": ttk.Button(
buttons_frame, text="Load Points", command=self._load_points
),
}
buttons_frame.grid(column=0, row=0, columnspan=2)
self._buttons["cleanup"].grid(column=0, row=0)
self._buttons["switch"].grid(column=1, row=0)
self._buttons["load_points"].grid(column=2, row=0)
scroll = ttk.Scrollbar(frame, orient="vertical")
self._table = table = ttk.Treeview(
frame,
columns=("channel", "status", "game", "viewers", "points"),
yscrollcommand=scroll.set,
)
scroll.config(command=table.yview)
table.grid(column=0, row=1, sticky="nsew")
scroll.grid(column=1, row=1, sticky="ns")
self._font = Font(frame, manager._style.lookup("Treeview", "font"))
self._const_width: Set[str] = set()
table.tag_configure("watching", background="gray70")
table.bind("<Button-1>", self._disable_column_resize)
table.bind("<<TreeviewSelect>>", self._selected)
self._column("#0", '', width=0)
self._column("channel", "Channel", width=100, anchor='w')
self._column("status", "Status", width_template="OFFLINE ❌")
self._column("game", "Game", width=50)
self._column("viewers", "Viewers", width_template="0000000")
self._column("points", "Points", width_template="0000000")
self._channel_map: Dict[str, Channel] = {}
def _column(
self,
cid: str,
name: str,
*,
anchor: str = "center",
width: Optional[int] = None,
width_template: Optional[str] = None,
):
if width_template is not None:
width = self._measure(width_template)
self._const_width.add(cid)
assert width is not None
self._table.column(cid, width=width, stretch=False)
self._table.heading(cid, text=name, anchor=anchor)
def _disable_column_resize(self, event):
if self._table.identify_region(event.x, event.y) == "separator":
return "break"
def _selected(self, event):
selection = self._table.selection()
if selection:
self._buttons["switch"].config(state="normal")
else:
self._buttons["switch"].config(state="disabled")
def _load_points(self):
# disable the button afterwards
self._buttons["load_points"].config(state="disabled")
asyncio.gather(*(ch.claim_bonus() for ch in self._manager._twitch.channels.values()))
def _measure(self, text: str) -> int:
# we need this because columns have 9-10 pixels of padding that cuts text off
return self._font.measure(text) + 10
def _adjust_width(self, column: str, value: str):
# causes the column to expand if the value's width is greater than the current width
if column in self._const_width:
return
value_width = self._measure(value)
curr_width = self._table.column(column, "width")
if value_width > curr_width:
self._table.column(column, minwidth=value_width, width=value_width)
self._table.event_generate("<<ThemeChanged>>") # force redraw
def _set(self, iid: str, column: str, value: str):
self._table.set(iid, column, value)
self._adjust_width(column, value)
def _insert(self, iid: str, *args: str):
self._table.insert(parent='', index="end", iid=iid, values=args)
for column, value in zip(self._table.cget("columns"), args):
self._adjust_width(column, value)
def clear_watching(self):
for iid in self._table.tag_has("watching"):
self._table.item(iid, tags='')
def set_watching(self, channel: Channel):
self.clear_watching()
self._table.item(channel.iid, tags="watching")
def get_selection(self) -> Optional[Channel]:
if not self._channel_map:
return None
selection = self._table.selection()
if not selection:
return None
return self._channel_map[selection[0]]
def clear_selection(self):
self._table.selection_set('')
def display(self, channel: Channel):
# status
if channel.online:
status_str = "ONLINE ✅"
elif channel.pending_online:
status_str = "OFFLINE ⏰"
else:
status_str = "OFFLINE ❌"
# game
game_str = str(channel.game or '')
# viewers
viewers_str = ''
if channel.viewers is not None:
viewers_str = str(channel.viewers)
# points
points_str = ''
if channel.points is not None:
points_str = str(channel.points)
iid = channel.iid
if self._table.exists(iid):
self._set(iid, "status", status_str)
self._set(iid, "game", game_str)
self._set(iid, "viewers", viewers_str)
if points_str:
self._set(iid, "points", points_str)
else:
self._channel_map[iid] = channel
self._insert(iid, channel.name, status_str, game_str, viewers_str, points_str)
def remove(self, channel: Channel):
iid = channel.iid
del self._channel_map[iid]
self._table.delete(iid)
class GUIManager:
def __init__(self, twitch: Twitch):
self._twitch: Twitch = twitch
self._poll_task: Optional[asyncio.Task[NoReturn]] = None
self._closed = asyncio.Event()
self._root = root = Tk()
root.resizable(False, True)
root.iconbitmap("pickaxe.ico") # window icon
root.title(f"Twitch Drops Miner v{__version__} (by DevilXD)") # window title
root.protocol("WM_DELETE_WINDOW", self._on_close)
root.bind_all("<KeyPress-Escape>", self.unfocus)
self._style = ttk.Style(root)
self._style.map(
"Treeview",
foreground=self._fixed_map("foreground"),
background=self._fixed_map("background"),
)
main_frame = ttk.Frame(root, padding=8)
main_frame.grid(sticky="nsew")
root.rowconfigure(0, weight=1)
root.columnconfigure(0, weight=1)
self.websockets = WebsocketStatus(self, main_frame)
self.login = LoginForm(self, main_frame)
self.progress = CampaignProgress(self, main_frame)
self.games = GameSelector(self, main_frame)
self.output = ConsoleOutput(self, main_frame)
self.channels = ChannelList(self, main_frame)
# clamp minimum window height (update first, so that geometry calculates the size)
root.update_idletasks()
root.minsize(width=0, height=root.winfo_reqheight())
# register logging handler
handler = TKOutputHandler(self)
handler.setFormatter(
logging.Formatter("{asctime}: {levelname}: {message}", style='{', datefmt="%H:%M:%S")
)
logging.getLogger("TwitchDrops").addHandler(handler)
# https://stackoverflow.com/questions/56329342/tkinter-treeview-background-tag-not-working
def _fixed_map(self, option):
# Fix for setting text colour for Tkinter 8.6.9
# From: https://core.tcl.tk/tk/info/509cafafae
#
# Returns the style map for 'option' with any styles starting with
# ('!disabled', '!selected', ...) filtered out.
# style.map() returns an empty list for missing options, so this
# should be future-safe.
return [
elm for elm in self._style.map("Treeview", query_opt=option)
if elm[:2] != ("!disabled", "!selected")
]
@property
def running(self) -> bool:
return self._poll_task is not None
@property
def close_requested(self) -> bool:
return self._closed.is_set()
def start(self):
if self._poll_task is None:
self._poll_task = asyncio.create_task(self._poll())
# self.progress.start_timer()
def stop(self):
self.progress.stop_timer()
if self._poll_task is not None:
self._poll_task.cancel()
self._poll_task = None
async def _poll(self):
"""
This runs the Tkinter event loop via asyncio instead of calling mainloop.
0.05s gives similar performance and CPU usage.
Not ideal, but the simplest way to avoid threads, thread safety,
loop.call_soon_threadsafe, futures and all of that.
"""
update = self._root.update
while True:
update()
await asyncio.sleep(0.05)
def unfocus(self, event):
self._root.focus_set()
self.channels.clear_selection()
def _on_close(self):
self._closed.set()
# notify client we're supposed to close
self._twitch.request_close()
def prevent_close(self):
self._closed.clear()
async def wait_until_closed(self):
# wait until the user closes the window
await self._closed.wait()
def close(self):
self.stop()
if self._root is not None:
self._root.destroy()
self._closed.set()
def print(self, *args, **kwargs):
# print to our custom output
self.output.print(*args, **kwargs)
if __name__ == "__main__":
# Everything below is for debug purposes only
from types import SimpleNamespace
class StrNamespace(SimpleNamespace):
def __str__(self):
if hasattr(self, "_str__"):
return self._str__(self)
return super().__str__()
def state_change(state: State):
def changer(state: State = state):
gui.print(f"State change: {state.value}")
return changer
gui: GUIManager
mock = SimpleNamespace(
_options=SimpleNamespace(game=None),
state_change=state_change,
)
gui = GUIManager(mock) # type: ignore
mock.request_close = gui._root.destroy
def create_game(id: int, name: str):
return StrNamespace(name=name, id=id, _str__=lambda s: s.name)
iid = 0
def create_channel(name: str, online: int, game: Optional[str], viewers: int, points: int):
if online == 1:
online = False
pending = True
else:
pending = False
if game is not None:
game_obj: Optional[StrNamespace] = create_game(0, game)
else:
game_obj = None
global iid
return SimpleNamespace(
name=name,
iid=(iid := iid + 1),
points=points,
online=bool(online),
pending_online=pending,
game=game_obj,
viewers=viewers,
)
# Game selctor
gui.games.set_games([
create_game(491115, "Paladins"),
create_game(460630, "Tom Clancy's Rainbow Six Siege"),
])
game = gui.games.get_next_selection()
game = gui.games.get_next_selection()
game = gui.games.get_next_selection()
# Channel list
gui.channels.display(create_channel("PaladinsGame", 0, None, 0, 0))
channel = create_channel("Traitus", 1, None, 0, 0)
gui.channels.display(channel)
gui.channels.display(create_channel("Testus", 2, "Paladins", 42, 1234567))
gui.channels.set_watching(channel)
gui._root.update()
gui.channels.get_selection()
gui._root.mainloop()
+15 -3
View File
@@ -17,6 +17,9 @@ class Game:
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
def __repr__(self) -> str:
return f"Game({self.id}, {self.name})"
def __eq__(self, other: object): def __eq__(self, other: object):
if isinstance(other, self.__class__): if isinstance(other, self.__class__):
return self.id == other.id return self.id == other.id
@@ -93,15 +96,14 @@ class TimedDrop(BaseDrop):
@property @property
def remaining_minutes(self) -> int: def remaining_minutes(self) -> int:
return self.required_minutes - self.current_minutes return self.required_minutes - self.current_minutes + 1
@property @property
def progress(self) -> float: def progress(self) -> float:
return self.current_minutes / self.required_minutes return self.current_minutes / self.required_minutes
def update(self, message: JsonType): def update(self, message: JsonType):
# {"type": "drop-progress", data: {"current_progress_min": 3, "required_progress_min": 10}} # See Twitch.process_drop for message examples
# {"type": "drop-claim", data: {"drop_instance_id": ...}}
msg_type = message["type"] msg_type = message["type"]
if msg_type == "drop-progress": if msg_type == "drop-progress":
self.current_minutes = message["data"]["current_progress_min"] self.current_minutes = message["data"]["current_progress_min"]
@@ -109,6 +111,10 @@ class TimedDrop(BaseDrop):
elif msg_type == "drop-claim": elif msg_type == "drop-claim":
self.claim_id = message["data"]["drop_instance_id"] self.claim_id = message["data"]["drop_instance_id"]
def bump_minutes(self):
if self.current_minutes < self.required_minutes:
self.current_minutes += 1
class DropsCampaign: class DropsCampaign:
def __init__(self, twitch: Twitch, data: JsonType): def __init__(self, twitch: Twitch, data: JsonType):
@@ -149,3 +155,9 @@ class DropsCampaign:
def get_drop(self, drop_id: str) -> Optional[TimedDrop]: def get_drop(self, drop_id: str) -> Optional[TimedDrop]:
return self.timed_drops.get(drop_id) return self.timed_drops.get(drop_id)
def get_active_drop(self) -> Optional[TimedDrop]:
for drop in self.timed_drops.values():
if drop.can_earn:
return drop
return None
+7 -92
View File
@@ -1,51 +1,18 @@
from __future__ import annotations from __future__ import annotations
__version__ = 4
import sys
import json
import ctypes
import logging import logging
import asyncio
import argparse import argparse
import warnings from typing import Optional
import traceback
import threading
from typing import NoReturn
from twitch import Twitch from twitch import Twitch
from constants import JsonType, SETTINGS_PATH, TERMINATED_STR from version import __version__
try:
import win32api
from win32con import CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT
except (ImportError, ModuleNotFoundError):
raise ImportError("You have to run 'python -m pip install pywin32' first")
# disable some warnings
warnings.simplefilter("ignore", RuntimeWarning)
warnings.simplefilter("ignore", DeprecationWarning)
# nice console title
try:
ctypes.windll.kernel32.SetConsoleTitleW(f"Twitch Drops Miner v{__version__} (by DevilXD)")
except AttributeError:
# ensure we're on windows and there was no import problems
print("Only Windows supported!")
quit()
assert sys.platform == "win32"
def terminate() -> NoReturn:
forever = threading.Event()
print(f"\n{TERMINATED_STR}")
forever.wait()
raise RuntimeError("Uh oh") # this will never run, solely for MyPy
class ParsedArgs(argparse.Namespace): class ParsedArgs(argparse.Namespace):
_verbose: int _verbose: int
_debug_ws: bool _debug_ws: bool
_debug_gql: bool _debug_gql: bool
game: Optional[str]
@property @property
def logging_level(self) -> int: def logging_level(self) -> int:
@@ -87,6 +54,7 @@ parser.add_argument("-V", "--version", action="version", version=f"v{__version__
parser.add_argument("-v", dest="_verbose", action="count", default=0) parser.add_argument("-v", dest="_verbose", action="count", default=0)
parser.add_argument("--debug-ws", dest="_debug_ws", action="store_true") parser.add_argument("--debug-ws", dest="_debug_ws", action="store_true")
parser.add_argument("--debug-gql", dest="_debug_gql", action="store_true") parser.add_argument("--debug-gql", dest="_debug_gql", action="store_true")
parser.add_argument("-g", "--game", default=None)
options: ParsedArgs = parser.parse_args(namespace=ParsedArgs()) options: ParsedArgs = parser.parse_args(namespace=ParsedArgs())
# handle logging stuff # handle logging stuff
if options.logging_level > logging.DEBUG: if options.logging_level > logging.DEBUG:
@@ -94,62 +62,9 @@ if options.logging_level > logging.DEBUG:
# that aren't ours. This always runs, unless the main logging level is DEBUG or below. # that aren't ours. This always runs, unless the main logging level is DEBUG or below.
logging.getLogger().addHandler(logging.NullHandler()) logging.getLogger().addHandler(logging.NullHandler())
logger = logging.getLogger("TwitchDrops") logger = logging.getLogger("TwitchDrops")
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter("{levelname}: {message}", style='{'))
logger.addHandler(handler)
logger.setLevel(options.logging_level) logger.setLevel(options.logging_level)
logging.getLogger("TwitchDrops.gql").setLevel(options.debug_gql) logging.getLogger("TwitchDrops.gql").setLevel(options.debug_gql)
logging.getLogger("TwitchDrops.websocket").setLevel(options.debug_ws) logging.getLogger("TwitchDrops.websocket").setLevel(options.debug_ws)
# handle settings # client run
try: client = Twitch(options)
with open(SETTINGS_PATH, 'r', encoding="utf8") as file: client.start()
settings: JsonType = json.load(file)
except json.JSONDecodeError as exc:
print(f"Error while reading the settings file:\n{str(exc)}")
terminate()
except FileNotFoundError:
settings = {}
# asyncio loop
loop = asyncio.get_event_loop()
# client init
client = Twitch(settings.get("username"), settings.get("password"))
# main task and it's close event
main_task = loop.create_task(client.run(settings.get("channels")))
close_event = threading.Event()
def clean_exit(code: int):
if code not in (CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT):
# filter only events we want
return False
# cancel the main task - this triggers the cleanup
main_task.cancel()
# wait until cleanup completes
close_event.wait()
# tell OS that we're free to exit now
return True
# ensures clean exit upon closing the console
win32api.SetConsoleCtrlHandler(clean_exit, True)
try:
loop.run_until_complete(main_task)
except (asyncio.CancelledError, KeyboardInterrupt):
# KeyboardInterrupt causes run_until_complete to exit, but without cancelling the task.
# The loop stops and thus the task gets frozen, until the loop runs again.
# Because we don't want anything from there to actually run during cleanup,
# we need to explicitly cancel the task ourselves here.
main_task.cancel()
# main_task was cancelled due to program shutting down - do the cleanup
loop.run_until_complete(client.close())
# notify we're free to exit
close_event.set()
except Exception:
# Remove the handler so it doesn't delay exit
win32api.SetConsoleCtrlHandler(clean_exit, False)
print("Fatal error encountered:\n")
traceback.print_exc()
terminate()
finally:
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
+431 -267
View File
@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import os import os
import msvcrt
import asyncio import asyncio
import logging import logging
import traceback
from yarl import URL from yarl import URL
from time import time
from itertools import chain
from functools import partial from functools import partial
from typing import Any, Optional, Union, List, Dict, Collection, cast from typing import Any, Callable, Iterable, Optional, Union, List, Dict, Set, cast, TYPE_CHECKING
try: try:
import aiohttp import aiohttp
@@ -14,10 +16,12 @@ except ImportError:
raise ImportError("You have to run 'python -m pip install aiohttp' first") raise ImportError("You have to run 'python -m pip install aiohttp' first")
from channel import Channel from channel import Channel
from websocket import WebsocketPool from gui import GUIManager, LoginData
from inventory import DropsCampaign, Game from websocket import WebsocketPool, task_wrapper
from inventory import DropsCampaign, Game, TimedDrop
from exceptions import LoginException, CaptchaRequired from exceptions import LoginException, CaptchaRequired
from constants import ( from constants import (
State,
JsonType, JsonType,
WebsocketTopic, WebsocketTopic,
CLIENT_ID, CLIENT_ID,
@@ -27,66 +31,119 @@ from constants import (
GQL_URL, GQL_URL,
GQL_OPERATIONS, GQL_OPERATIONS,
DROPS_ENABLED_TAG, DROPS_ENABLED_TAG,
TERMINATED_STR,
MAX_WEBSOCKETS,
WS_TOPICS_LIMIT,
GQLOperation, GQLOperation,
) )
if TYPE_CHECKING:
from main import ParsedArgs
logger = logging.getLogger("TwitchDrops") logger = logging.getLogger("TwitchDrops")
gql_logger = logging.getLogger("TwitchDrops.gql") gql_logger = logging.getLogger("TwitchDrops.gql")
class Twitch: class Twitch:
def __init__( def __init__(self, options: ParsedArgs):
self, self._options = options
username: Optional[str] = None, # GUI
password: Optional[str] = None, self.gui = GUIManager(self)
*,
loop: Optional[asyncio.AbstractEventLoop] = None,
):
self.username: Optional[str] = username
self.password: Optional[str] = password
# Cookies, session and auth # Cookies, session and auth
cookie_jar = aiohttp.CookieJar() cookie_jar = aiohttp.CookieJar()
if os.path.isfile(COOKIES_PATH): if os.path.isfile(COOKIES_PATH):
cookie_jar.load(COOKIES_PATH) cookie_jar.load(COOKIES_PATH)
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
cookie_jar=cookie_jar, headers={"User-Agent": USER_AGENT}, loop=loop cookie_jar=cookie_jar, headers={"User-Agent": USER_AGENT}
) )
self._access_token: Optional[str] = None self._access_token: Optional[str] = None
self._user_id: Optional[int] = None self._user_id: Optional[int] = None
self._is_logged_in = asyncio.Event() self._is_logged_in = asyncio.Event()
# Storing, watching and changing channels # State management
self._state: State = State.INVENTORY_FETCH
self._state_change = asyncio.Event()
self.inventory: List[DropsCampaign] = [] # inventory
# Storing and watching channels
self.channels: Dict[int, Channel] = {} self.channels: Dict[int, Channel] = {}
self._watching_channel: Optional[Channel] = None self._watching_channel: Optional[Channel] = None
self._watching_task: Optional[asyncio.Task[Any]] = None self._watching_task: Optional[asyncio.Task[Any]] = None
self._channel_change = asyncio.Event() self._last_watch = time() - 60
# Inventory
self.inventory: List[DropsCampaign] = []
self._campaign_change = asyncio.Event()
# Websocket # Websocket
self.websocket = WebsocketPool(self) self.websocket = WebsocketPool(self)
# Runner task
self._main_task: Optional[asyncio.Task[None]] = None
def wait_until_login(self): def wait_until_login(self):
return self._is_logged_in.wait() return self._is_logged_in.wait()
def reevaluate_campaigns(self): def change_state(self, state: State) -> None:
self._campaign_change.set() self._state = state
self._state_change.set()
def state_change(self, state: State) -> Callable[[], None]:
# this is identical to change_state, but defers the call
# perfect for GUI usage
return partial(self.change_state, state)
def request_close(self):
"""
Called when the application is requested to close,
usually by the console or application window being closed.
"""
self.stop()
def start(self):
self._loop = loop = asyncio.get_event_loop()
self._main_task = loop.create_task(self._run())
try:
loop.run_until_complete(self._main_task)
except asyncio.CancelledError:
# happens when the user requests close
pass
except KeyboardInterrupt:
# KeyboardInterrupt causes run_until_complete to exit, but without cancelling the task.
# The loop stops and thus the task gets frozen, until the loop runs again.
# Because we don't want anything from there to actually run during cleanup,
# we need to explicitly cancel the task ourselves here.
self.stop()
except CaptchaRequired:
self.gui.prevent_close()
self.gui.print(
"Your login attempt was denied by CAPTCHA.\nPlease try again in +12 hours."
)
except Exception:
self.gui.prevent_close()
self.gui.print("Fatal error encountered:\n")
self.gui.print(traceback.format_exc())
finally:
loop.run_until_complete(self.close())
loop.run_until_complete(loop.shutdown_asyncgens())
if not self.gui.close_requested:
self.gui.print(
"\nApplication Terminated.\nClose the window to exit the application."
)
loop.run_until_complete(self.gui.wait_until_closed())
loop.close()
def stop(self):
if self._main_task is not None:
self._main_task.cancel()
self._main_task = None
async def close(self): async def close(self):
print("Exiting...") start_time = time()
self._session.cookie_jar.save(COOKIES_PATH) # type: ignore self.gui.print("Exiting...")
self.stop_watching() self.stop_watching()
self._session.cookie_jar.save(COOKIES_PATH) # type: ignore
await self._session.close() await self._session.close()
await self.websocket.stop() await self.websocket.stop()
await asyncio.sleep(1) # allows aiohttp to safely close the session # wait at least one full second + whatever it takes to complete the closing
# this allows aiohttp to safely close the session
await asyncio.sleep(start_time + 1 - time())
def is_currently_watching(self, channel: Channel) -> bool: def is_watching(self, channel: Channel) -> bool:
return self._watching_channel is not None and self._watching_channel == channel return self._watching_channel is not None and self._watching_channel == channel
async def run(self, channel_names: Optional[List[str]] = None): async def _run(self):
""" """
Main method that runs the whole client. Main method that runs the whole client.
@@ -95,129 +152,130 @@ class Twitch:
• Selecting a stream to watch, and watching it • Selecting a stream to watch, and watching it
• Changing the stream that's being watched if necessary • Changing the stream that's being watched if necessary
""" """
self.gui.start()
await self.check_login()
# Add default topics
assert self._user_id is not None
self.websocket.add_topics([
WebsocketTopic("User", "Drops", self._user_id, self.process_drops),
WebsocketTopic("User", "CommunityPoints", self._user_id, self.process_points),
])
await self.websocket.start()
games: Set[Game] = set()
selected_game: Optional[Game] = None
self.change_state(State.INVENTORY_FETCH)
while True: while True:
# Claim the drops we can if self._state is State.INVENTORY_FETCH:
self.inventory = await self.get_inventory() # Claim the drops we can
games = set() await self.fetch_inventory()
for campaign in self.inventory: games.clear()
if campaign.status == "UPCOMING": for campaign in self.inventory:
# we have no use in processing upcoming campaigns here if campaign.status == "UPCOMING":
continue # we have no use in processing upcoming campaigns here
for drop in campaign.timed_drops.values():
if drop.can_earn:
games.add(campaign.game)
if drop.can_claim:
await drop.claim()
# 'games' now has all games we want to farm drops for
# if it's empty, there's no point in continuing
if not games:
print(f"No active campaigns to farm drops for.\n\n{TERMINATED_STR}")
await asyncio.Future()
# Start our websocket connection, only after we confirm that there are drops to mine
await self.websocket.start()
if not channel_names:
# get a list of all channels with drops enabled
print("Fetching suitable live channels to watch...")
live_streams: Dict[Game, List[Channel]] = await self.get_live_streams(
games, [DROPS_ENABLED_TAG]
)
for game, channels in live_streams.items():
for channel in channels:
if channel.id not in self.channels:
self.channels[channel.id] = channel
print(f"Added channel: {channel.name} for game: {game.name}")
else:
# Fetch information about all channels we're supposed to handle
for channel_name in channel_names:
channel: Channel = await Channel(self, channel_name) # type: ignore
self.channels[channel.id] = channel
# Sub to these channel updates
topics: List[WebsocketTopic] = [
WebsocketTopic("Channel", "VideoPlayback", channel_id, self.process_stream_state)
for channel_id in self.channels
]
self.websocket.add_topics(topics)
self._campaign_change.clear()
# Repeat: Change into a channel we can watch, then reset the flag
self._channel_change.set()
refresh_channels = False # we're entering having fresh channel data already
while True:
# wait for either the change channel signal, or campaign change signal
await asyncio.wait(
(
self._channel_change.wait(),
self._campaign_change.wait(),
),
return_when=asyncio.FIRST_COMPLETED,
)
if self._campaign_change.is_set():
# we need to reevaluate all campaigns
# stop watching
self.stop_watching()
# close the websocket
await self.websocket.stop()
break # cycle the outer loop
# otherwise, it was the channel change one
for channel in self.channels.values():
if (
channel.stream is not None # steam online
and channel.stream.game is not None # there's game information
and channel.stream.drops_enabled # drops are enabled
and channel.stream.game in games # it's a game we can earn drops in
):
self.watch(channel)
refresh_channels = True
self._channel_change.clear()
break
else:
# there's no available channel to watch
if refresh_channels:
# refresh the status of all channels,
# to make sure that our websocket didn't miss anything til this point
print("No suitable channel to watch, refreshing...")
for channel in self.channels.values():
await channel.get_stream()
await asyncio.sleep(0.5)
refresh_channels = False
continue continue
print("No suitable channel to watch, retrying in 120 seconds") for drop in campaign.timed_drops.values():
await asyncio.sleep(120) if drop.can_earn:
games.add(campaign.game)
if drop.can_claim:
await drop.claim()
self.change_state(State.GAME_SELECT)
elif self._state is State.GAME_SELECT:
# 'games' has all games we want to farm drops for
# if it's empty, there's no point in continuing
if not games:
self.gui.print("No active campaigns to farm drops for.")
return
self.gui.games.set_games(games)
selected_game = self.gui.games.get_selection()
self.change_state(State.CHANNEL_FETCH)
elif self._state is State.CHANNEL_FETCH:
if selected_game is None:
self.change_state(State.GAME_SELECT)
else:
# get a list of all live channels with drops enabled
live_streams: List[Channel] = await self.get_live_streams(
selected_game, [DROPS_ENABLED_TAG]
)
# filter out ones we already have
live_streams = [ch for ch in live_streams if ch.id not in self.channels]
for channel in live_streams:
self.channels[channel.id] = channel
channel.display()
# load points
# asyncio.gather(*(channel.claim_bonus() for channel in live_streams))
# Sub to these channel updates
topics: List[WebsocketTopic] = [
WebsocketTopic(
"Channel", "VideoPlayback", channel_id, self.process_stream_state
)
for channel_id in self.channels
]
self.websocket.add_topics(topics)
self.change_state(State.CHANNEL_SWITCH)
elif self._state is State.CHANNEL_CLEANUP:
# remove all channels that are offline,
# or aren't streaming the game we want anymore
to_remove = [
channel for channel in self.channels.values()
if not (channel.online or channel.pending_online)
or channel.game is None or channel.game != selected_game
]
self.websocket.remove_topics(
WebsocketTopic.as_str("Channel", "VideoPlayback", channel.id)
for channel in to_remove
)
for channel in to_remove:
del self.channels[channel.id]
channel.remove()
self.change_state(State.CHANNEL_FETCH)
elif self._state is State.CHANNEL_SWITCH:
if selected_game is None:
self.change_state(State.GAME_SELECT)
else:
# Change into the selected channel
channels: Iterable[Channel]
selected_channel = self.gui.channels.get_selection()
if selected_channel is not None:
self.gui.channels.clear_selection()
channels = chain([selected_channel], self.channels.values())
else:
channels = self.channels.values()
# If there's no selected channel, change into a channel we can watch
for channel in channels:
if (
channel.online # steam online
and channel.drops_enabled # drops are enabled
and channel.game == selected_game # it's a game we've selected
):
self.watch(channel)
# break the state change chain by clearing the flag
self._state_change.clear()
break
else:
self.stop_watching()
selected_game = self.gui.games.get_next_selection()
if selected_game is None:
self.gui.print("No suitable channel to watch.")
# TODO: Figure out what to do here.
return
self.change_state(State.CHANNEL_CLEANUP)
await self._state_change.wait()
def watch(self, channel: Channel): def watch(self, channel: Channel):
if self.is_watching(channel):
# we're already watching the same channel, so there's no point switching
return
if self._watching_task is not None: if self._watching_task is not None:
self._watching_task.cancel() self._watching_task.cancel()
self.gui.channels.set_watching(channel)
async def watcher(channel: Channel):
op = GQL_OPERATIONS["ChannelPointsContext"].with_variables(
{"channelLogin": channel.name}
)
i = 0
while True:
await channel._send_watch()
if i == 0:
# ensure every 30 minutes that we don't have unclaimed points bonus
response = await self.gql_request(op)
channel_data: JsonType = response["data"]["community"]["channel"]
claim_available: JsonType = (
channel_data["self"]["communityPoints"]["availableClaim"]
)
if claim_available:
await self.claim_points(channel_data["id"], claim_available["id"])
logger.info("Claimed bonus points")
i = (i + 1) % 30
await asyncio.sleep(58.5)
if channel.stream is not None and channel.stream.game is not None:
game_name = channel.stream.game.name
else:
game_name = "<Unknown>"
print(f"Watching: {channel.name}, game: {game_name}")
self._watching_channel = channel self._watching_channel = channel
self._watching_task = asyncio.create_task(watcher(channel)) self._watching_task = asyncio.create_task(channel.watch_loop())
self.gui.progress.start_timer()
def stop_watching(self): def stop_watching(self):
self.gui.progress.stop_timer()
self.gui.channels.clear_watching()
if self._watching_task is not None: if self._watching_task is not None:
logger.info("Watching stopped.")
self._watching_task.cancel() self._watching_task.cancel()
self._watching_task = None self._watching_task = None
self._watching_channel = None self._watching_channel = None
@@ -231,10 +289,10 @@ class Twitch:
if msg_type == "stream-down": if msg_type == "stream-down":
logger.info(f"{channel.name} goes OFFLINE") logger.info(f"{channel.name} goes OFFLINE")
channel.set_offline() channel.set_offline()
if self.is_currently_watching(channel): if self.is_watching(channel):
print(f"{channel.name} goes OFFLINE, switching...") self.gui.print(f"{channel.name} goes OFFLINE, switching...")
# change the channel if we're currently watching it # change the channel if we're currently watching it
self._channel_change.set() self.change_state(State.CHANNEL_SWITCH)
elif msg_type == "stream-up": elif msg_type == "stream-up":
logger.info(f"{channel.name} goes ONLINE") logger.info(f"{channel.name} goes ONLINE")
channel.set_online() channel.set_online()
@@ -243,17 +301,140 @@ class Twitch:
# if it's not online for some reason, set it so # if it's not online for some reason, set it so
channel.set_online() channel.set_online()
else: else:
assert channel.stream is not None
viewers = message["viewers"] viewers = message["viewers"]
channel.stream.viewer_count = viewers channel.viewers = viewers
logger.debug(f"{channel.name} viewers: {viewers}") logger.debug(f"{channel.name} viewers: {viewers}")
@task_wrapper
async def process_drops(self, user_id: int, message: JsonType):
# Message examples:
# {"type": "drop-progress", data: {"current_progress_min": 3, "required_progress_min": 10}}
# {"type": "drop-claim", data: {"drop_instance_id": ...}}
msg_type: str = message["type"]
if msg_type not in ("drop-progress", "drop-claim"):
return
drop_id: str = message["data"]["drop_id"]
drop: Optional[TimedDrop] = self.get_drop(drop_id)
if msg_type == "drop-claim" and drop is None:
logger.error(
f"Received a drop claim ID for a non-existing drop: {drop_id}\n"
f"Drop claim ID: {message['data']['drop_instance_id']}"
)
return
# Sometimes, the drop update we receive doesn't actually match what we're mining.
# This is a Twitch bug workaround: use GQL to get the current drop progress.
if msg_type == "drop-progress" and (drop is None or not drop.campaign.active):
logger.debug(
"Received a drop update for an inactive campaign, using drop context instead"
)
context = await self.gql_request(GQL_OPERATIONS["CurrentDrop"])
drop_data = context["data"]["currentUser"]["dropCurrentSession"]
drop_id = drop_data["dropID"]
drop = self.get_drop(drop_id)
if drop is None:
logger.warning(f"Received an update for a non-existing drop: {drop_id}")
return
if not drop.campaign.active:
# Sometimes, even GQL fails to give us the correct drop.
# In that case, we can use the locally cached inventory to try and put together
# the drop that we're actually mining right now.
# TODO: Find a better way of figuring out the correct game
game = drop.campaign.game
drop = None
for campaign in self.inventory:
if campaign.game == game:
drop = campaign.get_active_drop()
break
if drop is None or not drop.campaign.active:
logger.error("Active drop search failed")
return
drop.bump_minutes()
self.gui.progress.update(drop)
return
else:
# TODO: Use a cleaner solution than modifying the raw payload
message["data"]["current_progress_min"] = drop_data["currentMinutesWatched"]
message["data"]["required_progress_min"] = drop_data["requiredMinutesWatched"]
assert drop is not None
drop.update(message)
if msg_type == "drop-claim":
campaign = drop.campaign
await drop.claim()
self.gui.print(
f"Claimed drop: {drop.rewards_text()} "
f"({campaign.claimed_drops}/{campaign.total_drops})"
)
if campaign.remaining_drops == 0:
self.change_state(State.INVENTORY_FETCH)
self.gui.progress.update(drop)
@task_wrapper
async def process_points(self, user_id: int, message: JsonType):
# Example payloads:
# {
# "type": "points-earned",
# "data": {
# "timestamp": "YYYY-MM-DDTHH:MM:SS.123456789Z",
# "channel_id": "123456789",
# "point_gain": {
# "user_id": "12345678",
# "channel_id": "123456789",
# "total_points": 10,
# "baseline_points": 10,
# "reason_code": "WATCH",
# "multipliers": []
# },
# "balance": {
# "user_id": "12345678",
# "channel_id": "123456789",
# "balance": 12345
# }
# }
# }
# {
# "type": "claim-available",
# "data": {
# "timestamp":"2021-12-23T21:41:35.784041064Z",
# "claim": {
# "id": "4ae6fefd-3658-40ae-ad3d-92254c576a91",
# "user_id": "94275183",
# "channel_id": "218893986",
# "point_gain": {
# "user_id": "94275183",
# "channel_id": "218893986",
# "total_points": 50,
# "baseline_points": 50,
# "reason_code": "CLAIM",
# "multipliers": []
# },
# "created_at": "2021-12-23T21:41:31Z"
# }
# }
# }
msg_type = message["type"]
if msg_type == "points-earned":
data = message["data"]
channel = self.channels.get(int(data["channel_id"]))
points = data["point_gain"]["total_points"]
balance = data["balance"]["balance"]
if channel is not None:
channel.points = balance
self.gui.channels.display(channel)
self.gui.print(f"Earned points for watching: {points:3}, total: {balance}")
elif msg_type == "claim-available":
claim_data = message["data"]["claim"]
points = claim_data["point_gain"]["total_points"]
await self.claim_points(claim_data["channel_id"], claim_data["id"])
self.gui.print(f"Claimed bonus points: {points}")
async def _validate_password(self, password: str) -> bool: async def _validate_password(self, password: str) -> bool:
""" """
Use Twitch's password validator to validate the password length, characters required, etc. Use Twitch's password validator to validate the password length, characters required, etc.
Helps avoid running into the CAPTCHA if you mistype your password by mistake. Helps avoid running into the CAPTCHA if you mistype your password by mistake.
Valid length: 8-71 Valid length: 8-71
""" """
if not 8 <= len(password) <= 71:
return False
payload = {"password": password} payload = {"password": password}
async with self._session.post( async with self._session.post(
f"{AUTH_URL}/api/v1/password_strength", json=payload f"{AUTH_URL}/api/v1/password_strength", json=payload
@@ -261,120 +442,97 @@ class Twitch:
strength_response = await response.json() strength_response = await response.json()
return strength_response["isValid"] return strength_response["isValid"]
async def get_password(self, prompt: str = "Password: ") -> str: async def ask_login(self) -> LoginData:
"""
A loop that'll keep asking for password, until it's considered valid.
Use own implementation rather than `getpass.getpass`, to add some user feedback
on how many characters have been typed in.
"""
while True: while True:
for c in prompt: data = await self.gui.login.ask_login()
msvcrt.putwch(c) if await self._validate_password(data.password):
pass_chars: List[str] = [] return data
try:
while True:
c = msvcrt.getwch()
if c in "\r\n":
break
elif c == '\003':
raise KeyboardInterrupt
elif c == '\b':
# backspace
if not pass_chars:
# we have nothing to remove
continue
pass_chars.pop()
# move one character back
msvcrt.putwch('\b')
# overwrite the • with a space
msvcrt.putwch(' ')
# move back again
msvcrt.putwch('\b')
else:
pass_chars.append(c)
msvcrt.putwch('•')
finally:
msvcrt.putwch('\r')
msvcrt.putwch('\n')
password = ''.join(pass_chars)
if await self._validate_password(password):
return password
async def _login(self) -> str: async def _login(self) -> str:
logger.debug("Login flow started") logger.debug("Login flow started")
if self.username is None:
self.username = input("Username: ")
if self.password is None:
print("\nNote: Password can be pasted in by pressing right click inside the window.\n")
self.password = await self.get_password()
payload: JsonType = { payload: JsonType = {
"username": self.username,
"password": self.password,
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"undelete_user": False, "undelete_user": False,
"remember_me": True, "remember_me": True,
} }
for attempt in range(10): while True:
async with self._session.post(f"{AUTH_URL}/login", json=payload) as response: username, password, token = await self.ask_login()
login_response = await response.json() payload["username"] = username
payload["password"] = password
# remove stale 2FA tokens, if present
payload.pop("authy_token", None)
payload.pop("twitchguard_code", None)
for attempt in range(2):
async with self._session.post(f"{AUTH_URL}/login", json=payload) as response:
login_response: JsonType = await response.json()
# Feed this back in to avoid running into CAPTCHA if possible # Feed this back in to avoid running into CAPTCHA if possible
if "captcha_proof" in login_response: if "captcha_proof" in login_response:
payload["captcha"] = {"proof": login_response["captcha_proof"]} payload["captcha"] = {"proof": login_response["captcha_proof"]}
# Error handling # Error handling
if "error_code" in login_response: if "error_code" in login_response:
error_code = login_response["error_code"] error_code: int = login_response["error_code"]
logger.debug(f"Login error code: {error_code}") logger.debug(f"Login error code: {error_code}")
if error_code == 1000: if error_code == 1000:
# we've failed bois # we've failed bois
logger.debug("Login failed due to CAPTCHA") logger.debug("Login failed due to CAPTCHA")
raise CaptchaRequired() raise CaptchaRequired()
elif error_code == 3001: elif error_code == 3001:
# wrong password you dummy # wrong password you dummy
logger.debug("Login failed due to incorrect login or pass") logger.debug("Login failed due to incorrect login or pass")
print(f"Incorrect username or password.\nUsername: {self.username}") self.gui.print("Incorrect username or password.")
self.password = await self.get_password() self.gui.login.clear(password=True)
elif error_code in ( break
3011, # Authy token needed elif error_code in (
3012, # Invalid authy token 3012, # Invalid authy token
3022, # Email code needed 3023, # Invalid email code
3023, # Invalid email code ):
): logger.debug("Login failed due to incorrect 2FA code")
# 2FA handling if error_code == 3023:
email = error_code in (3022, 3023) self.gui.print("Incorrect email code.")
logger.debug("2FA token required") else:
token = input("2FA token: ") self.gui.print("Incorrect 2FA code.")
if email: self.gui.login.clear(token=True)
# email code break
payload["twitchguard_code"] = token elif error_code in (
3011, # Authy token needed
3022, # Email code needed
):
# 2FA handling
email = error_code == 3022
if not token:
logger.debug("2FA token required")
# user didn't provide a token, so ask them for it
if email:
self.gui.print("Email code required. Check your email.")
else:
self.gui.print("2FA token required.")
break
if email:
payload["twitchguard_code"] = token
else:
payload["authy_token"] = token
continue
else: else:
# authy token raise LoginException(login_response["error"])
payload["authy_token"] = token # Success handling
continue if "access_token" in login_response:
else: # we're in bois
raise LoginException(login_response["error"]) self._access_token = cast(str, login_response["access_token"])
logger.debug("Access token granted")
# Success handling self.gui.login.clear()
if "access_token" in login_response: return self._access_token
# we're in bois
self._access_token = login_response["access_token"]
logger.debug(f"Access token: {self._access_token}")
break
if self._access_token is None:
# this means we've ran out of retries
raise LoginException("Ran out of login retries")
return self._access_token
async def check_login(self) -> None: async def check_login(self) -> None:
if self._access_token is not None and self._user_id is not None: if self._access_token is not None and self._user_id is not None:
# we're all good # we're all good
return return
# looks like we're missing something # looks like we're missing something
print("Logging in") logger.debug("Checking login")
self.gui.login.update("Logging in...", None)
jar = cast(aiohttp.CookieJar, self._session.cookie_jar) jar = cast(aiohttp.CookieJar, self._session.cookie_jar)
while True: while True:
cookie = jar.filter_cookies("https://twitch.tv") # type: ignore cookie = jar.filter_cookies("https://twitch.tv") # type: ignore
@@ -386,7 +544,7 @@ class Twitch:
elif self._access_token is None: elif self._access_token is None:
# have cookie - get our access token # have cookie - get our access token
self._access_token = cookie["auth-token"].value self._access_token = cookie["auth-token"].value
logger.debug("Session restored from cookie") logger.debug("Restoring session from cookie")
# validate our access token, by obtaining user_id # validate our access token, by obtaining user_id
async with self._session.get( async with self._session.get(
"https://id.twitch.tv/oauth2/validate", "https://id.twitch.tv/oauth2/validate",
@@ -395,6 +553,7 @@ class Twitch:
status = response.status status = response.status
if status == 401: if status == 401:
# the access token we have is invalid - clear the cookie and reauth # the access token we have is invalid - clear the cookie and reauth
logger.debug("Restored session is invalid")
jar.clear_domain("twitch.tv") jar.clear_domain("twitch.tv")
continue continue
elif status == 200: elif status == 200:
@@ -403,7 +562,8 @@ class Twitch:
self._user_id = int(validate_response["user_id"]) self._user_id = int(validate_response["user_id"])
cookie["persistent"] = str(self._user_id) cookie["persistent"] = str(self._user_id)
self._is_logged_in.set() self._is_logged_in.set()
print(f"Login successful, User ID: {self._user_id}") logger.debug(f"Login successful, user ID: {self._user_id}")
self.gui.login.update("Logged in", self._user_id)
# update our cookie and save it # update our cookie and save it
jar.update_cookies(cookie, URL("https://twitch.tv")) jar.update_cookies(cookie, URL("https://twitch.tv"))
jar.save(COOKIES_PATH) jar.save(COOKIES_PATH)
@@ -420,34 +580,38 @@ class Twitch:
gql_logger.debug(f"GQL Response: {response_json}") gql_logger.debug(f"GQL Response: {response_json}")
return response_json return response_json
async def get_inventory(self) -> List[DropsCampaign]: async def fetch_inventory(self) -> None:
response = await self.gql_request(GQL_OPERATIONS["Inventory"]) response = await self.gql_request(GQL_OPERATIONS["Inventory"])
inventory = response["data"]["currentUser"]["inventory"] inventory = response["data"]["currentUser"]["inventory"]
return [DropsCampaign(self, data) for data in inventory["dropCampaignsInProgress"]] self.inventory = [
DropsCampaign(self, data) for data in inventory["dropCampaignsInProgress"]
]
async def get_live_streams( def get_drop(self, drop_id: str) -> Optional[TimedDrop]:
self, games: Collection[Game], tag_ids: List[str] for campaign in self.inventory:
) -> Dict[Game, List[Channel]]: drop = campaign.get_drop(drop_id)
limit = min(int((MAX_WEBSOCKETS * WS_TOPICS_LIMIT) // len(games)), 100) if drop is not None:
live_streams = {} return drop
for game in games: return None
response = await self.gql_request(
GQL_OPERATIONS["GameDirectory"].with_variables({
"limit": limit,
"name": game.name,
"options": {
"includeRestricted": ["SUB_ONLY_LIVE"],
"tags": tag_ids,
},
})
)
live_streams[game] = [
Channel.from_directory(self, stream_channel_data["node"])
for stream_channel_data in response["data"]["game"]["streams"]["edges"]
]
return live_streams
async def claim_points(self, channel_id: Union[str, int], claim_id: str): async def get_live_streams(self, game: Game, tag_ids: List[str]) -> List[Channel]:
limit = 45
response = await self.gql_request(
GQL_OPERATIONS["GameDirectory"].with_variables({
"limit": limit,
"name": game.name,
"options": {
"includeRestricted": ["SUB_ONLY_LIVE"],
"tags": tag_ids,
},
})
)
return [
Channel.from_directory(self, stream_channel_data["node"])
for stream_channel_data in response["data"]["game"]["streams"]["edges"]
]
async def claim_points(self, channel_id: Union[str, int], claim_id: str) -> None:
variables = {"input": {"channelID": str(channel_id), "claimID": claim_id}} variables = {"input": {"channelID": str(channel_id), "claimID": claim_id}}
await self.gql_request( await self.gql_request(
GQL_OPERATIONS["ClaimCommunityPoints"].with_variables(variables) GQL_OPERATIONS["ClaimCommunityPoints"].with_variables(variables)
+1
View File
@@ -0,0 +1 @@
__version__ = 4
+51 -67
View File
@@ -13,7 +13,6 @@ from typing import Any, Optional, List, Dict, Set, Iterable, TYPE_CHECKING
from websockets.exceptions import ConnectionClosed, ConnectionClosedOK from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
from websockets.client import WebSocketClientProtocol, connect as websocket_connect from websockets.client import WebSocketClientProtocol, connect as websocket_connect
from inventory import TimedDrop
from exceptions import MinerException from exceptions import MinerException
from constants import ( from constants import (
JsonType, JsonType,
@@ -29,6 +28,7 @@ if TYPE_CHECKING:
from twitch import Twitch from twitch import Twitch
logger = logging.getLogger("TwitchDrops")
ws_logger = logging.getLogger("TwitchDrops.websocket") ws_logger = logging.getLogger("TwitchDrops.websocket")
NONCE_CHARS = string.ascii_letters + string.digits NONCE_CHARS = string.ascii_letters + string.digits
@@ -39,11 +39,11 @@ def create_nonce(length: int = 30) -> str:
def task_wrapper(afunc): def task_wrapper(afunc):
@wraps(afunc) @wraps(afunc)
async def wrapper(self: Websocket, *args, **kwargs): async def wrapper(self, *args, **kwargs):
try: try:
await afunc(self, *args, **kwargs) await afunc(self, *args, **kwargs)
except Exception: except Exception:
ws_logger.exception("Exception in websocket task") logger.exception("Exception in task")
raise # raise up to the wrapping task raise # raise up to the wrapping task
return wrapper return wrapper
@@ -55,7 +55,7 @@ class Websocket:
# websocket index # websocket index
self._idx: int = index self._idx: int = index
# current websocket connection # current websocket connection
self._ws: WebSocketClientProtocol self._ws: Optional[WebSocketClientProtocol] = None
# set when there's an active websocket connection # set when there's an active websocket connection
self._connected_flag = asyncio.Event() self._connected_flag = asyncio.Event()
# set when the websocket needs to reconnect # set when the websocket needs to reconnect
@@ -70,6 +70,8 @@ class Websocket:
# topics stuff # topics stuff
self.topics: Dict[str, WebsocketTopic] = {} self.topics: Dict[str, WebsocketTopic] = {}
self._submitted: Set[WebsocketTopic] = set() self._submitted: Set[WebsocketTopic] = set()
# notify GUI
self.set_status("Disconnected")
@property @property
def connected(self) -> bool: def connected(self) -> bool:
@@ -78,12 +80,25 @@ class Websocket:
def wait_until_connected(self): def wait_until_connected(self):
return self._connected_flag.wait() return self._connected_flag.wait()
def set_status(self, status: Optional[str] = None, refresh_topics: bool = False):
kwargs: Dict[str, Any] = {}
if status is not None:
kwargs["status"] = status
if refresh_topics:
kwargs["topics"] = len(self.topics)
self._twitch.gui.websockets.update(self._idx, **kwargs)
def request_reconnect(self): def request_reconnect(self):
ws_logger.warning(f"Websocket[{self._idx}] requested reconnect.") ws_logger.warning(f"Websocket[{self._idx}] requested reconnect.")
# reset our ping interval, so we send a PING after reconnect right away # reset our ping interval, so we send a PING after reconnect right away
self._next_ping = time() self._next_ping = time()
self._reconnect_requested.set() self._reconnect_requested.set()
async def close(self):
self.set_status("Disconnecting...")
if self._ws is not None:
await self._ws.close()
async def start(self): async def start(self):
if self.connected: if self.connected:
return return
@@ -98,27 +113,35 @@ class Websocket:
self._handle_task = asyncio.create_task(self._handle()) self._handle_task = asyncio.create_task(self._handle())
async def stop(self): async def stop(self):
if self._ws is not None: await self.close()
await self._ws.close()
if self._handle_task is not None: if self._handle_task is not None:
# this raises back any stray exceptions
await self._handle_task await self._handle_task
self._handle_task = None self._handle_task = None
def stop_nowait(self): def stop_nowait(self):
if self._ws is not None: asyncio.create_task(self.close())
asyncio.create_task(self._ws.close())
# note: this detaches the handle task, so we have to assume it closes properly # note: this detaches the handle task, so we have to assume it closes properly
self._handle_task = None self._handle_task = None
def remove(self):
# this stops the websocket, and then removes it from the gui list
async def remover():
await self.stop()
self._twitch.gui.websockets.remove(self._idx)
asyncio.create_task(remover())
@task_wrapper @task_wrapper
async def _handle(self): async def _handle(self):
# ensure we're logged in before connecting # ensure we're logged in before connecting
await self._twitch.wait_until_login() await self._twitch.wait_until_login()
self.set_status("Connecting...")
ws_logger.info(f"Websocket[{self._idx}] connecting...") ws_logger.info(f"Websocket[{self._idx}] connecting...")
# Connect/Reconnect loop # Connect/Reconnect loop
async for websocket in websocket_connect(WEBSOCKET_URL, ssl=True, ping_interval=None): async for websocket in websocket_connect(WEBSOCKET_URL, ssl=True, ping_interval=None):
websocket.BACKOFF_MAX = 3 * 60 # type: ignore # 3 minutes websocket.BACKOFF_MAX = 3 * 60 # type: ignore # 3 minutes
self._ws = websocket self._ws = websocket
self.set_status("Connected")
try: try:
try: try:
self._reconnect_requested.clear() self._reconnect_requested.clear()
@@ -135,20 +158,24 @@ class Websocket:
if isinstance(exc, ConnectionClosedOK): if isinstance(exc, ConnectionClosedOK):
if exc.rcvd_then_sent: if exc.rcvd_then_sent:
# server closed the connection, not us - reconnect # server closed the connection, not us - reconnect
ws_logger.warning(f"Websocket[{self._idx}] disconnected.") ws_logger.warning(f"Websocket[{self._idx}] got disconnected.")
else: else:
# we closed it - exit # we closed it - exit
self._ws = None
ws_logger.info(f"Websocket[{self._idx}] stopped.") ws_logger.info(f"Websocket[{self._idx}] stopped.")
self.set_status("Disconnected")
return return
if exc.rcvd is not None:
code = exc.rcvd.code
elif exc.sent is not None:
code = exc.sent.code
else: else:
code = -1 if exc.rcvd is not None:
ws_logger.warning(f"Websocket[{self._idx}] closed unexpectedly: {code}") code = exc.rcvd.code
elif exc.sent is not None:
code = exc.sent.code
else:
code = -1
ws_logger.warning(f"Websocket[{self._idx}] closed unexpectedly: {code}")
except Exception: except Exception:
ws_logger.exception(f"Exception in Websocket[{self._idx}]") ws_logger.exception(f"Exception in Websocket[{self._idx}]")
self.set_status("Reconnecting...")
ws_logger.warning(f"Websocket[{self._idx}] reconnecting...") ws_logger.warning(f"Websocket[{self._idx}] reconnecting...")
async def _handle_ping(self): async def _handle_ping(self):
@@ -203,6 +230,7 @@ class Websocket:
Gather incoming messages over the timeout specified. Gather incoming messages over the timeout specified.
Note that there's no return value - this modifies `messages` in-place. Note that there's no return value - this modifies `messages` in-place.
""" """
assert self._ws is not None
while True: while True:
raw_message = await self._ws.recv() raw_message = await self._ws.recv()
message = json.loads(raw_message) message = json.loads(raw_message)
@@ -211,10 +239,10 @@ class Websocket:
def _handle_message(self, message): def _handle_message(self, message):
# request the assigned topic to process the response # request the assigned topic to process the response
topic_process = self.topics.get(message["data"]["topic"]) topic = self.topics.get(message["data"]["topic"])
if topic_process is not None: if topic is not None:
# use a task to not block the websocket # use a task to not block the websocket
asyncio.create_task(topic_process(json.loads(message["data"]["message"]))) asyncio.create_task(topic(json.loads(message["data"]["message"])))
async def _handle_recv(self): async def _handle_recv(self):
""" """
@@ -246,6 +274,7 @@ class Websocket:
topic = topics_set.pop() topic = topics_set.pop()
self.topics[str(topic)] = topic self.topics[str(topic)] = topic
self._topics_changed.set() self._topics_changed.set()
self.set_status(refresh_topics=True)
def remove_topics(self, topics_set: Set[str]): def remove_topics(self, topics_set: Set[str]):
existing = topics_set.intersection(self.topics.keys()) existing = topics_set.intersection(self.topics.keys())
@@ -256,6 +285,7 @@ class Websocket:
for topic in existing: for topic in existing:
del self.topics[topic] del self.topics[topic]
self._topics_changed.set() self._topics_changed.set()
self.set_status(refresh_topics=True)
async def send(self, message: JsonType): async def send(self, message: JsonType):
if self._ws is None: if self._ws is None:
@@ -283,17 +313,12 @@ class WebsocketPool:
await self._twitch.wait_until_login() await self._twitch.wait_until_login()
if self.running: if self.running:
return return
# Add default topics
assert self._twitch._user_id is not None
user_id = self._twitch._user_id
self.add_topics([
WebsocketTopic("User", "Drops", user_id, self.process_drops),
WebsocketTopic("User", "CommunityPoints", user_id, self.process_points),
])
self._running.set() self._running.set()
await asyncio.gather(*(ws.start() for ws in self.websockets)) await asyncio.gather(*(ws.start() for ws in self.websockets))
async def stop(self): async def stop(self):
if not self.running:
return
self._running.clear() self._running.clear()
await asyncio.gather(*(ws.stop() for ws in self.websockets)) await asyncio.gather(*(ws.stop() for ws in self.websockets))
@@ -340,49 +365,8 @@ class WebsocketPool:
if count <= (len(self.websockets) - 1) * WS_TOPICS_LIMIT: if count <= (len(self.websockets) - 1) * WS_TOPICS_LIMIT:
ws = self.websockets.pop() ws = self.websockets.pop()
recycled_topics.extend(ws.topics.values()) recycled_topics.extend(ws.topics.values())
ws.stop_nowait() ws.remove()
else: else:
break break
if recycled_topics: if recycled_topics:
self.add_topics(recycled_topics) self.add_topics(recycled_topics)
@task_wrapper
async def process_drops(self, user_id: int, message: JsonType):
drop_id = message["data"]["drop_id"]
drop: Optional[TimedDrop] = None
for campaign in self._twitch.inventory:
drop = campaign.get_drop(drop_id)
if drop is not None:
break
else:
ws_logger.warning(f"Drop with ID of {drop_id} not found!")
return
drop.update(message)
msg_type = message["type"]
campaign = drop.campaign
if msg_type == "drop-progress":
print(
f"Drop: {drop.rewards_text()} ({campaign.claimed_drops}/{campaign.total_drops}): "
f"{drop.progress:6.1%} ({drop.remaining_minutes} minutes remaining)"
)
elif msg_type == "drop-claim":
await drop.claim()
print(
f"Claimed drop: {drop.rewards_text()} "
f"({campaign.claimed_drops}/{campaign.total_drops})"
)
if campaign.remaining_drops == 0:
self._twitch.reevaluate_campaigns()
@task_wrapper
async def process_points(self, user_id: int, message: JsonType):
msg_type = message["type"]
if msg_type == "points-earned":
points = message["data"]["point_gain"]["total_points"]
balance = message["data"]["balance"]["balance"]
print(f"Earned points for watching: {points:3}, total: {balance}")
elif msg_type == "claim-available":
claim_data = message["data"]["claim"]
points = claim_data["point_gain"]["total_points"]
await self._twitch.claim_points(claim_data["channel_id"], claim_data["id"])
print(f"Claimed bonus points: {points}")