from __future__ import annotations import asyncio import logging from collections import OrderedDict, abc, deque from datetime import datetime, timedelta, timezone from functools import partial from time import time from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn import aiohttp from src.api import GQLClient, HTTPClient from src.auth import _AuthState from src.config import ( MAX_CHANNELS, ClientType, State, WebsocketTopic, ) from src.exceptions import ( ExitRequest, ReloadRequest, RequestException, ) from src.i18n import _ from src.models.campaign import DropsCampaign from src.models.channel import Channel from src.services.channel_service import ChannelService from src.services.inventory_service import InventoryService from src.services.maintenance import MaintenanceService from src.services.message_handlers import MessageHandlerService from src.services.watch_service import WatchService from src.utils import ( AwaitableValue, task_wrapper, ) from src.websocket import WebsocketPool if TYPE_CHECKING: from src.config import ClientInfo, GQLOperation, JsonType from src.config.settings import Settings from src.models.channel import Stream from src.models.drop import TimedDrop from src.models.game import Game from src.web.gui_manager import WebGUIManager logger = logging.getLogger("TwitchDrops") gql_logger = logging.getLogger("TwitchDrops.gql") class Twitch: def __init__(self, settings: Settings): self.settings: Settings = settings # State management self._state: State = State.IDLE self._state_change = asyncio.Event() self.wanted_games: list[Game] = [] self.inventory: list[DropsCampaign] = [] self._drops: dict[str, TimedDrop] = {} self._campaigns: dict[str, DropsCampaign] = {} self._mnt_triggers: deque[datetime] = deque() # Client type and auth self._client_type: ClientInfo = ClientType.ANDROID_APP self._auth_state: _AuthState = _AuthState(self) # GUI (will be set by main.py) self.gui: WebGUIManager = None # type: ignore[assignment] # API clients (will be initialized after GUI is set) self._http_client: HTTPClient | None = None self._gql_client: GQLClient | None = None # Storing and watching channels self.channels: OrderedDict[int, Channel] = OrderedDict() self.watching_channel: AwaitableValue[Channel] = AwaitableValue() self._watching_task: asyncio.Task[None] | None = None self._watching_restart = asyncio.Event() # Manual mode tracking self._manual_target_channel: Channel | None = None self._manual_target_game: Game | None = None # Websocket self.websocket = WebsocketPool(self) # Maintenance task self._mnt_task: asyncio.Task[None] | None = None # Services self._maintenance_service: MaintenanceService = MaintenanceService(self) self._channel_service: ChannelService = ChannelService(self) self._message_handler_service: MessageHandlerService = MessageHandlerService(self) self._inventory_service: InventoryService = InventoryService(self) self._watch_service: WatchService = WatchService(self) def _ensure_api_clients(self) -> None: """Ensure API clients are initialized (called after GUI is set).""" if self._http_client is None: self._http_client = HTTPClient(self.settings, self.gui, self, self._client_type) if self._gql_client is None: self._gql_client = GQLClient(self._http_client, self._auth_state, self._client_type) async def get_session(self): """ Get the HTTP session (for backward compatibility). Delegates to HTTPClient. """ self._ensure_api_clients() assert self._http_client is not None return await self._http_client.get_session() def request(self, method: str, url: str | Any, **kwargs): """ Make an HTTP request (for backward compatibility). Delegates to HTTPClient. """ self._ensure_api_clients() assert self._http_client is not None return self._http_client.request(method, url, **kwargs) async def shutdown(self) -> None: start_time = time() self.stop_watching() if self._watching_task is not None: self._watching_task.cancel() self._watching_task = None if self._mnt_task is not None: self._mnt_task.cancel() self._mnt_task = None # stop websocket and close HTTP session await self.websocket.stop(clear_topics=True) if self._http_client is not None: await self._http_client.close() self._drops.clear() self.channels.clear() self.inventory.clear() self._auth_state.clear() self.wanted_games.clear() self._mnt_triggers.clear() # wait at least half a second + whatever it takes to complete the closing # this allows aiohttp to safely close the session await asyncio.sleep(start_time + 0.5 - time()) def wait_until_login(self) -> abc.Coroutine[Any, Any, Literal[True]]: """Wait until the user is logged in.""" return self._auth_state._logged_in.wait() def change_state(self, state: State) -> None: """Change the current state of the miner.""" if self._state is not State.EXIT: # prevent state changing once we switch to exit state self._state = state self._state_change.set() def state_change(self, state: State) -> abc.Callable[[], None]: """Return a callable that changes state when invoked (deferred call for GUI usage).""" return partial(self.change_state, state) def close(self) -> None: """ Called when the application is requested to close by the user, usually by the console or application window being closed. """ self.change_state(State.EXIT) def print(self, message: str) -> None: """Print a message in the GUI.""" self.gui.print(message) def save(self, *, force: bool = False) -> None: """Save the application state (settings and GUI state).""" self.gui.save(force=force) self.settings.save(force=force) def get_priority(self, channel: Channel) -> int: """Delegate to ChannelService.""" return self._channel_service.get_priority(channel) @staticmethod def _viewers_key(channel: Channel) -> int: """Delegate to ChannelService.""" return ChannelService.get_viewers_key(channel) def _remove_channel_topics(self, channels: abc.Iterable[Channel]) -> None: """Remove websocket topics for a list of channels.""" topics_to_remove: list[str] = [] for channel in channels: topics_to_remove.append(WebsocketTopic.as_str("Channel", "StreamState", channel.id)) topics_to_remove.append(WebsocketTopic.as_str("Channel", "StreamUpdate", channel.id)) if topics_to_remove: self.websocket.remove_topics(topics_to_remove) async def run(self) -> None: """Main entry point for the miner - handles reload and exit requests.""" while True: try: await self._run() break except ReloadRequest: await self.shutdown() except ExitRequest: break except aiohttp.ContentTypeError as exc: raise RequestException(_("login", "unexpected_content")) from exc async def _run(self) -> None: """ Main method that runs the whole client. Here, we manage several things, specifically: • Fetching the drops inventory to make sure that everything we can claim, is claimed • Selecting a stream to watch, and watching it • Changing the stream that's being watched if necessary """ # Initialize API clients now that GUI is available self._ensure_api_clients() auth_state = await self.get_auth() await self.websocket.start() # NOTE: watch task is explicitly restarted on each new run if self._watching_task is not None: self._watching_task.cancel() self._watching_task = asyncio.create_task(self._watch_loop()) # Add default topics self.websocket.add_topics( [ WebsocketTopic("User", "Drops", auth_state.user_id, self.process_drops), WebsocketTopic( "User", "Notifications", auth_state.user_id, self.process_notifications ), ] ) full_cleanup: bool = False channels: Final[OrderedDict[int, Channel]] = self.channels self.change_state(State.INVENTORY_FETCH) while True: if self._state is State.IDLE: if self.settings.dump: self.close() continue self.gui.tray.change_icon("idle") self.gui.status.update(_("gui", "status", "idle")) self.stop_watching() # clear the flag and wait until it's set again self._state_change.clear() elif self._state is State.INVENTORY_FETCH: self.gui.tray.change_icon("maint") # ensure the websocket is running await self.websocket.start() await self.fetch_inventory() self.gui.set_games({campaign.game for campaign in self.inventory}) # Save state on every inventory fetch self.save() self.change_state(State.GAMES_UPDATE) elif self._state is State.GAMES_UPDATE: # claim drops from expired and active campaigns logger.info("Checking for claimable drops") logger.debug("Campaigns in inventory: %s", self.inventory) for campaign in self.inventory: if not campaign.upcoming: for drop in campaign.drops: if drop.can_claim: await drop.claim() # figure out which games we want based on games_to_watch whitelist self.wanted_games.clear() games_to_watch: list[str] = self.settings.games_to_watch next_hour: datetime = datetime.now(timezone.utc) + timedelta(hours=1) logger.info("games_to_watch: %s", games_to_watch) logger.info( "inventory has %d eligible campaigns", sum(1 for c in self.inventory if c.eligible), ) logger.debug("inventories: %s", self.inventory) # Log detailed game -> campaigns -> channels mapping if logger.isEnabledFor(logging.DEBUG): logger.info("=== Active Campaigns Mapping ===") from collections import defaultdict game_campaign_map: dict[str, list[tuple[DropsCampaign, list[str]]]] = ( defaultdict(list) ) for campaign in self.inventory: if campaign.eligible and not campaign.finished: logger.info( "eligible Campaign: %s - %s", campaign.name, campaign.game.name ) if campaign.can_earn_within(next_hour): channel_names = [] if campaign.allowed_channels: channel_names = [ch.name for ch in campaign.allowed_channels] else: channel_names = [""] game_campaign_map[campaign.game.name].append((campaign, channel_names)) for game_name in sorted(game_campaign_map.keys()): logger.debug(f"Game: {game_name}") for campaign, channel_list in game_campaign_map[game_name]: status_info = f"{'ACTIVE' if campaign.active else 'UPCOMING'}" ends_info = campaign.ends_at.astimezone().strftime("%Y-%m-%d %H:%M") channel_info = ( f"{len(channel_list)} channels" if channel_list[0] != "" else "directory" ) logger.debug( f" └─ Campaign: {campaign.name} [{status_info}] (ends: {ends_info})" ) logger.debug(f" Channels: {channel_info}") if channel_list[0] != "" and len(channel_list) <= 10: logger.debug(f" └─ {', '.join(channel_list)}") elif channel_list[0] != "": logger.debug( f" └─ {', '.join(channel_list[:10])} ... (+{len(channel_list) - 10} more)" ) logger.info("=== End Campaigns Mapping ===") # Build wanted_games list preserving the order from games_to_watch for game_name in games_to_watch: # Find campaigns for this game (case-insensitive matching) game_name_lower: str = game_name.lower() for campaign in self.inventory: game: Game = campaign.game if ( game.name.lower() == game_name_lower and game not in self.wanted_games # isn't already there and campaign.can_earn_within( next_hour ) # can be progressed within the next hour ): self.wanted_games.append(game) break # Only add each game once if self.wanted_games: logger.info( "Wanted games: %s", ", ".join(game.name for game in self.wanted_games) ) else: logger.warning( "No wanted games found! games_to_watch=%s, eligible_campaigns=%d", games_to_watch, sum( 1 for c in self.inventory if c.eligible and c.can_earn_within(next_hour) ), ) # Handle manual mode: check if manual game still has drops if self.is_manual_mode(): manual_has_drops = any( campaign.can_earn_within(next_hour) and campaign.game == self._manual_target_game for campaign in self.inventory ) if not manual_has_drops: self.exit_manual_mode("All drops completed for manual game") elif self._manual_target_game in self.wanted_games: # Move manual game to front of wanted_games for priority self.wanted_games.remove(self._manual_target_game) self.wanted_games.insert(0, self._manual_target_game) logger.info( f"Manual mode: prioritizing game {self._manual_target_game.name}" ) full_cleanup = True self.restart_watching() self.change_state(State.CHANNELS_CLEANUP) elif self._state is State.CHANNELS_CLEANUP: self.gui.status.update(_("gui", "status", "cleanup")) if not self.wanted_games or full_cleanup: # no games selected or we're doing full cleanup: remove everything to_remove_channels: list[Channel] = list(channels.values()) else: # remove all channels that: to_remove_channels = [ channel for channel in channels.values() if ( not channel.acl_based # aren't ACL-based and ( channel.offline # and are offline # or online but aren't streaming the game we want anymore or (channel.game is None or channel.game not in self.wanted_games) ) ) ] full_cleanup = False if to_remove_channels: self._remove_channel_topics(to_remove_channels) for channel in to_remove_channels: del channels[channel.id] # Don't remove from GUI - batch_update in CHANNELS_FETCH will handle it atomically del to_remove_channels if self.wanted_games: self.change_state(State.CHANNELS_FETCH) else: # with no games available, we switch to IDLE after cleanup self.print(_("status", "no_campaign")) self.change_state(State.IDLE) elif self._state is State.CHANNELS_FETCH: self.gui.status.update(_("gui", "status", "gathering")) # start with all current channels, keep them in memory for smooth update new_channels: set[Channel] = set(channels.values()) channels.clear() # gather and add ACL channels from campaigns # NOTE: we consider only campaigns that can be progressed # NOTE: we use another set so that we can set them online separately no_acl: set[Game] = set() acl_channels: set[Channel] = set() next_hour = datetime.now(timezone.utc) + timedelta(hours=1) for campaign in self.inventory: if campaign.game in self.wanted_games and campaign.can_earn_within(next_hour): if campaign.allowed_channels: acl_channels.update(campaign.allowed_channels) else: no_acl.add(campaign.game) # remove all ACL channels that already exist from the other set acl_channels.difference_update(new_channels) # use the other set to set them online if possible await self.bulk_check_online(acl_channels) # finally, add them as new channels new_channels.update(acl_channels) for game in no_acl: # for every campaign without an ACL, for it's game, # add a list of live channels with drops enabled new_channels.update(await self.get_live_streams(game, drops_enabled=True)) # sort them descending by viewers, by priority and by game priority # NOTE: Viewers sort also ensures ONLINE channels are sorted to the top # NOTE: We can drop using the set now, because there's no more channels being added ordered_channels: list[Channel] = sorted( new_channels, key=self._viewers_key, reverse=True ) ordered_channels.sort(key=lambda ch: ch.acl_based, reverse=True) ordered_channels.sort(key=self.get_priority) # ensure that we won't end up with more channels than we can handle # NOTE: we trim from the end because that's where the non-priority, # offline (or online but low viewers) channels end up to_remove_channels = ordered_channels[MAX_CHANNELS:] ordered_channels = ordered_channels[:MAX_CHANNELS] if to_remove_channels: # tracked channels and gui were cleared earlier, so no need to do it here # just make sure to unsubscribe from their topics self._remove_channel_topics(to_remove_channels) del to_remove_channels # set our new channel list and update GUI in one batch for channel in ordered_channels: channels[channel.id] = channel # Batch update GUI - prevents flickering from individual adds self.gui.channels.batch_update(ordered_channels) # subscribe to these channel's state updates to_add_topics: list[WebsocketTopic] = [] for channel_id in channels: to_add_topics.append( WebsocketTopic( "Channel", "StreamState", channel_id, self.process_stream_state ) ) to_add_topics.append( WebsocketTopic( "Channel", "StreamUpdate", channel_id, self.process_stream_update ) ) self.websocket.add_topics(to_add_topics) # relink watching channel after cleanup # NOTE: this replaces 'self.watching_channel's internal value with the new object # Don't call stop_watching() here - let CHANNEL_SWITCH handle it to avoid clearing drop display watching_channel = self.watching_channel.get_with_default(None) if watching_channel is not None: new_watching: Channel | None = channels.get(watching_channel.id) if new_watching is not None and self.can_watch(new_watching): self.watch(new_watching, update_status=False) # If channel not found, CHANNEL_SWITCH will handle selecting a new one del new_watching # pre-display the active drop 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_campaign := self.get_active_campaign(channel)) is not None and ( active_drop := active_campaign.first_drop ) is not None: active_drop.display(countdown=False, subone=True) break self.change_state(State.CHANNEL_SWITCH) del ( no_acl, acl_channels, new_channels, to_add_topics, ordered_channels, watching_channel, ) elif self._state is State.CHANNEL_SWITCH: if self.settings.dump: self.close() continue self.gui.status.update(_("gui", "status", "switching")) # Determine the best channel to watch new_watching: Channel | None = None # type: ignore[no-redef] selected_channel: Channel | None = self.gui.channels.get_selection() watching_channel: Channel | None = self.watching_channel.get_with_default(None) # type: ignore[no-redef] # Handle user selection if selected_channel is not None and self.can_watch(selected_channel): # Check if this is a game change -> enter manual mode if watching_channel and selected_channel.game != watching_channel.game: self.enter_manual_mode(selected_channel) new_watching = selected_channel # Handle manual mode elif self.is_manual_mode(): # Try to stay on manual target channel if self._manual_target_channel and self.can_watch(self._manual_target_channel): new_watching = self._manual_target_channel else: # Manual channel offline, find another channel for same game for channel in channels.values(): if channel.game == self._manual_target_game and self.can_watch(channel): new_watching = channel self._manual_target_channel = channel game_name = ( self._manual_target_game.name if self._manual_target_game else "Unknown" ) logger.info( f"Manual mode: switching to {channel.name} (same game: {game_name})" ) break # No channels available for manual game -> exit manual mode if new_watching is None: self.exit_manual_mode("No channels available for manual game") # Auto-select best channel based on priority else: for channel in sorted(channels.values(), key=self.get_priority): if self.can_watch(channel) and self.should_switch(channel): new_watching = channel break if new_watching is not None: # Switch to new channel self.watch(new_watching) # Display the active drop for the new channel if (active_campaign := self.get_active_campaign(new_watching)) is not None and ( active_drop := active_campaign.first_drop ) is not None: active_drop.display(countdown=False, subone=True) self._state_change.clear() elif watching_channel is not None and self.can_watch(watching_channel): # Continue watching current channel if self.is_manual_mode() and self._manual_target_game: status_text = f"🎯 Manual Mode: Watching {watching_channel.name} for {self._manual_target_game.name}" else: status_text = _("status", "watching").format(channel=watching_channel.name) self.gui.status.update(status_text) self._state_change.clear() else: # No channels available to watch self.print(_("status", "no_channel")) self.change_state(State.IDLE) elif self._state is State.EXIT: self.gui.tray.change_icon("pickaxe") self.gui.status.update(_("gui", "status", "exiting")) # we've been requested to exit the application break await self._state_change.wait() async def _watch_sleep(self, delay: float) -> None: """Delegate to WatchService.""" await self._watch_service.watch_sleep(delay) @task_wrapper(critical=True) async def _watch_loop(self) -> NoReturn: """Delegate to WatchService.""" await self._watch_service.watch_loop() # type: ignore[misc] @task_wrapper(critical=True) async def _maintenance_task(self) -> None: """Delegate to MaintenanceService.""" await self._maintenance_service.run_maintenance_task() def can_watch(self, channel: Channel) -> bool: """Delegate to WatchService.""" return self._watch_service.can_watch(channel) def should_switch(self, channel: Channel) -> bool: """Delegate to WatchService.""" return self._watch_service.should_switch(channel) def watch(self, channel: Channel, *, update_status: bool = True) -> None: """Delegate to WatchService.""" self._watch_service.watch(channel, update_status=update_status) def stop_watching(self) -> None: """Delegate to WatchService.""" self._watch_service.stop_watching() def restart_watching(self) -> None: """Delegate to WatchService.""" self._watch_service.restart_watching() def is_manual_mode(self) -> bool: """Check if manual mode is currently active.""" return self._manual_target_channel is not None and self._manual_target_game is not None def enter_manual_mode(self, channel: Channel) -> None: """ Enter manual mode for the given channel's game. Args: channel: The channel that was manually selected by the user """ if channel.game is None: logger.warning(f"Cannot enter manual mode: channel {channel.name} has no game") return self._manual_target_channel = channel self._manual_target_game = channel.game logger.info(f"Entered manual mode for game: {channel.game.name}, channel: {channel.name}") # Broadcast manual mode change to GUI self.gui.broadcast_manual_mode_change(self.get_manual_mode_info()) def exit_manual_mode(self, reason: str = "") -> None: """ Exit manual mode and return to automatic channel selection. Args: reason: Optional reason for exiting manual mode (for logging) """ if not self.is_manual_mode(): return game_name = self._manual_target_game.name if self._manual_target_game else "Unknown" logger.info( f"Exiting manual mode for game: {game_name}. Reason: {reason or 'User requested'}" ) self._manual_target_channel = None self._manual_target_game = None # Broadcast manual mode change to GUI self.gui.broadcast_manual_mode_change(self.get_manual_mode_info()) # Trigger channel switch to select new channel automatically self.change_state(State.CHANNEL_SWITCH) def get_manual_mode_info(self) -> dict[str, Any]: """ Get current manual mode status information. Returns: Dictionary with manual mode status including active state and game name """ if self.is_manual_mode(): return { "active": True, "game_name": self._manual_target_game.name if self._manual_target_game else "", "channel_name": self._manual_target_channel.name if self._manual_target_channel else "", } return {"active": False} @task_wrapper async def process_stream_state(self, channel_id: int, message: JsonType) -> None: """Delegate to MessageHandlerService.""" await self._message_handler_service.process_stream_state(channel_id, message) @task_wrapper async def process_stream_update(self, channel_id: int, message: JsonType) -> None: """Delegate to MessageHandlerService.""" await self._message_handler_service.process_stream_update(channel_id, message) def on_channel_update( self, channel: Channel, stream_before: Stream | None, stream_after: Stream | None ) -> None: """Delegate to MessageHandlerService.""" self._message_handler_service.on_channel_update(channel, stream_before, stream_after) @task_wrapper async def process_drops(self, user_id: int, message: JsonType) -> None: """Delegate to MessageHandlerService.""" await self._message_handler_service.process_drops(user_id, message) @task_wrapper async def process_notifications(self, user_id: int, message: JsonType) -> None: """Delegate to MessageHandlerService.""" await self._message_handler_service.process_notifications(user_id, message) async def get_auth(self) -> _AuthState: """Get authentication state (validates token if needed).""" await self._auth_state.validate() return self._auth_state async def gql_request( self, ops: GQLOperation | list[GQLOperation] ) -> JsonType | list[JsonType]: """ Execute GraphQL request(s). Delegates to GQLClient for execution. """ self._ensure_api_clients() assert self._gql_client is not None return await self._gql_client.request(ops) async def fetch_campaigns( self, campaigns_chunk: list[tuple[str, JsonType]] ) -> dict[str, JsonType]: """Delegate to InventoryService.""" return await self._inventory_service.fetch_campaigns(campaigns_chunk) async def fetch_inventory(self) -> None: """Delegate to InventoryService.""" await self._inventory_service.fetch_inventory() def get_active_campaign(self, channel: Channel | None = None) -> DropsCampaign | None: """Delegate to InventoryService.""" return self._inventory_service.get_active_campaign(channel) async def get_live_streams( self, game: Game, *, limit: int = 20, drops_enabled: bool = True ) -> list[Channel]: """Delegate to ChannelService.""" return await self._channel_service.get_live_streams( game, limit=limit, drops_enabled=drops_enabled ) async def bulk_check_online(self, channels: abc.Iterable[Channel]): """Delegate to ChannelService.""" await self._channel_service.bulk_check_online(channels)