Handle maximum channels limit;
Proper handle of a case where the app ends up with more channels from ACLs than is supported
This commit is contained in:
1 parent
ce9af7b4ba
commit
423e51bc27
7 files changed
+102
-63
No files matched your search
Vendored
+3
-3
@@ -16,7 +16,7 @@
|
|||||||
"type": "python",
|
"type": "python",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "main.py",
|
"program": "main.py",
|
||||||
"args": ["--no-run-check", "-vv"],
|
"args": ["--no-run-check", "--idle", "-vv"],
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": false
|
"justMyCode": false
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"type": "python",
|
"type": "python",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "main.py",
|
"program": "main.py",
|
||||||
"args": ["--no-run-check", "-vvv"],
|
"args": ["--no-run-check", "--idle", "-vvv"],
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": false
|
"justMyCode": false
|
||||||
},
|
},
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"type": "python",
|
"type": "python",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "main.py",
|
"program": "main.py",
|
||||||
"args": ["--no-run-check", "-vvv", "--debug-ws"],
|
"args": ["--no-run-check", "--idle", "-vvv", "--debug-ws"],
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": false
|
"justMyCode": false
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -312,7 +312,7 @@ class Channel:
|
|||||||
self.display()
|
self.display()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def _payload(self):
|
def _payload(self) -> JsonType:
|
||||||
assert self._stream is not None
|
assert self._stream is not None
|
||||||
payload = [
|
payload = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -663,7 +663,9 @@ class ChannelList:
|
|||||||
|
|
||||||
def set_watching(self, channel: Channel):
|
def set_watching(self, channel: Channel):
|
||||||
self.clear_watching()
|
self.clear_watching()
|
||||||
self._table.item(channel.iid, tags="watching")
|
iid = channel.iid
|
||||||
|
self._table.item(iid, tags="watching")
|
||||||
|
self._table.see(iid)
|
||||||
|
|
||||||
def get_selection(self) -> Optional[Channel]:
|
def get_selection(self) -> Optional[Channel]:
|
||||||
if not self._channel_map:
|
if not self._channel_map:
|
||||||
@@ -676,6 +678,10 @@ class ChannelList:
|
|||||||
def clear_selection(self):
|
def clear_selection(self):
|
||||||
self._table.selection_set('')
|
self._table.selection_set('')
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
iids = self._table.get_children()
|
||||||
|
self._table.delete(*iids)
|
||||||
|
|
||||||
def display(self, channel: Channel, *, add: bool = False):
|
def display(self, channel: Channel, *, add: bool = False):
|
||||||
# priority
|
# priority
|
||||||
priority = "✔" if channel.priority else "❌"
|
priority = "✔" if channel.priority else "❌"
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
aiohttp>2.0,<4.0
|
aiohttp>2.0,<4.0
|
||||||
pystray
|
pystray
|
||||||
websockets
|
websockets>10.0
|
||||||
@@ -17,8 +17,8 @@ from typing import (
|
|||||||
Set,
|
Set,
|
||||||
OrderedDict,
|
OrderedDict,
|
||||||
Callable,
|
Callable,
|
||||||
Iterable,
|
|
||||||
AsyncIterator,
|
AsyncIterator,
|
||||||
|
Final,
|
||||||
cast,
|
cast,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
@@ -41,7 +41,9 @@ from constants import (
|
|||||||
USER_AGENT,
|
USER_AGENT,
|
||||||
COOKIES_PATH,
|
COOKIES_PATH,
|
||||||
GQL_OPERATIONS,
|
GQL_OPERATIONS,
|
||||||
|
MAX_WEBSOCKETS,
|
||||||
WATCH_INTERVAL,
|
WATCH_INTERVAL,
|
||||||
|
WS_TOPICS_LIMIT,
|
||||||
DROPS_ENABLED_TAG,
|
DROPS_ENABLED_TAG,
|
||||||
JsonType,
|
JsonType,
|
||||||
State,
|
State,
|
||||||
@@ -49,8 +51,6 @@ from constants import (
|
|||||||
WebsocketTopic,
|
WebsocketTopic,
|
||||||
)
|
)
|
||||||
|
|
||||||
from exceptions import MinerException
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from gui import LoginForm
|
from gui import LoginForm
|
||||||
from main import ParsedArgs
|
from main import ParsedArgs
|
||||||
@@ -60,6 +60,13 @@ logger = logging.getLogger("TwitchDrops")
|
|||||||
gql_logger = logging.getLogger("TwitchDrops.gql")
|
gql_logger = logging.getLogger("TwitchDrops.gql")
|
||||||
|
|
||||||
|
|
||||||
|
def viewers_key(channel: Channel) -> int:
|
||||||
|
viewers = channel.viewers
|
||||||
|
if viewers is not None:
|
||||||
|
return viewers
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
class Twitch:
|
class Twitch:
|
||||||
def __init__(self, options: ParsedArgs):
|
def __init__(self, options: ParsedArgs):
|
||||||
self.options = options
|
self.options = options
|
||||||
@@ -174,6 +181,7 @@ class Twitch:
|
|||||||
])
|
])
|
||||||
games: List[Game] = []
|
games: List[Game] = []
|
||||||
full_cleanup: bool = False
|
full_cleanup: bool = False
|
||||||
|
channels: Final[OrderedDict[int, Channel]] = self.channels
|
||||||
self.change_state(State.INVENTORY_FETCH)
|
self.change_state(State.INVENTORY_FETCH)
|
||||||
while True:
|
while True:
|
||||||
if self._state is State.IDLE:
|
if self._state is State.IDLE:
|
||||||
@@ -214,10 +222,6 @@ class Twitch:
|
|||||||
self.change_state(State.GAME_SELECT)
|
self.change_state(State.GAME_SELECT)
|
||||||
elif self._state is State.GAME_SELECT:
|
elif self._state is State.GAME_SELECT:
|
||||||
self.game = self.gui.games.get_selection()
|
self.game = self.gui.games.get_selection()
|
||||||
# pre-display the active drop without a countdown
|
|
||||||
active_drop = self.get_active_drop()
|
|
||||||
if active_drop is not None:
|
|
||||||
active_drop.display(countdown=False)
|
|
||||||
# restart the watch loop if needed
|
# restart the watch loop if needed
|
||||||
self.restart_watching()
|
self.restart_watching()
|
||||||
# signal channel cleanup that we're removing everything
|
# signal channel cleanup that we're removing everything
|
||||||
@@ -226,12 +230,12 @@ class Twitch:
|
|||||||
elif self._state is State.CHANNELS_CLEANUP:
|
elif self._state is State.CHANNELS_CLEANUP:
|
||||||
if self.game is None or full_cleanup:
|
if self.game is None or full_cleanup:
|
||||||
# no game selected or we're doing full cleanup: remove everything
|
# no game selected or we're doing full cleanup: remove everything
|
||||||
to_remove: List[Channel] = list(self.channels.values())
|
to_remove: List[Channel] = list(channels.values())
|
||||||
else:
|
else:
|
||||||
# remove all channels that:
|
# remove all channels that:
|
||||||
to_remove = [
|
to_remove = [
|
||||||
channel
|
channel
|
||||||
for channel in self.channels.values()
|
for channel in channels.values()
|
||||||
if (
|
if (
|
||||||
not channel.priority # aren't prioritized
|
not channel.priority # aren't prioritized
|
||||||
and (
|
and (
|
||||||
@@ -242,14 +246,14 @@ class Twitch:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
full_cleanup = False
|
full_cleanup = False
|
||||||
|
if to_remove:
|
||||||
self.websocket.remove_topics(
|
self.websocket.remove_topics(
|
||||||
WebsocketTopic.as_str("Channel", "VideoPlayback", channel.id)
|
WebsocketTopic.as_str("Channel", "StreamState", channel.id)
|
||||||
for channel in to_remove
|
for channel in to_remove
|
||||||
)
|
)
|
||||||
for channel in to_remove:
|
for channel in to_remove:
|
||||||
del self.channels[channel.id]
|
del channels[channel.id]
|
||||||
channel.remove()
|
channel.remove()
|
||||||
self.gui.channels.shrink()
|
|
||||||
self.change_state(State.CHANNELS_FETCH)
|
self.change_state(State.CHANNELS_FETCH)
|
||||||
elif self._state is State.CHANNELS_FETCH:
|
elif self._state is State.CHANNELS_FETCH:
|
||||||
if self.game is None:
|
if self.game is None:
|
||||||
@@ -259,61 +263,77 @@ class Twitch:
|
|||||||
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)
|
||||||
# gather ACLs from campaigns
|
# gather ACLs from campaigns
|
||||||
|
# NOTE: we consider only campaigns that can be progressed
|
||||||
no_acl = False
|
no_acl = False
|
||||||
new_channels: OrderedSet[Channel] = OrderedSet()
|
new_channels: OrderedSet[Channel] = OrderedSet()
|
||||||
for campaign in self.inventory[self.game]:
|
for campaign in self.inventory[self.game]:
|
||||||
acls = campaign.allowed_channels
|
if any(drop.can_earn() for drop in campaign.drops):
|
||||||
if acls:
|
acl = campaign.allowed_channels
|
||||||
for channel in acls:
|
if acl:
|
||||||
new_channels.add(channel)
|
new_channels.update(acl)
|
||||||
else:
|
else:
|
||||||
no_acl = True
|
no_acl = True
|
||||||
# set them online if possible
|
# set them online if possible
|
||||||
await asyncio.gather(*(channel.check_online() for channel in new_channels))
|
await asyncio.gather(*(channel.check_online() for channel in new_channels))
|
||||||
if no_acl:
|
if no_acl:
|
||||||
# if there's at least one game without an ACL,
|
# if there's at least one campaign without an ACL,
|
||||||
# get a list of all live channels with drops enabled
|
# add a list of live channels with drops enabled
|
||||||
live_streams: List[Channel] = await self.get_live_streams()
|
new_channels.update(await self.get_live_streams())
|
||||||
for channel in live_streams:
|
# merge current channels into new ones
|
||||||
new_channels.add(channel)
|
new_channels.update(self.channels.values())
|
||||||
for channel in new_channels:
|
# sort them descending by viewers,
|
||||||
if self.can_watch(channel):
|
# then by priority so that prioritized ones are first
|
||||||
# there are streams we can watch, so let's pre-display the active drop
|
# NOTE: We can drop OrderedSet now because there's no more channels being added
|
||||||
# again, but this time with a substracted minute
|
ordered_channels: List[Channel] = sorted(
|
||||||
if (active_drop := self.get_active_drop(channel)) is not None:
|
new_channels, key=viewers_key, reverse=True
|
||||||
active_drop.display(countdown=False, subone=True)
|
|
||||||
break
|
|
||||||
# add them, filtering out ones we already have
|
|
||||||
for channel in new_channels:
|
|
||||||
if (channel_id := channel.id) not in self.channels:
|
|
||||||
self.channels[channel_id] = channel
|
|
||||||
channel.display(add=True)
|
|
||||||
# Subscribe to these channel's state updates
|
|
||||||
topics: List[WebsocketTopic] = [
|
|
||||||
WebsocketTopic(
|
|
||||||
"Channel", "VideoPlayback", channel_id, self.process_stream_state
|
|
||||||
)
|
)
|
||||||
for channel_id in self.channels
|
ordered_channels.sort(key=lambda ch: ch.priority, reverse=True)
|
||||||
]
|
# 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:
|
||||||
try:
|
# channel points gain and drop update subscriptions
|
||||||
self.websocket.add_topics(topics)
|
# NOTE: we trim from the end because that's where the non-priority,
|
||||||
except MinerException as err:
|
# offline (or online but low viewers) channels end up
|
||||||
# don't panic if we have more topics than available sockets
|
max_channels = MAX_WEBSOCKETS * WS_TOPICS_LIMIT - 2
|
||||||
# but still log an error
|
to_remove = ordered_channels[max_channels:]
|
||||||
logger.error(err)
|
ordered_channels = ordered_channels[:max_channels]
|
||||||
pass
|
if to_remove:
|
||||||
|
# tracked channels and gui are cleared below, so no need to do it here
|
||||||
|
# just make sure to unsubscribe from their topics
|
||||||
|
self.websocket.remove_topics(
|
||||||
|
WebsocketTopic.as_str("Channel", "StreamState", channel.id)
|
||||||
|
for channel in to_remove
|
||||||
|
)
|
||||||
|
# set our new channel list
|
||||||
|
channels.clear()
|
||||||
|
self.gui.channels.clear()
|
||||||
|
self.gui.channels.shrink()
|
||||||
|
for channel in ordered_channels:
|
||||||
|
channels[channel.id] = channel
|
||||||
|
channel.display(add=True)
|
||||||
|
# subscribe to these channel's state updates
|
||||||
|
self.websocket.add_topics([
|
||||||
|
WebsocketTopic(
|
||||||
|
"Channel", "StreamState", channel_id, self.process_stream_state
|
||||||
|
)
|
||||||
|
for channel_id in channels
|
||||||
|
])
|
||||||
# relink watching channel after cleanup,
|
# relink watching channel after cleanup,
|
||||||
# or stop watching it if it no longer qualifies
|
# or stop watching it if it no longer qualifies
|
||||||
watching_channel = self.watching_channel.get_with_default(None)
|
watching_channel = self.watching_channel.get_with_default(None)
|
||||||
if watching_channel is not None:
|
if watching_channel is not None:
|
||||||
new_watching = self.channels.get(watching_channel.id)
|
new_watching = channels.get(watching_channel.id)
|
||||||
if new_watching is not None and self.can_watch(new_watching):
|
if new_watching is not None and self.can_watch(new_watching):
|
||||||
self.watch(new_watching)
|
self.watch(new_watching)
|
||||||
else:
|
else:
|
||||||
# we're removing a channel we're watching
|
# we're removing a channel we're watching
|
||||||
self.stop_watching()
|
self.stop_watching()
|
||||||
|
# pre-display the active drop again with a substracted minute
|
||||||
|
for channel in channels.values():
|
||||||
|
# check if there's any channels we can watch first
|
||||||
|
if self.can_watch(channel):
|
||||||
|
if (active_drop := self.get_active_drop(channel)) is not None:
|
||||||
|
active_drop.display(countdown=False, subone=True)
|
||||||
|
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:
|
if self.game is None:
|
||||||
@@ -321,7 +341,6 @@ class Twitch:
|
|||||||
else:
|
else:
|
||||||
# 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
|
||||||
channels: Iterable[Channel]
|
|
||||||
priority_channels: List[Channel] = []
|
priority_channels: List[Channel] = []
|
||||||
selected_channel = self.gui.channels.get_selection()
|
selected_channel = self.gui.channels.get_selection()
|
||||||
if selected_channel is not None:
|
if selected_channel is not None:
|
||||||
@@ -330,9 +349,8 @@ class Twitch:
|
|||||||
watching_channel = self.watching_channel.get_with_default(None)
|
watching_channel = self.watching_channel.get_with_default(None)
|
||||||
if watching_channel is not None:
|
if watching_channel is not None:
|
||||||
priority_channels.append(watching_channel)
|
priority_channels.append(watching_channel)
|
||||||
channels = chain(priority_channels, self.channels.values())
|
|
||||||
# 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 channels:
|
for channel in chain(priority_channels, channels.values()):
|
||||||
if self.can_watch(channel):
|
if self.can_watch(channel):
|
||||||
self.watch(channel)
|
self.watch(channel)
|
||||||
# break the state change chain by clearing the flag
|
# break the state change chain by clearing the flag
|
||||||
|
|||||||
@@ -56,14 +56,14 @@ class OrderedSet(MutableSet[_V]):
|
|||||||
Implementation of a set that preserves insertion order,
|
Implementation of a set that preserves insertion order,
|
||||||
based on OrderedDict with values set to None.
|
based on OrderedDict with values set to None.
|
||||||
"""
|
"""
|
||||||
def __init__(self, iterable: Iterable[_V] = []):
|
def __init__(self, /, iterable: Iterable[_V] = []):
|
||||||
self._items: OrderedDict[_V, None] = OrderedDict((item, None) for item in iterable)
|
self._items: OrderedDict[_V, None] = OrderedDict((item, None) for item in iterable)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.__class__.__name__}([{', '.join(map(repr, self._items))}])"
|
return f"{self.__class__.__name__}([{', '.join(map(repr, self._items))}])"
|
||||||
|
|
||||||
def __contains__(self, x: object) -> bool:
|
def __contains__(self, /, item: object) -> bool:
|
||||||
return x in self._items
|
return item in self._items
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[_V]:
|
def __iter__(self) -> Iterator[_V]:
|
||||||
return iter(self._items)
|
return iter(self._items)
|
||||||
@@ -71,13 +71,25 @@ class OrderedSet(MutableSet[_V]):
|
|||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self._items)
|
return len(self._items)
|
||||||
|
|
||||||
def add(self, item: _V) -> None:
|
def add(self, /, item: _V) -> None:
|
||||||
self._items[item] = None
|
self._items[item] = None
|
||||||
|
|
||||||
def discard(self, item: _V) -> None:
|
def discard(self, /, item: _V) -> None:
|
||||||
with suppress(KeyError):
|
with suppress(KeyError):
|
||||||
del self._items[item]
|
del self._items[item]
|
||||||
|
|
||||||
|
def update(self, /, *others: Iterable[_V]) -> None:
|
||||||
|
for it in others:
|
||||||
|
for item in it:
|
||||||
|
if item not in self._items:
|
||||||
|
self._items[item] = None
|
||||||
|
|
||||||
|
def difference_update(self, *others: Iterable[_V]) -> None:
|
||||||
|
for it in others:
|
||||||
|
for item in it:
|
||||||
|
if item in self._items:
|
||||||
|
del self._items[item]
|
||||||
|
|
||||||
|
|
||||||
class AwaitableValue(Generic[_V]):
|
class AwaitableValue(Generic[_V]):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ from time import time
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Optional, List, Dict, Set, Iterable, TYPE_CHECKING
|
from typing import Optional, List, Dict, Set, Iterable, TYPE_CHECKING
|
||||||
|
|
||||||
|
try:
|
||||||
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
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise ImportError("You have to run 'pip install websockets' first") from exc
|
||||||
|
|
||||||
from exceptions import MinerException
|
from exceptions import MinerException
|
||||||
from utils import task_wrapper, create_nonce
|
from utils import task_wrapper, create_nonce
|
||||||
|
|||||||
Reference in new issue
Block a user