diff --git a/src/__main__.py b/src/__main__.py index 1e5ffc3..9dc410f 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -29,7 +29,9 @@ if __name__ == "__main__": logger = logging.getLogger("TwitchDrops") # Force INFO level logging by default for better visibility - logger.setLevel(logging.DEBUG) + logger.setLevel(logging.INFO) + if logger.level < logging.INFO: + logger.setLevel(logging.INFO) # Always add console handler console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(FILE_FORMATTER) diff --git a/src/core/client.py b/src/core/client.py index 1f6b0b2..7d1a503 100644 --- a/src/core/client.py +++ b/src/core/client.py @@ -98,6 +98,9 @@ class Twitch: 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 @@ -309,9 +312,9 @@ class Twitch: 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("fames_to_watch: %s", games_to_watch) + 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.info("inventories: %s", self.inventory) + logger.debug("inventories: %s", self.inventory) # Log detailed game -> campaigns -> channels mapping logger.info("=== Active Campaigns Mapping ===") @@ -368,6 +371,20 @@ class Twitch: 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) @@ -395,7 +412,7 @@ class Twitch: self._remove_channel_topics(to_remove_channels) for channel in to_remove_channels: del channels[channel.id] - channel.remove() + # 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) @@ -405,10 +422,9 @@ class Twitch: self.change_state(State.IDLE) elif self._state is State.CHANNELS_FETCH: self.gui.status.update(_("gui", "status", "gathering")) - # start with all current channels, clear the memory and GUI + # start with all current channels, keep them in memory for smooth update new_channels: set[Channel] = set(channels.values()) channels.clear() - self.gui.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 @@ -452,10 +468,11 @@ class Twitch: # just make sure to unsubscribe from their topics self._remove_channel_topics(to_remove_channels) del to_remove_channels - # set our new channel list + # set our new channel list and update GUI in one batch for channel in ordered_channels: channels[channel.id] = channel - channel.display(add=True) + # 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: @@ -470,17 +487,15 @@ class Twitch: ) ) self.websocket.add_topics(to_add_topics) - # relink watching channel after cleanup, - # or stop watching it if it no longer qualifies + # 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) - else: - # we've removed a channel we were watching - self.stop_watching() + # 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(): @@ -510,28 +525,54 @@ class Twitch: # Determine the best channel to watch new_watching: Channel | None = None selected_channel: Channel | None = self.gui.channels.get_selection() + watching_channel: Channel | None = self.watching_channel.get_with_default(None) + # Handle user selection if selected_channel is not None and self.can_watch(selected_channel): - # User-selected channel takes priority + # 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 + logger.info(f"Manual mode: switching to {channel.name} (same game: {self._manual_target_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: - # Auto-select best channel based on priority for channel in sorted(channels.values(), key=self.get_priority): if self.can_watch(channel) and self.should_switch(channel): new_watching = channel break - watching_channel: Channel | None = self.watching_channel.get_with_default(None) - 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 - self.gui.status.update( - _("status", "watching").format(channel=watching_channel.name) - ) + 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 @@ -689,7 +730,10 @@ class Twitch: self.gui.channels.set_watching(channel) self.watching_channel.set(channel) if update_status: - status_text: str = _("status", "watching").format(channel=channel.name) + if self.is_manual_mode() and self._manual_target_game: + status_text: str = f"🎯 Manual Mode: Watching {channel.name} for {self._manual_target_game.name}" + else: + status_text: str = _("status", "watching").format(channel=channel.name) self.print(status_text) self.gui.status.update(status_text) @@ -701,9 +745,69 @@ class Twitch: def restart_watching(self) -> None: """Restart the watch loop (forces immediate re-send of watch payload).""" - self.gui.progress.stop_timer() + # Don't stop the timer - this would clear the drop display on the frontend + # The timer will naturally update when the next drop progress arrives self._watching_restart.set() + 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: """Process websocket stream state updates (viewcount, stream-up, stream-down).""" @@ -1001,7 +1105,6 @@ class Twitch: campaigns.sort(key=lambda c: c.eligible, reverse=True) self._drops.clear() - self.gui.inv.clear() self.inventory.clear() self._mnt_triggers.clear() switch_triggers: set[datetime] = set() @@ -1015,6 +1118,8 @@ class Twitch: self._campaigns[campaign.id] = campaign # concurrently add the campaigns into the GUI # NOTE: this fetches pictures from the CDN, so might be slow without a cache + # Start batch mode to prevent individual emissions + self.gui.inv.start_batch() status_update( _("gui", "status", "adding_campaigns").format(counter=f"(0/{len(campaigns)})") ) @@ -1038,6 +1143,8 @@ class Twitch: for task in add_campaign_tasks: task.cancel() raise + # Finalize batch mode - emit all campaigns atomically + await self.gui.inv.finalize_batch() self._mnt_triggers.extend(sorted(switch_triggers)) # trim out all triggers that we're already past now = datetime.now(timezone.utc) diff --git a/src/models/game.py b/src/models/game.py index b304d21..8642deb 100644 --- a/src/models/game.py +++ b/src/models/game.py @@ -16,6 +16,8 @@ class Game: self.name: str = data.get("displayName") or data["name"] if "slug" in data: self.slug = data["slug"] + # Store box art URL if available (used for game icons in UI) + self.box_art_url: str | None = data.get("boxArtURL") def __str__(self) -> str: return self.name diff --git a/src/web/app.py b/src/web/app.py index 15dfb8e..cce248c 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -95,13 +95,14 @@ async def serve_index(): @app.get("/api/status") async def get_status(): """Get current application status""" - if not gui_manager: + if not gui_manager or not twitch_client: raise HTTPException(status_code=503, detail="GUI not initialized") return { "status": gui_manager.status.get(), "login": gui_manager.login.get_status(), - "close_requested": gui_manager.close_requested + "close_requested": gui_manager.close_requested, + "manual_mode": twitch_client.get_manual_mode_info() } @@ -119,10 +120,28 @@ async def get_channels(): @app.post("/api/channels/select") async def select_channel(request: ChannelSelectRequest): """Select a channel to watch""" - if not gui_manager: + if not gui_manager or not twitch_client: raise HTTPException(status_code=503, detail="GUI not initialized") + # Validate channel exists + channel = twitch_client.channels.get(request.channel_id) + if not channel: + raise HTTPException(status_code=404, detail="Channel not found") + + # Validate channel has a game + if not channel.game: + raise HTTPException(status_code=400, detail="Channel is not playing any game") + + # Warn if channel has no drops (shouldn't happen if GUI is filtering correctly) + if not any(campaign.can_earn(channel) for campaign in twitch_client.inventory): + logger.warning(f"User selected channel {channel.name} but it has no available drops") + gui_manager.select_channel(request.channel_id) + + # Trigger channel switch to apply the selection + from src.config import State + twitch_client.change_state(State.CHANNEL_SWITCH) + return {"success": True} @@ -214,6 +233,19 @@ async def trigger_close(): return {"success": True} +@app.post("/api/mode/exit-manual") +async def exit_manual_mode(): + """Exit manual mode and return to automatic channel selection""" + if not twitch_client: + raise HTTPException(status_code=503, detail="Twitch client not initialized") + + if not twitch_client.is_manual_mode(): + return {"success": False, "message": "Not in manual mode"} + + twitch_client.exit_manual_mode("User requested") + return {"success": True} + + # ==================== Socket.IO Events ==================== @sio.event @@ -222,14 +254,16 @@ async def connect(sid, environ): logger.info(f"Web client connected: {sid}") # Send initial state to new client - if gui_manager: + if gui_manager and twitch_client: await sio.emit("initial_state", { "status": gui_manager.status.get(), "channels": gui_manager.channels.get_channels(), "campaigns": gui_manager.inv.get_campaigns(), "console": gui_manager.output.get_history(), "settings": gui_manager.settings.get_settings(), - "login": gui_manager.login.get_status() + "login": gui_manager.login.get_status(), + "manual_mode": twitch_client.get_manual_mode_info(), + "current_drop": gui_manager.progress.get_current_drop() }, room=sid) diff --git a/src/web/gui_manager.py b/src/web/gui_manager.py index 1364b8b..8d95422 100644 --- a/src/web/gui_manager.py +++ b/src/web/gui_manager.py @@ -50,7 +50,7 @@ class WebGUIManager: self.websockets = WebsocketStatusManager(self._broadcaster) self.output = ConsoleOutputManager(self._broadcaster) self.progress = CampaignProgressManager(self._broadcaster) - self.channels = ChannelListManager(self._broadcaster) + self.channels = ChannelListManager(self._broadcaster, self) self.inv = InventoryManager(self._broadcaster, ImageCache(self)) self.login = LoginFormManager(self._broadcaster, self) self.tray = TrayIconStub(self._broadcaster) @@ -225,6 +225,16 @@ class WebGUIManager: self._broadcaster.emit("theme_change", {"dark_mode": dark_mode}) ) + def broadcast_manual_mode_change(self, manual_mode_info: dict): + """Broadcast manual mode status change to connected clients. + + Args: + manual_mode_info: Manual mode status from get_manual_mode_info() + """ + asyncio.create_task( + self._broadcaster.emit("manual_mode_update", manual_mode_info) + ) + # Type aliases for backwards compatibility with code that imports from gui LoginForm = LoginFormManager diff --git a/src/web/managers/campaigns.py b/src/web/managers/campaigns.py index 21902d6..258533c 100644 --- a/src/web/managers/campaigns.py +++ b/src/web/managers/campaigns.py @@ -37,6 +37,7 @@ class CampaignProgressManager: "drop_id": drop.id, "drop_name": drop.name, "campaign_name": drop.campaign.name, + "campaign_id": drop.campaign.id, "game_name": drop.campaign.game.name, "current_minutes": drop.current_minutes, "required_minutes": drop.required_minutes, @@ -59,3 +60,25 @@ class CampaignProgressManager: True if remaining seconds is at or below zero """ return self._remaining_seconds <= 0 + + def get_current_drop(self) -> dict | None: + """Get the current drop progress data for sending to newly connected clients. + + Returns: + Dictionary with drop progress data, or None if no active drop + """ + if self._current_drop is None: + return None + + drop = self._current_drop + return { + "drop_id": drop.id, + "drop_name": drop.name, + "campaign_name": drop.campaign.name, + "campaign_id": drop.campaign.id, + "game_name": drop.campaign.game.name, + "current_minutes": drop.current_minutes, + "required_minutes": drop.required_minutes, + "progress": drop.progress, + "remaining_seconds": self._remaining_seconds + } diff --git a/src/web/managers/channels.py b/src/web/managers/channels.py index fc4a1ae..1a8e297 100644 --- a/src/web/managers/channels.py +++ b/src/web/managers/channels.py @@ -18,11 +18,12 @@ class ChannelListManager: or when the watched channel switches. """ - def __init__(self, broadcaster: WebSocketBroadcaster): + def __init__(self, broadcaster: WebSocketBroadcaster, gui_manager=None): self._broadcaster = broadcaster self._channels: dict[int, dict[str, Any]] = {} self._watching_id: int | None = None self._selected_id: int | None = None + self._gui_manager = gui_manager def display(self, channel: Channel, *, add: bool = False): """Add or update a channel in the display list. @@ -35,6 +36,8 @@ class ChannelListManager: "id": channel.id, "name": channel.name, "game": channel.game.name if channel.game else None, + "game_id": channel.game.id if channel.game else None, + "game_icon": channel.game.box_art_url if channel.game else None, "viewers": channel.viewers, "online": channel.online, "drops_enabled": channel.drops_enabled, @@ -84,12 +87,63 @@ class ChannelListManager: ) def get_selection(self) -> Channel | None: - """Get user's channel selection (handled via webapp API). + """Get user's channel selection from web GUI. Returns: - None (selection is handled through the web API, not here) + The selected Channel if one exists, None otherwise """ - return None # Handled via webapp API + if self._gui_manager is None: + return None + + # Get the selected channel ID from the parent GUI manager + selected_id = self._gui_manager.get_selected_channel_id() + if selected_id is None: + return None + + # Get the Channel object from the Twitch client + from src.core.client import Twitch + if hasattr(self._gui_manager, '_twitch'): + twitch: Twitch = self._gui_manager._twitch + return twitch.channels.get(selected_id) + + return None + + def batch_update(self, channels: list[Channel]): + """Replace all channels atomically with a new list. + + This prevents UI flicker by updating all channels in one operation + instead of clearing and gradually re-adding them. + + Args: + channels: List of channels to display + """ + # Build new channels dict + new_channels = {} + channels_data = [] + + for channel in channels: + channel_data = { + "id": channel.id, + "name": channel.name, + "game": channel.game.name if channel.game else None, + "game_id": channel.game.id if channel.game else None, + "game_icon": channel.game.box_art_url if channel.game else None, + "viewers": channel.viewers, + "online": channel.online, + "drops_enabled": channel.drops_enabled, + "acl_based": channel.acl_based, + "watching": channel.id == self._watching_id + } + new_channels[channel.id] = channel_data + channels_data.append(channel_data) + + # Atomically replace all channels + self._channels = new_channels + + # Emit batch update event + asyncio.create_task( + self._broadcaster.emit("channels_batch_update", {"channels": channels_data}) + ) def get_channels(self) -> list[dict[str, Any]]: """Get all currently tracked channels. diff --git a/src/web/managers/inventory.py b/src/web/managers/inventory.py index 7b36c85..dbc094a 100644 --- a/src/web/managers/inventory.py +++ b/src/web/managers/inventory.py @@ -22,6 +22,7 @@ class InventoryManager: self._broadcaster = broadcaster self._cache = cache self._campaigns: dict[str, dict[str, Any]] = {} + self._batch_mode: bool = False def clear(self): """Clear all campaigns from inventory.""" @@ -59,6 +60,7 @@ class InventoryManager: "name": campaign.name, "game_name": campaign.game.name, "image_url": image_url, + "link_url": f"https://www.twitch.tv/drops/campaigns?dropID={campaign.id}", "starts_at": campaign.starts_at.isoformat(), "ends_at": campaign.ends_at.isoformat(), "linked": campaign.linked, @@ -71,7 +73,10 @@ class InventoryManager: } self._campaigns[campaign.id] = campaign_data - await self._broadcaster.emit("campaign_add", campaign_data) + + # Only emit immediately if not in batch mode + if not self._batch_mode: + await self._broadcaster.emit("campaign_add", campaign_data) def update_drop(self, drop: TimedDrop): """Update a specific drop's progress within its campaign. @@ -99,6 +104,25 @@ class InventoryManager: ) break + def start_batch(self): + """Start batch mode - prevents individual campaign_add emissions. + + Call this before adding multiple campaigns, then call finalize_batch() + when done to emit all campaigns at once. + """ + self._batch_mode = True + self._campaigns.clear() + + async def finalize_batch(self): + """Finalize batch mode and emit all campaigns atomically. + + This sends a single inventory_batch_update event with all campaigns, + preventing UI flicker from individual adds. + """ + self._batch_mode = False + campaigns_data = list(self._campaigns.values()) + await self._broadcaster.emit("inventory_batch_update", {"campaigns": campaigns_data}) + def get_campaigns(self) -> list[dict[str, Any]]: """Get all campaigns in inventory. diff --git a/web/index.html b/web/index.html index 0f1c057..e73fb18 100644 --- a/web/index.html +++ b/web/index.html @@ -13,6 +13,12 @@