Files
TwitchDropsMiner/src/core/client.py
2025-10-19 17:08:18 +11:00

727 lines
33 KiB
Python

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 = ["<directory>"]
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] != "<directory>"
else "directory"
)
logger.debug(
f" └─ Campaign: {campaign.name} [{status_info}] (ends: {ends_info})"
)
logger.debug(f" Channels: {channel_info}")
if channel_list[0] != "<directory>" and len(channel_list) <= 10:
logger.debug(f" └─ {', '.join(channel_list)}")
elif channel_list[0] != "<directory>":
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)