Remove Game Selector;

Implement multi-game mining based on the priority list
This commit is contained in:
DevilXD committed 2022-03-30 14:15:26 +02:00
1 parent b94bc1e02e
commit 5af4ee79c3
9 files changed
+164 -261

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ __pycache__
/*.tmp /*.tmp
/Twitch Drops Miner /Twitch Drops Miner
# Dev files # Dev files
/env
/cache /cache
cookies.jar cookies.jar
settings.json settings.json
+3 -3
View File
@@ -1,5 +1,5 @@
@echo off @echo off
cls cls
pyinstaller --onefile --noconsole --name "Twitch Drops Miner (by DevilXD)" --icon pickaxe.ico ^ %CD%/env/scripts/pyinstaller --onefile --noconsole --name "Twitch Drops Miner (by DevilXD)" ^
--add-data pickaxe.ico;. main.py --icon pickaxe.ico --add-data pickaxe.ico;. main.py
call pack.bat if %ERRORLEVEL% == 0 call pack.bat
+3 -3
View File
@@ -132,7 +132,7 @@ class Channel:
return NotImplemented return NotImplemented
def __hash__(self) -> int: def __hash__(self) -> int:
return hash((self.__class__.__name__, self.id)) return self.id
@property @property
def name(self) -> str: def name(self) -> str:
@@ -247,8 +247,8 @@ class Channel:
return Stream.from_get_stream(self, stream_data) return Stream.from_get_stream(self, stream_data)
async def check_online(self) -> bool: async def check_online(self) -> bool:
self._stream = stream = await self.get_stream() self._stream = await self.get_stream()
if stream is None: if self._stream is None:
invalidate_cache(self, "_payload") invalidate_cache(self, "_payload")
return False return False
return True return True
-1
View File
@@ -59,7 +59,6 @@ class State(Enum):
IDLE = auto() IDLE = auto()
INVENTORY_FETCH = auto() INVENTORY_FETCH = auto()
GAMES_UPDATE = auto() GAMES_UPDATE = auto()
GAME_SELECT = auto()
CHANNELS_FETCH = auto() CHANNELS_FETCH = auto()
CHANNELS_CLEANUP = auto() CHANNELS_CLEANUP = auto()
CHANNEL_SWITCH = auto() CHANNEL_SWITCH = auto()
+29 -137
View File
@@ -6,7 +6,7 @@ import tkinter as tk
from math import log10, ceil from math import log10, ceil
from functools import partial from functools import partial
from tkinter.font import Font from tkinter.font import Font
from collections import abc, namedtuple, OrderedDict from collections import abc, namedtuple
from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar
from typing import Any, TypedDict, NoReturn, TYPE_CHECKING from typing import Any, TypedDict, NoReturn, TYPE_CHECKING
@@ -341,121 +341,6 @@ class LoginForm:
self._var.set(f"{status}\n{user_str}") self._var.set(f"{status}\n{user_str}")
class GameSelector:
def __init__(self, manager: GUIManager, master: ttk.Widget):
self._manager = manager
self._settings = manager._twitch.settings
self._var = StringVar(master)
frame = ttk.LabelFrame(master, text="Game Selector", padding=(4, 0, 4, 4))
frame.grid(column=1, row=1, sticky="nsew", padx=2)
frame.rowconfigure(0, weight=1)
frame.columnconfigure(0, weight=1)
self._list = PaddedListbox(
frame,
height=5,
padding=(1, 0),
activestyle="none",
selectmode="single",
highlightthickness=0,
exportselection=False,
)
self._list.grid(column=0, row=0, sticky="nsew")
self._selection: str | None = None
self._games: OrderedDict[str, tuple[int, Game]] = OrderedDict()
self._excluded_indices: set[int] = set()
self._list.bind("<<ListboxSelect>>", self._on_select)
def set_games(self, games: abc.Iterable[Game]):
self._games.clear()
self._games.update((str(game), (i, game)) for i, game in enumerate(games))
self._list.delete(0, "end")
self._list.insert("end", *self._games.keys())
self._list.config(width=0) # autoadjust listbox width
# gray-out excluded games and relink the selection
self._excluded_indices.clear()
self._exclude_sync()
selected_index: int | None = None
if self._selection is not None and self._selection in self._games:
selected_index = self._games[self._selection][0]
self._list.selection_clear(0, "end")
if selected_index is not None:
# reselect the currently selected item
self._list.selection_set(selected_index)
elif self._selection is not None:
# the game we've had selected isn't there anymore - clear selection
self._selection = None
def _exclude_update(self, game_name: str, *, index: int | None = None) -> None:
# called from the settings tab or _update below
if index is None:
try:
index = self._games[game_name][0]
except KeyError:
# user added an excluded game that isn't on the list - just return
return
if (
game_name in self._settings.exclude
or self._settings.priority_only
and game_name not in self._settings.priority
):
if index not in self._excluded_indices:
self._excluded_indices.add(index)
self._list.itemconfig(index, foreground="gray60")
elif index in self._excluded_indices:
self._excluded_indices.discard(index)
self._list.itemconfig(index, foreground='')
def _exclude_sync(self) -> None:
# called when priority_only state changes
for game_name, (i, _) in self._games.items():
self._exclude_update(game_name, index=i)
def _on_select(self, event: tk.Event[PaddedListbox]) -> None:
current: tuple[int, ...] = self._list.curselection()
if not current:
# can happen when the user clicks on an empty list
return
idx: int = current[0]
if idx in self._excluded_indices:
# user clicked on an excluded game - reselect the previous one if possible
self._list.selection_clear(0, "end")
if self._selection is not None:
for i, game_name in enumerate(self._list.get(0, "end")):
if game_name == self._selection:
self._list.selection_set(i)
break
else:
self._selection = None
return
new_selection: str = self._list.get(idx)
if new_selection != self._selection:
self._selection = new_selection
self._manager._twitch.change_state(State.GAME_SELECT)
def get_next(self) -> Game | None:
if self._selection is not None:
return self._games[self._selection][1]
# select and return the first prioritized game from the list
self._list.selection_clear(0, "end")
for game_name in self._settings.priority:
if game_name in self._games:
# it's a prioritized game - easy choice
idx, game = self._games[game_name]
self._selection = game_name
self._list.selection_set(idx)
return game
# if priority_only is enabled, we end here
if self._settings.priority_only:
return None
# with priorities out of the way, select and return the first non-excluded game instead
for i, game_name in enumerate(self._list.get(0, "end")):
if i not in self._excluded_indices:
self._selection = game_name
self._list.selection_set(i)
return self._games[game_name][1]
return None
class _BaseVars(TypedDict): class _BaseVars(TypedDict):
progress: DoubleVar progress: DoubleVar
percentage: StringVar percentage: StringVar
@@ -476,28 +361,28 @@ class _ProgressVars(TypedDict):
class CampaignProgress: class CampaignProgress:
BAR_LENGTH = 240 BAR_LENGTH = 420
def __init__(self, manager: GUIManager, master: ttk.Widget): def __init__(self, manager: GUIManager, master: ttk.Widget):
self._manager = manager self._manager = manager
self._vars: _ProgressVars = { self._vars: _ProgressVars = {
"campaign": { "campaign": {
"name": StringVar(master), # campaign name "name": StringVar(master, "..."), # campaign name
"progress": DoubleVar(master), # controls the progress bar "progress": DoubleVar(master), # controls the progress bar
"percentage": StringVar(master), # percentage display string "percentage": StringVar(master, "-%"), # percentage display string
"remaining": StringVar(master), # time remaining string "remaining": StringVar(master), # time remaining string, filled via _update_time
}, },
"drop": { "drop": {
"rewards": StringVar(master), # drop rewards "rewards": StringVar(master, "..."), # drop rewards
"progress": DoubleVar(master), # as above "progress": DoubleVar(master), # as above
"percentage": StringVar(master), # as above "percentage": StringVar(master, "-%"), # as above
"remaining": StringVar(master), # as above "remaining": StringVar(master), # as above
}, },
} }
self._frame = frame = ttk.LabelFrame( self._frame = frame = ttk.LabelFrame(
master, text="Campaign Progress", padding=(4, 0, 4, 4) master, text="Campaign Progress", padding=(4, 0, 4, 4)
) )
frame.grid(column=0, row=1, sticky="nsew", padx=2) frame.grid(column=0, row=1, columnspan=2, sticky="nsew", padx=2)
frame.columnconfigure(0, weight=2) frame.columnconfigure(0, weight=2)
frame.columnconfigure(1, weight=1) frame.columnconfigure(1, weight=1)
ttk.Label(frame, text="Campaign:").grid(column=0, row=0, columnspan=2) ttk.Label(frame, text="Campaign:").grid(column=0, row=0, columnspan=2)
@@ -1100,8 +985,8 @@ class SettingsPanel:
AUTOSTART_NAME: str = "TwitchDropsMiner" AUTOSTART_NAME: str = "TwitchDropsMiner"
def __init__(self, manager: GUIManager, master: ttk.Widget): def __init__(self, manager: GUIManager, master: ttk.Widget):
self._twitch = manager._twitch
self._settings = manager._twitch.settings self._settings = manager._twitch.settings
self._game_selector = manager.games
self._vars: _SettingsVars = { self._vars: _SettingsVars = {
"tray": IntVar(master, self._settings.autostart_tray), "tray": IntVar(master, self._settings.autostart_tray),
"autostart": IntVar(master, self._settings.autostart), "autostart": IntVar(master, self._settings.autostart),
@@ -1199,7 +1084,16 @@ class SettingsPanel:
ttk.Button( ttk.Button(
exclude_frame, text="❌", command=self.exclude_delete, width=2, style="Large.TButton" exclude_frame, text="❌", command=self.exclude_delete, width=2, style="Large.TButton"
).grid(column=0, row=2, columnspan=2, sticky="ew") ).grid(column=0, row=2, columnspan=2, sticky="ew")
self.priority_only() # update exclude section state # Reload button
reload_frame = ttk.Frame(center_frame)
reload_frame.grid(column=0, row=1, columnspan=3, pady=4)
ttk.Label(
reload_frame,
text="Most changes require a reload to take an immediate effect: "
).grid(column=0, row=0)
ttk.Button(
reload_frame, text="Reload", command=self._twitch.state_change(State.INVENTORY_FETCH)
).grid(column=1, row=0)
def clear_selection(self) -> None: def clear_selection(self) -> None:
self._priority_list.selection_clear(0, "end") self._priority_list.selection_clear(0, "end")
@@ -1226,6 +1120,13 @@ class SettingsPanel:
self._exclude_entry.config(values=games_list) self._exclude_entry.config(values=games_list)
self._priority_entry.config(values=games_list) self._priority_entry.config(values=games_list)
def priorities(self) -> dict[str, int]:
# NOTE: we shift the indexes so that 0 can be used as the default one
size = self._priority_list.size()
return {
game_name: i - size for i, game_name in enumerate(self._priority_list.get(0, "end"))
}
def priority_add(self) -> None: def priority_add(self) -> None:
game_name: str = self._priority_entry.get() game_name: str = self._priority_entry.get()
if not game_name: if not game_name:
@@ -1240,7 +1141,6 @@ class SettingsPanel:
self._priority_list.insert("end", game_name) self._priority_list.insert("end", game_name)
self._priority_list.see("end") self._priority_list.see("end")
self._settings.priority.append(game_name) self._settings.priority.append(game_name)
self._game_selector._exclude_update(game_name)
else: else:
# already there, set the selection on it # already there, set the selection on it
self._priority_list.selection_set(existing_idx) self._priority_list.selection_set(existing_idx)
@@ -1272,15 +1172,11 @@ class SettingsPanel:
idx: int | None = self._priority_idx() idx: int | None = self._priority_idx()
if idx is None: if idx is None:
return return
# NOTE: get the item before it's removed from the list
item: str = self._priority_list.get(idx)
self._priority_list.delete(idx) self._priority_list.delete(idx)
del self._settings.priority[idx] del self._settings.priority[idx]
self._game_selector._exclude_update(item)
def priority_only(self) -> None: def priority_only(self) -> None:
self._settings.priority_only = bool(self._vars["priority_only"].get()) self._settings.priority_only = bool(self._vars["priority_only"].get())
self._game_selector._exclude_sync()
def exclude_add(self) -> None: def exclude_add(self) -> None:
game_name: str = self._exclude_entry.get() game_name: str = self._exclude_entry.get()
@@ -1288,9 +1184,9 @@ class SettingsPanel:
# prevent adding empty strings # prevent adding empty strings
return return
self._exclude_entry.clear() self._exclude_entry.clear()
e = self._settings.exclude exclude = self._settings.exclude
if game_name not in e: if game_name not in exclude:
e.add(game_name) exclude.add(game_name)
# insert it alphabetically # insert it alphabetically
for i, item in enumerate(self._exclude_list.get(0, "end")): for i, item in enumerate(self._exclude_list.get(0, "end")):
if game_name < item: if game_name < item:
@@ -1300,7 +1196,6 @@ class SettingsPanel:
else: else:
self._exclude_list.insert("end", game_name) self._exclude_list.insert("end", game_name)
self._exclude_list.see("end") self._exclude_list.see("end")
self._game_selector._exclude_update(game_name)
else: else:
# it was already there, select it # it was already there, select it
for i, item in enumerate(self._exclude_list.get(0, "end")): for i, item in enumerate(self._exclude_list.get(0, "end")):
@@ -1322,7 +1217,6 @@ class SettingsPanel:
if item in self._settings.exclude: if item in self._settings.exclude:
self._settings.exclude.discard(item) self._settings.exclude.discard(item)
self._exclude_list.delete(idx) self._exclude_list.delete(idx)
self._game_selector._exclude_update(item)
class GUIManager: class GUIManager:
@@ -1388,7 +1282,6 @@ class GUIManager:
self.websockets = WebsocketStatus(self, main_frame) self.websockets = WebsocketStatus(self, main_frame)
self.login = LoginForm(self, main_frame) self.login = LoginForm(self, main_frame)
self.progress = CampaignProgress(self, main_frame) self.progress = CampaignProgress(self, main_frame)
self.games = GameSelector(self, main_frame)
self.output = ConsoleOutput(self, main_frame) self.output = ConsoleOutput(self, main_frame)
self.channels = ChannelList(self, main_frame) self.channels = ChannelList(self, main_frame)
# Inventory tab # Inventory tab
@@ -1498,7 +1391,6 @@ class GUIManager:
self._cache.save() self._cache.save()
def set_games(self, games: abc.Iterable[Game]) -> None: def set_games(self, games: abc.Iterable[Game]) -> None:
self.games.set_games(games)
self.settings.set_games(games) self.settings.set_games(games)
def display_drop( def display_drop(
+18 -4
View File
@@ -75,13 +75,19 @@ class BaseDrop:
campaign = self.campaign campaign = self.campaign
return all(campaign.timed_drops[pid].is_claimed for pid in self._precondition_drops) return all(campaign.timed_drops[pid].is_claimed for pid in self._precondition_drops)
def can_earn(self, channel: Channel | None = None) -> bool: def _base_can_earn(self) -> bool:
return ( return (
self.preconditions # preconditions are met self.preconditions # preconditions are met
and not self.is_claimed # drop isn't already claimed and not self.is_claimed # isn't already claimed
and self.campaign.active # campaign is active # is within the timeframe
# drop is within the active timeframe
and self.starts_at <= datetime.now(timezone.utc) < self.ends_at and self.starts_at <= datetime.now(timezone.utc) < self.ends_at
)
def can_earn(self, channel: Channel | None = None) -> bool:
return (
self._base_can_earn()
# NOTE: this extends drop-only checks with campaign ones
and self.campaign.active # campaign is active
# channel isn't specified, or there's no ACL, or the channel is in the ACL # channel isn't specified, or there's no ACL, or the channel is in the ACL
and ( and (
channel is None channel is None
@@ -267,3 +273,11 @@ class DropsCampaign:
def get_drop(self, drop_id: str) -> TimedDrop | None: def get_drop(self, drop_id: str) -> TimedDrop | None:
return self.timed_drops.get(drop_id) return self.timed_drops.get(drop_id)
def can_earn(self, channel: Channel | None = None) -> bool:
# True if any of the containing drops can be earned
return (
self.active
and (channel is None or not self.allowed_channels or channel in self.allowed_channels)
and any(drop._base_can_earn() for drop in self.drops)
)
+1
View File
@@ -1,3 +1,4 @@
aiohttp>2.0,<4.0 aiohttp>2.0,<4.0
Pillow
pystray pystray
websockets>10.0 websockets>10.0
+108 -112
View File
@@ -4,7 +4,6 @@ import asyncio
import logging import logging
from yarl import URL from yarl import URL
from time import time from time import time
from itertools import chain
from functools import partial from functools import partial
from collections import abc, OrderedDict from collections import abc, OrderedDict
from contextlib import suppress, asynccontextmanager from contextlib import suppress, asynccontextmanager
@@ -46,20 +45,15 @@ logger = logging.getLogger("TwitchDrops")
gql_logger = logging.getLogger("TwitchDrops.gql") gql_logger = logging.getLogger("TwitchDrops.gql")
def viewers_key(channel: Channel) -> int:
if (viewers := channel.viewers) is not None:
return viewers
return -1
class Twitch: class Twitch:
def __init__(self, settings: Settings): def __init__(self, settings: Settings):
self.settings: Settings = settings self.settings: Settings = settings
# State management # State management
self._state: State = State.IDLE self._state: State = State.IDLE
self._state_change = asyncio.Event() self._state_change = asyncio.Event()
self.game: Game | None = None self.games: dict[Game, int] = {}
self.inventory: dict[Game, list[DropsCampaign]] = {} self.inventory: list[DropsCampaign] = []
self._drops: dict[str, TimedDrop] = {}
# GUI # GUI
self.gui = GUIManager(self) self.gui = GUIManager(self)
# Cookies, session and auth # Cookies, session and auth
@@ -151,6 +145,22 @@ class Twitch:
""" """
self.gui.print(*args, **kwargs) self.gui.print(*args, **kwargs)
@staticmethod
def _viewers_key(channel: Channel) -> int:
if (viewers := channel.viewers) is not None:
return viewers
return -1
def _game_key(self, channel: Channel) -> int:
game = channel.game
if game is None:
return 1
elif game not in self.games:
# in case a channel is gathered from an ACL and doesn't play the expected game,
# we use the same priority as for non-prioritized games
return 0
return self.games[game]
async def run(self): async def run(self):
""" """
Main method that runs the whole client. Main method that runs the whole client.
@@ -166,6 +176,7 @@ class Twitch:
except ExitRequest: except ExitRequest:
# we've been requested to exit during login, most likely # we've been requested to exit during login, most likely
return return
await self.websocket.start()
# NOTE: watch task is explicitly restarted on each new run # NOTE: watch task is explicitly restarted on each new run
if self._watching_task is not None: if self._watching_task is not None:
self._watching_task.cancel() self._watching_task.cancel()
@@ -195,42 +206,35 @@ class Twitch:
self.change_state(State.GAMES_UPDATE) self.change_state(State.GAMES_UPDATE)
elif self._state is State.GAMES_UPDATE: elif self._state is State.GAMES_UPDATE:
# Figure out which games to watch, and claim the drops we can # Figure out which games to watch, and claim the drops we can
games: list[Game] = [] self.games.clear()
for game, campaigns in self.inventory.items(): priorities = self.gui.settings.priorities()
add_game = False # claim drops from expired and active campaigns
for campaign in campaigns: for campaign in self.inventory:
if not campaign.upcoming: if not campaign.upcoming:
# claim drops from expired and active campaigns for drop in campaign.drops:
active = campaign.active if drop.can_claim:
for drop in campaign.drops: await drop.claim()
if drop.can_claim: # collect games from active campaigns
await drop.claim() exclude = self.settings.exclude
# add game only for active campaigns priority = self.settings.priority
if active and not add_game and drop.can_earn(): priority_only = self.settings.priority_only
add_game = True for campaign in self.inventory:
if add_game: game = campaign.game
games.append(game) if (
# 'games' has all games we can mine drops for game not in self.games # isn't already there
# if it's empty, there's no point in continuing and ( # isn't excluded
if not games: priority_only and game.name in priority
self.gui.print("No active campaigns to mine drops for.") and game.name not in exclude
return )
# only start the websocket after we confirm there are drops to mine and campaign.can_earn() # campaign can be progressed
await self.websocket.start() ):
self.gui.set_games(games) self.games[game] = priorities.get(game.name, 0)
self.change_state(State.GAME_SELECT) self.gui.set_games(self.games.keys())
elif self._state is State.GAME_SELECT: full_cleanup = True
new_game: Game | None = self.gui.games.get_next()
if new_game != self.game:
if new_game is not None:
# restart the watch loop immediately on new game selected
self.restart_watching()
self.game = new_game
full_cleanup = True
self.change_state(State.CHANNELS_CLEANUP) self.change_state(State.CHANNELS_CLEANUP)
elif self._state is State.CHANNELS_CLEANUP: elif self._state is State.CHANNELS_CLEANUP:
if self.game is None or full_cleanup: if not self.games or full_cleanup:
# no game selected or we're doing full cleanup: remove everything # no games selected or we're doing full cleanup: remove everything
to_remove: list[Channel] = list(channels.values()) to_remove: list[Channel] = list(channels.values())
else: else:
# remove all channels that: # remove all channels that:
@@ -242,7 +246,7 @@ class Twitch:
and ( and (
channel.offline # and are offline channel.offline # and are offline
# or online but aren't streaming the game we want anymore # or online but aren't streaming the game we want anymore
or (channel.game is None or channel.game != self.game) or (channel.game is None or channel.game not in self.games)
) )
) )
] ]
@@ -256,15 +260,15 @@ class Twitch:
del channels[channel.id] del channels[channel.id]
channel.remove() channel.remove()
del to_remove del to_remove
if self.game is not None: if self.games:
self.change_state(State.CHANNELS_FETCH) self.change_state(State.CHANNELS_FETCH)
else: else:
# with no game selected, we switch to IDLE after cleanup # with no games available, we switch to IDLE after cleanup
self.gui.print(
"No active campaigns to mine drops for. Waiting for an active campaign..."
)
self.change_state(State.IDLE) self.change_state(State.IDLE)
elif self._state is State.CHANNELS_FETCH: elif self._state is State.CHANNELS_FETCH:
if self.game is None:
self.change_state(State.GAME_SELECT)
continue
# pre-display the active drop without substracting a minute # pre-display the active drop without substracting a minute
if (active_drop := self.get_active_drop()) is not None: if (active_drop := self.get_active_drop()) is not None:
active_drop.display(countdown=False) active_drop.display(countdown=False)
@@ -272,15 +276,15 @@ class Twitch:
new_channels: OrderedSet[Channel] = OrderedSet(self.channels.values()) new_channels: OrderedSet[Channel] = OrderedSet(self.channels.values())
# gather and add ACL channels from campaigns # gather and add ACL channels from campaigns
# NOTE: we consider only campaigns that can be progressed # NOTE: we consider only campaigns that can be progressed
# NOTE: we use an other set so that we can set them online separately # NOTE: we use another set so that we can set them online separately
no_acl = False no_acl: set[Game] = set()
acl_channels: OrderedSet[Channel] = OrderedSet() acl_channels: OrderedSet[Channel] = OrderedSet()
for campaign in self.inventory[self.game]: for campaign in self.inventory:
if any(drop.can_earn() for drop in campaign.drops): if campaign.game in self.games and campaign.can_earn():
if campaign.allowed_channels: if campaign.allowed_channels:
acl_channels.update(campaign.allowed_channels) acl_channels.update(campaign.allowed_channels)
else: else:
no_acl = True no_acl.add(campaign.game)
# remove all ACL channels that already exist from the other set # remove all ACL channels that already exist from the other set
acl_channels.difference_update(new_channels) acl_channels.difference_update(new_channels)
# use the other set to set them online if possible # use the other set to set them online if possible
@@ -288,17 +292,17 @@ class Twitch:
await asyncio.gather(*(channel.check_online() for channel in acl_channels)) await asyncio.gather(*(channel.check_online() for channel in acl_channels))
# finally, add them as new channels # finally, add them as new channels
new_channels.update(acl_channels) new_channels.update(acl_channels)
if no_acl: for game in no_acl:
# if there's at least one campaign without an ACL, # for every campaign without an ACL, for it's game,
# add a list of live channels with drops enabled # add a list of live channels with drops enabled
new_channels.update(await self.get_live_streams()) new_channels.update(await self.get_live_streams(game))
# sort them descending by viewers, # sort them descending by viewers, by priority and by game priority
# then by priority so that prioritized ones are first
# NOTE: We can drop OrderedSet now because there's no more channels being added # NOTE: We can drop OrderedSet now because there's no more channels being added
ordered_channels: list[Channel] = sorted( ordered_channels: list[Channel] = sorted(
new_channels, key=viewers_key, reverse=True new_channels, key=self._viewers_key, reverse=True
) )
ordered_channels.sort(key=lambda ch: ch.priority, reverse=True) ordered_channels.sort(key=lambda ch: ch.priority, reverse=True)
ordered_channels.sort(key=self._game_key)
# ensure that we won't end up with more channels than we can handle # ensure that we won't end up with more channels than we can handle
# NOTE: we substract 2 due to the two base topics always being added: # NOTE: we substract 2 due to the two base topics always being added:
# channel points gain and drop update subscriptions # channel points gain and drop update subscriptions
@@ -348,9 +352,6 @@ class Twitch:
break break
self.change_state(State.CHANNEL_SWITCH) self.change_state(State.CHANNEL_SWITCH)
elif self._state is State.CHANNEL_SWITCH: elif self._state is State.CHANNEL_SWITCH:
if self.game is None:
self.change_state(State.GAME_SELECT)
continue
# Change into the selected channel, stay in the watching channel, # Change into the selected channel, stay in the watching channel,
# or select a new channel that meets the required conditions # or select a new channel that meets the required conditions
priority_channels: list[Channel] = [] priority_channels: list[Channel] = []
@@ -362,22 +363,32 @@ class Twitch:
if watching_channel is not None: if watching_channel is not None:
priority_channels.append(watching_channel) priority_channels.append(watching_channel)
# If there's no selected channel, change into a channel we can watch # If there's no selected channel, change into a channel we can watch
for channel in chain(priority_channels, channels.values()): new_watching = None
for channel in priority_channels:
if self.can_watch(channel): if self.can_watch(channel):
self.watch(channel) new_watching = channel
# break the state change chain by clearing the flag
self._state_change.clear()
break break
if new_watching is None:
# NOTE: we need to sort the channels every time because one channel
# can end up streaming any game, since channels aren't game-tied
for channel in sorted(channels.values(), key=self._game_key):
if self.can_watch(channel):
new_watching = channel
break
if new_watching is not None:
self.watch(channel)
# break the state change chain by clearing the flag
self._state_change.clear()
else: else:
self.gui.print( self.gui.print(
f"No suitable channel to watch for game: {self.game}\n" "No available channels to watch. Waiting for an ONLINE channel..."
"Waiting for an ONLINE channel..."
) )
self.change_state(State.IDLE) self.change_state(State.IDLE)
elif self._state is State.EXIT: elif self._state is State.EXIT:
# we've been requested to exit the application # we've been requested to exit the application
break break
await self._state_change.wait() await self._state_change.wait()
# post-main-loop code goes here
async def _watch_sleep(self, delay: float) -> None: async def _watch_sleep(self, delay: float) -> None:
# we use wait_for here to allow an asyncio.sleep that can be ended prematurely, # we use wait_for here to allow an asyncio.sleep that can be ended prematurely,
@@ -417,7 +428,7 @@ class Twitch:
) )
if drop_data is not None: if drop_data is not None:
drop_id = drop_data["dropID"] drop_id = drop_data["dropID"]
drop = self.get_drop(drop_id) drop = self._drops.get(drop_id)
if drop is None: if drop is None:
use_active = True use_active = True
logger.error(f"Missing drop: {drop_id}") logger.error(f"Missing drop: {drop_id}")
@@ -453,20 +464,15 @@ class Twitch:
await asyncio.sleep(60) await asyncio.sleep(60)
def can_watch(self, channel: Channel) -> bool: def can_watch(self, channel: Channel) -> bool:
if self.game is None: if not self.games:
return False return False
return ( return (
channel.online # steam online channel.online # steam online
and channel.drops_enabled # drops are enabled and channel.drops_enabled # drops are enabled
# it's a game we've selected # it's one of the games we've selected
and channel.game is not None and channel.game is not None and channel.game in self.games
and channel.game == self.game
# we can progress any campaign for the selected game # we can progress any campaign for the selected game
and any( and any(campaign.can_earn(channel) for campaign in self.inventory)
drop.can_earn(channel)
for campaign in self.inventory[self.game]
for drop in campaign.drops
)
) )
def watch(self, channel: Channel): def watch(self, channel: Channel):
@@ -513,13 +519,22 @@ class Twitch:
Called by a Channel when it goes online (after pending). Called by a Channel when it goes online (after pending).
""" """
logger.debug(f"{channel.name} goes ONLINE") logger.debug(f"{channel.name} goes ONLINE")
channel_order = self._game_key(channel)
watching_channel = self.watching_channel.get_with_default(None) watching_channel = self.watching_channel.get_with_default(None)
if watching_channel is not None:
watching_order = self._game_key(watching_channel)
else:
watching_order = 1
if ( if (
( (
self._state is State.IDLE # we're currently idle self._state is State.IDLE # we're currently idle
or channel.priority # or this channel has priority or watching_channel is None # or aren't watching anything
and watching_channel is not None # and we're watching... # or this channel is higher order than the watching one
and not watching_channel.priority # ... a non-priority channel # NOTE: order is tied to the list position, so lower == higher
or channel_order < watching_order
or channel_order == watching_order # or the order is the same
and channel.priority # and this channel has priority
and not watching_channel.priority # and we're watching a non-priority channel
) and self.can_watch(channel) ) and self.can_watch(channel)
): ):
self.gui.print(f"{channel.name} goes ONLINE, switching...") self.gui.print(f"{channel.name} goes ONLINE, switching...")
@@ -546,7 +561,7 @@ class Twitch:
if msg_type not in ("drop-progress", "drop-claim"): if msg_type not in ("drop-progress", "drop-claim"):
return return
drop_id: str = message["data"]["drop_id"] drop_id: str = message["data"]["drop_id"]
drop: TimedDrop | None = self.get_drop(drop_id) drop: TimedDrop | None = self._drops.get(drop_id)
if msg_type == "drop-claim": if msg_type == "drop-claim":
if drop is None: if drop is None:
logger.error( logger.error(
@@ -881,43 +896,27 @@ class Twitch:
# add campaigns that remained, that can be earned but are not in-progress yet # add campaigns that remained, that can be earned but are not in-progress yet
for campaign_id, available_data in available_campaigns.items(): for campaign_id, available_data in available_campaigns.items():
campaign = await self.fetch_campaign(campaign_id, available_data, claimed_benefits) campaign = await self.fetch_campaign(campaign_id, available_data, claimed_benefits)
if any(drop.can_earn() for drop in campaign.drops): if campaign.can_earn():
campaigns.append(campaign) campaigns.append(campaign)
campaigns.sort(key=lambda c: c.ends_at) campaigns.sort(key=lambda c: c.ends_at)
self.inventory.clear() self._drops.clear()
self.gui.inv.clear() self.gui.inv.clear()
self.inventory.clear()
for campaign in campaigns: for campaign in campaigns:
game = campaign.game self._drops.update((drop.id, drop) for drop in campaign.drops)
if game not in self.inventory:
self.inventory[game] = []
await self.gui.inv.add_campaign(campaign) await self.gui.inv.add_campaign(campaign)
self.inventory[game].append(campaign) self.inventory.append(campaign)
self.gui.save() self.gui.save()
def get_drop(self, drop_id: str) -> TimedDrop | None:
"""
Returns a drop from the inventory, based on it's ID.
"""
# try it with the currently selected game first
if self.game is not None:
for campaign in self.inventory[self.game]:
if (drop := campaign.get_drop(drop_id)) is not None:
return drop
# fallback to checking all campaigns
for campaign in chain(*self.inventory.values()):
if (drop := campaign.get_drop(drop_id)) is not None:
return drop
return None
def get_active_drop(self, channel: Channel | None = None) -> TimedDrop | None: def get_active_drop(self, channel: Channel | None = None) -> TimedDrop | None:
if self.game is None: if not self.games:
return None return None
watching_channel = self.watching_channel.get_with_default(channel) watching_channel = self.watching_channel.get_with_default(channel)
drops = sorted( drops = sorted(
( (
drop drop
for campaign in self.inventory[self.game] for campaign in self.inventory
if campaign.active if campaign.game in self.games and campaign.can_earn(watching_channel)
for drop in campaign.drops for drop in campaign.drops
if drop.can_earn(watching_channel) if drop.can_earn(watching_channel)
), ),
@@ -927,14 +926,11 @@ class Twitch:
return drops[0] return drops[0]
return None return None
async def get_live_streams(self) -> list[Channel]: async def get_live_streams(self, game: Game, *, limit: int = 30) -> list[Channel]:
if self.game is None:
return []
limit = 30
response = await self.gql_request( response = await self.gql_request(
GQL_OPERATIONS["GameDirectory"].with_variables({ GQL_OPERATIONS["GameDirectory"].with_variables({
"limit": limit, "limit": limit,
"name": self.game.name, "name": game.name,
"options": { "options": {
"includeRestricted": ["SUB_ONLY_LIVE"], "includeRestricted": ["SUB_ONLY_LIVE"],
"tags": [DROPS_ENABLED_TAG], "tags": [DROPS_ENABLED_TAG],
+1 -1
View File
@@ -213,4 +213,4 @@ class Game:
return NotImplemented return NotImplemented
def __hash__(self) -> int: def __hash__(self) -> int:
return hash((self.__class__.__name__, self.id)) return self.id