Implement support for campaign ACLs

This commit is contained in:
DevilXD committed 2022-01-22 15:31:44 +01:00
1 parent e21494cfe8
commit bd9d818b21
8 files changed
+333 -198

No files matched your search

+135 -103
View File
@@ -8,7 +8,7 @@ from time import time
from itertools import chain
from functools import partial
from typing import (
Optional, Union, List, Dict, Set, Callable, Iterable, Generic, TypeVar, cast, TYPE_CHECKING
Optional, Union, List, Dict, Set, OrderedDict, Callable, Iterable, cast, TYPE_CHECKING
)
try:
@@ -17,10 +17,11 @@ except ModuleNotFoundError as exc:
raise ImportError("You have to run 'pip install aiohttp' first") from exc
from channel import Channel
from websocket import WebsocketPool
from gui import GUIManager, LoginData
from websocket import WebsocketPool, task_wrapper
from inventory import DropsCampaign, Game, TimedDrop
from exceptions import LoginException, CaptchaRequired
from utils import AwaitableValue, OrderedSet, task_wrapper
from constants import (
State,
JsonType,
@@ -44,35 +45,6 @@ logger = logging.getLogger("TwitchDrops")
gql_logger = logging.getLogger("TwitchDrops.gql")
_V = TypeVar("_V")
_D = TypeVar("_D")
class _AwaitableValue(Generic[_V]):
def __init__(self):
self._value: _V
self._event = asyncio.Event()
def has_value(self) -> bool:
return self._event.is_set()
def get_with_default(self, default: _D) -> Union[_D, _V]:
if self._event.is_set():
return self._value
return default
async def get(self) -> _V:
await self._event.wait()
return self._value
def set(self, value: _V) -> None:
self._value = value
self._event.set()
def clear(self) -> None:
self._event.clear()
class Twitch:
def __init__(self, loop: asyncio.AbstractEventLoop, options: ParsedArgs):
self._loop = loop
@@ -95,10 +67,11 @@ class Twitch:
# State management
self._state: State = State.INVENTORY_FETCH
self._state_change = asyncio.Event()
self.inventory: List[DropsCampaign] = []
self.inventory: Dict[Game, List[DropsCampaign]] = {}
self.game: Optional[Game] = None
# Storing and watching channels
self.channels: Dict[int, Channel] = {}
self._watching_channel: _AwaitableValue[Channel] = _AwaitableValue()
self.channels: OrderedDict[int, Channel] = OrderedDict()
self.watching_channel: AwaitableValue[Channel] = AwaitableValue()
self._watching_task: Optional[asyncio.Task[None]] = None
self._watching_restart = asyncio.Event()
self._drop_update: Optional[asyncio.Future[bool]] = None
@@ -166,7 +139,7 @@ class Twitch:
await asyncio.sleep(start_time + 1 - time())
def is_watching(self, channel: Channel) -> bool:
watching_channel = self._watching_channel.get_with_default(None)
watching_channel = self.watching_channel.get_with_default(None)
return watching_channel is not None and watching_channel == channel
async def run(self):
@@ -188,7 +161,6 @@ class Twitch:
WebsocketTopic("User", "CommunityPoints", self._user_id, self.process_points),
])
games: List[Game] = []
selected_game: Optional[Game] = None
self.change_state(State.INVENTORY_FETCH)
while True:
if self._state is State.IDLE:
@@ -200,17 +172,23 @@ class Twitch:
elif self._state is State.GAMES_UPDATE:
# Figure out which games to watch, and claim the drops we can
games.clear()
for campaign in self.inventory:
# we have no use in processing upcoming campaigns here
if not campaign.upcoming:
for drop in campaign.timed_drops.values():
if drop.can_claim:
await drop.claim()
if drop.can_earn and (game := campaign.game) not in games:
games.append(game)
for game, campaigns in self.inventory.items():
add_game = False
for campaign in campaigns:
if not campaign.upcoming:
# claim drops from expired and active campaigns
active = campaign.active
for drop in campaign.drops:
if drop.can_claim:
await drop.claim()
# add game only for active campaigns
if active and not add_game and drop.can_earn:
add_game = True
if add_game:
games.append(game)
self.change_state(State.GAME_SELECT)
elif self._state is State.GAME_SELECT:
# 'games' has all games we want to mine drops for
# 'games' has all games we can mine drops for
# if it's empty, there's no point in continuing
if not games:
self.gui.print("No active campaigns to mine drops for.")
@@ -218,20 +196,25 @@ class Twitch:
# only start the websocket after we confirm there are drops to mine
await self.websocket.start()
self.gui.games.set_games(games)
selected_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(selected_game)
active_drop = self.get_active_drop()
if active_drop is not None:
active_drop.display(countdown=False)
self.restart_watching()
self.change_state(State.CHANNELS_CLEANUP)
elif self._state is State.CHANNELS_CLEANUP:
# remove all channels that are offline,
# or aren't streaming the game we want anymore
to_remove: List[Channel] = [
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
]
if self.game is None:
# remove everything
to_remove: List[Channel] = list(self.channels.values())
else:
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 != self.game
]
self.websocket.remove_topics(
WebsocketTopic.as_str("Channel", "VideoPlayback", channel.id)
for channel in to_remove
@@ -245,26 +228,42 @@ class Twitch:
self.gui.channels.shrink()
self.change_state(State.CHANNELS_FETCH)
elif self._state is State.CHANNELS_FETCH:
if selected_game is None:
if self.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]
)
if live_streams:
# pre-display the active drop without substracting a minute
active_drop = self.get_active_drop()
if active_drop is not None:
active_drop.display(countdown=False)
# gather ACLs from campaigns
no_acl = False
new_channels: OrderedSet[Channel] = OrderedSet()
for campaign in self.inventory[self.game]:
acls = campaign.allowed_channels
if acls:
for channel in acls:
new_channels.add(channel)
else:
no_acl = True
# set them online if possible
await asyncio.gather(*(channel.get_stream() for channel in new_channels))
if no_acl:
# if there's at least one game without an ACL,
# get a list of all live channels with drops enabled
live_streams: List[Channel] = await self.get_live_streams()
for channel in live_streams:
new_channels.add(channel)
if new_channels:
# there are online streams, so let's pre-display the active drop again,
# but this time with a substracted minute
active_drop = self.get_active_drop(selected_game)
active_drop = self.get_active_drop()
if active_drop is not None:
active_drop.display(countdown=False, subone=True)
# add them, filtering out ones we already have
for channel in live_streams:
for channel in new_channels:
if channel.id not in self.channels:
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(
@@ -275,7 +274,7 @@ class Twitch:
self.websocket.add_topics(topics)
self.change_state(State.CHANNEL_SWITCH)
elif self._state is State.CHANNEL_SWITCH:
if selected_game is None:
if self.game is None:
self.change_state(State.GAME_SELECT)
else:
# Change into the selected channel, stay in the watching channel,
@@ -286,24 +285,20 @@ class Twitch:
if selected_channel is not None:
self.gui.channels.clear_selection()
priority_channels.append(selected_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:
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
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
):
if self.can_watch(channel):
self.watch(channel)
# break the state change chain by clearing the flag
self._state_change.clear()
break
else:
self.stop_watching()
self.gui.print(f"No suitable channel to watch for game: {selected_game}")
self.gui.print(f"No suitable channel to watch for game: {self.game}")
self.change_state(State.IDLE)
elif self._state is State.EXIT:
# we've been requested to exit the application
@@ -315,7 +310,7 @@ class Twitch:
interval = WATCH_INTERVAL.total_seconds()
i = 1
while True:
channel = await self._watching_channel.get()
channel = await self.watching_channel.get()
await channel.send_watch()
last_watch = time()
self._drop_update = asyncio.Future()
@@ -347,8 +342,7 @@ class Twitch:
# 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
selected_game = self.gui.games.get_selection()
drop = self.get_active_drop(selected_game)
drop = self.get_active_drop()
if drop is not None:
drop.bump_minutes()
drop.display()
@@ -371,27 +365,37 @@ class Twitch:
except asyncio.TimeoutError:
pass
def can_watch(self, channel: Channel) -> bool:
if self.game is None:
return False
return (
channel.online # steam online
and channel.drops_enabled # drops are enabled
and channel.game == self.game # it's a game we've selected
# we can progress any campaign for the selected game
and any(
drop.can_earn
for campaign in self.inventory[self.game]
if not campaign.allowed_channels or channel in campaign.allowed_channels
for drop in campaign.drops
)
)
def watch(self, channel: Channel):
if self.is_watching(channel):
# we're already watching the same channel, so there's no point switching
return
self.gui.channels.set_watching(channel)
self._watching_channel.set(channel)
self.watching_channel.set(channel)
def stop_watching(self):
self.gui.progress.stop_timer()
self.gui.channels.clear_watching()
self._watching_channel.clear()
self.watching_channel.clear()
def restart_watching(self, channel: Optional[Channel] = None):
# this forcibly re-sends the watching payload to the specified or currently watched channel
if channel is None:
channel = self._watching_channel.get_with_default(None)
if channel is not None:
self.gui.progress.stop_timer()
self._watching_channel.set(channel)
self.gui.channels.set_watching(channel)
self._watching_restart.set()
def restart_watching(self):
self.gui.progress.stop_timer()
self._watching_restart.set()
@task_wrapper
async def process_stream_state(self, channel_id: int, message: JsonType):
@@ -401,14 +405,8 @@ class Twitch:
logger.error(f"Stream state change for a non-existing channel: {channel_id}")
return
if msg_type == "stream-down":
logger.info(f"{channel.name} goes OFFLINE")
channel.set_offline()
if self.is_watching(channel):
self.gui.print(f"{channel.name} goes OFFLINE, switching...")
# change the channel if we're currently watching it
self.change_state(State.CHANNEL_SWITCH)
elif msg_type == "stream-up":
logger.info(f"{channel.name} goes ONLINE")
channel.set_online()
elif msg_type == "viewcount":
if not channel.online:
@@ -417,7 +415,28 @@ class Twitch:
else:
viewers = message["viewers"]
channel.viewers = viewers
logger.debug(f"{channel.name} viewers: {viewers}")
# logger.debug(f"{channel.name} viewers: {viewers}")
def on_online(self, channel: Channel):
"""
Called by a Channel when it goes online (after pending).
"""
logger.debug(f"{channel.name} goes ONLINE")
if channel.priority:
wch = self.watching_channel.get_with_default(None)
if wch is not None and not wch.priority and self.can_watch(channel):
self.watch(channel)
def on_offline(self, channel: Channel):
"""
Called by a Channel when it goes offline.
"""
# change the channel if we're currently watching it
if self.is_watching(channel):
self.gui.print(f"{channel.name} goes OFFLINE, switching...")
self.change_state(State.CHANNEL_SWITCH)
else:
logger.debug(f"{channel.name} goes OFFLINE")
@task_wrapper
async def process_drops(self, user_id: int, message: JsonType):
@@ -706,9 +725,10 @@ class Twitch:
# fetch all available campaign IDs, that are currently ACTIVE and account is connected
response = await self.gql_request(GQL_OPERATIONS["Campaigns"])
data = response["data"]["currentUser"]["dropCampaigns"] or []
applicable_statuses = ("ACTIVE", "UPCOMING")
available_campaigns: Set[str] = set(
c["id"] for c in data
if c["status"] == "ACTIVE" and c["self"]["isAccountConnected"]
if c["status"] in applicable_statuses and c["self"]["isAccountConnected"]
)
# fetch in-progress campaigns (inventory)
response = await self.gql_request(GQL_OPERATIONS["Inventory"])
@@ -727,26 +747,36 @@ class Twitch:
# add campaigns that remained, that can be earned but are not in-progress yet
for campaign_id in available_campaigns:
campaign = await self.fetch_campaign(campaign_id, claimed_benefits)
if any(drop.can_earn for drop in campaign.timed_drops.values()):
if any(drop.can_earn for drop in campaign.drops):
campaigns.append(campaign)
campaigns.sort(key=lambda c: c.ends_at)
self.inventory = campaigns
self.inventory.clear()
for campaign in campaigns:
game = campaign.game
if game not in self.inventory:
self.inventory[game] = []
self.inventory[game].append(campaign)
def get_drop(self, drop_id: str) -> Optional[TimedDrop]:
for campaign in self.inventory:
for campaign in chain(*self.inventory.values()):
drop = campaign.timed_drops.get(drop_id)
if drop is not None:
return drop
return None
def get_active_drop(self, game: Game) -> Optional[TimedDrop]:
def get_active_drop(self) -> Optional[TimedDrop]:
if self.game is None:
return None
watching_channel = self.watching_channel.get_with_default(None)
drops = sorted(
(
drop
for campaign in self.inventory
if campaign.active and campaign.game == game
for drop in campaign.timed_drops.values()
if drop.can_earn
for campaign in self.inventory[self.game]
if campaign.active
for drop in campaign.drops
if drop.can_earn and (
watching_channel is None or watching_channel in campaign.allowed_channels
)
),
key=lambda d: d.remaining_minutes,
)
@@ -754,15 +784,17 @@ class Twitch:
return drops[0]
return None
async def get_live_streams(self, game: Game, tag_ids: List[str]) -> List[Channel]:
async def get_live_streams(self) -> List[Channel]:
if self.game is None:
return []
limit = 45
response = await self.gql_request(
GQL_OPERATIONS["GameDirectory"].with_variables({
"limit": limit,
"name": game.name,
"name": self.game.name,
"options": {
"includeRestricted": ["SUB_ONLY_LIVE"],
"tags": tag_ids,
"tags": [DROPS_ENABLED_TAG],
},
})
)