unify game selection and expectation logic (#32)

- unify game selection and expectation logic
- refactore settings and settings manager
- formatting and coverage and precommit hook
This commit is contained in:
Fengqing Liu
2026-01-19 00:39:58 +11:00
committed by GitHub
parent 8bf69abfda
commit 9727a9b8d2
27 changed files with 585 additions and 641 deletions

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import argparse
import asyncio
import logging
import signal
@@ -16,7 +15,7 @@ import truststore
if __name__ == "__main__":
truststore.inject_into_ssl()
from src.config import FILE_FORMATTER, LOGGING_LEVELS
from src.config import FILE_FORMATTER
from src.config.settings import Settings
from src.core.client import Twitch
from src.exceptions import CaptchaRequired
@@ -45,58 +44,9 @@ if __name__ == "__main__":
warnings.simplefilter("default", ResourceWarning)
class ParsedArgs(argparse.Namespace):
_verbose: int
_debug_ws: bool
_debug_gql: bool
log: bool
dump: bool
# TODO: replace int with union of literal values once typeshed updates
@property
def logging_level(self) -> int:
return LOGGING_LEVELS[min(self._verbose, 4)]
@property
def debug_ws(self) -> int:
"""
If the debug flag is True, return DEBUG.
If the main logging level is DEBUG, return INFO to avoid seeing raw messages.
Otherwise, return NOTSET to inherit the global logging level.
"""
if self._debug_ws:
return logging.DEBUG
elif self._verbose >= 4:
return logging.INFO
return logging.NOTSET
@property
def debug_gql(self) -> int:
if self._debug_gql:
return logging.DEBUG
elif self._verbose >= 4:
return logging.INFO
return logging.NOTSET
# handle input parameters
logger.debug("Parsing command line arguments")
parser = argparse.ArgumentParser(
description="A program that allows you to mine timed drops on Twitch.",
)
parser.add_argument("--version", action="version", version=f"v{__version__}")
parser.add_argument("-v", dest="_verbose", action="count", default=0)
parser.add_argument("--dump", action="store_true")
# undocumented debug args
parser.add_argument("--debug-ws", dest="_debug_ws", action="store_true", help=argparse.SUPPRESS)
parser.add_argument(
"--debug-gql", dest="_debug_gql", action="store_true", help=argparse.SUPPRESS
)
logger.debug("Parsing arguments into ParsedArgs namespace")
args = parser.parse_args(namespace=ParsedArgs())
# load settings
logger.debug("Loading settings")
try:
settings = Settings(args)
settings = Settings()
except Exception:
logger.exception("Error while loading settings")
print(f"Settings error: {traceback.format_exc()}", file=sys.stderr)
@@ -114,7 +64,9 @@ if __name__ == "__main__":
logger.info(f"Platform: {sys.platform}")
logger.info(f"Proxy: {settings.proxy}")
logger.info(f"Language: {settings.language}")
logger.info(f"Minimum refresh interval: {settings.minimum_refresh_interval_minutes} minutes")
logger.info(
f"Minimum refresh interval: {settings.minimum_refresh_interval_minutes} minutes"
)
exit_status = 0
client = Twitch(settings)
@@ -197,7 +149,7 @@ if __name__ == "__main__":
logger.info("Normal shutdown - proceeding")
# save the application state
logger.info("Saving application state")
client.save(force=True)
settings.save()
logger.info("Application state saved")
logger.info(f"=== Exiting with status code: {exit_status} ===")
sys.exit(exit_status)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, TypedDict
from dataclasses import dataclass
from typing import TypedDict
from yarl import URL
@@ -8,114 +9,69 @@ from src.config import DEFAULT_LANG, SETTINGS_PATH
from src.utils import json_load, json_save
if TYPE_CHECKING:
from typing import Any as ParsedArgs # Avoid circular import
class InventoryFilters(TypedDict):
game_name_search: list[str]
show_active: bool
show_not_linked: bool
show_upcoming: bool
show_expired: bool
show_finished: bool
show_benefit_item: bool
show_benefit_badge: bool
show_benefit_emote: bool
show_benefit_item: bool
show_benefit_other: bool
game_name_search: list[str]
show_expired: bool
show_finished: bool
show_not_linked: bool
show_upcoming: bool
class SettingsFile(TypedDict):
proxy: URL
language: str
dark_mode: bool
games_to_watch: list[str]
connection_quality: int
minimum_refresh_interval_minutes: int
inventory_filters: InventoryFilters
mining_benefits: dict[str, bool]
default_settings: SettingsFile = {
"proxy": URL(),
"games_to_watch": [],
"dark_mode": False,
default_settings = {
"connection_quality": 1,
"dark_mode": False,
"games_to_watch": [],
"language": DEFAULT_LANG,
"minimum_refresh_interval_minutes": 30,
"inventory_filters": {
"game_name_search": [],
"show_active": False,
"show_not_linked": True,
"show_upcoming": True,
"show_expired": False,
"show_finished": False,
"show_benefit_item": True,
"show_benefit_badge": True,
"show_benefit_emote": True,
"show_benefit_item": True,
"show_benefit_other": True,
"game_name_search": [],
"show_expired": False,
"show_finished": False,
"show_not_linked": True,
"show_upcoming": True,
},
"minimum_refresh_interval_minutes": 30,
"mining_benefits": {
"DIRECT_ENTITLEMENT": True,
"BADGE": True,
"DIRECT_ENTITLEMENT": True,
"EMOTE": True,
"UNKNOWN": True,
},
"proxy": "",
}
@dataclass
class Settings:
# from args
log: bool
dump: bool
# args properties
debug_ws: int
debug_gql: int
logging_level: int
# from settings file
proxy: URL
language: str
connection_quality: int
dark_mode: bool
games_to_watch: list[str]
connection_quality: int
minimum_refresh_interval_minutes: int
language: str
inventory_filters: InventoryFilters
minimum_refresh_interval_minutes: int
mining_benefits: dict[str, bool]
proxy: str
PASSTHROUGH = ("_settings", "_args", "_altered")
def __init__(self):
self.load()
def __init__(self, args: ParsedArgs):
self._settings: SettingsFile = json_load(SETTINGS_PATH, default_settings)
self._args: ParsedArgs = args
self._altered: bool = False
def load(self):
# TODO: remvoe customized serde in the future
settings = json_load(SETTINGS_PATH, default_settings, merge=True)
for key, value in settings.items():
if value is URL:
setattr(self, key, str(value))
else:
setattr(self, key, value)
# default logic of reading settings is to check args first, then the settings file
def __getattr__(self, name: str, /) -> Any:
if name in self.PASSTHROUGH:
# passthrough
return getattr(super(), name)
elif hasattr(self._args, name):
return getattr(self._args, name)
elif name in self._settings:
return self._settings[name] # type: ignore[literal-required]
return getattr(super(), name)
def __setattr__(self, name: str, value: Any, /) -> None:
if name in self.PASSTHROUGH:
# passthrough
return super().__setattr__(name, value)
elif name in self._settings:
self._settings[name] = value # type: ignore[literal-required]
self._altered = True
return
raise TypeError(f"{name} is missing a custom setter")
def __delattr__(self, name: str, /) -> None:
raise RuntimeError("settings can't be deleted")
def alter(self) -> None:
self._altered = True
def save(self, *, force: bool = False) -> None:
if self._altered or force:
json_save(SETTINGS_PATH, self._settings, sort=True)
def save(self) -> None:
json_save(SETTINGS_PATH, vars(self), sort=True)

View File

@@ -29,6 +29,7 @@ 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.stream_selector import StreamSelector
from src.services.watch_service import WatchService
from src.utils import (
AwaitableValue,
@@ -86,6 +87,7 @@ class Twitch:
self._message_handler_service: MessageHandlerService = MessageHandlerService(self)
self._inventory_service: InventoryService = InventoryService(self)
self._watch_service: WatchService = WatchService(self)
self._stream_selector: StreamSelector = StreamSelector()
def _ensure_api_clients(self) -> None:
"""Ensure API clients are initialized (called after GUI is set)."""
@@ -148,7 +150,7 @@ class Twitch:
self._state = state
self._state_change.set()
def state_change(self, state: State) -> abc.Callable[[], None]:
def get_change_state_callable(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)
@@ -163,11 +165,6 @@ class Twitch:
"""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 _remove_channel_topics(self, channels: abc.Iterable[Channel]) -> None:
"""Remove websocket topics for a list of channels."""
topics_to_remove: list[str] = []
@@ -224,9 +221,6 @@ class Twitch:
self.change_state(State.INVENTORY_FETCH)
while True:
if self._state is State.IDLE:
if self.settings.dump:
self.close()
continue
self.gui.status.update(_.t["gui"]["status"]["idle"])
self.stop_watching()
# clear the flag and wait until it's set again
@@ -239,7 +233,6 @@ class Twitch:
# Broadcast unwanted items (based on settings)
self.gui.broadcast_wanted_items()
# 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
@@ -263,48 +256,14 @@ class Twitch:
# 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 ===")
self._output_campaign_mapping(next_hour)
logger.info("Building wanted games list")
# Build wanted_games list preserving the order from games_to_watch
self.wanted_games = self._filter_wanted_campaigns(next_hour)
self.wanted_games = self._stream_selector.get_wanted_games(
self.settings, self.inventory
)
logger.info("Wanted games list built")
if self.wanted_games:
logger.info(
@@ -470,9 +429,6 @@ class Twitch:
watching_channel,
)
elif self._state is State.CHANNEL_SWITCH:
if self.settings.dump:
self.close()
continue
self.gui.status.update(_.t["gui"]["status"]["switching"])
# Determine the best channel to watch
@@ -698,5 +654,40 @@ class Twitch:
and campaign.has_wanted_unclaimed_benefits(mining_benefits)
):
wanted_games.append(game)
break
break
return wanted_games
def _output_campaign_mapping(self, next_hour: datetime) -> None:
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 ===")

View File

@@ -63,7 +63,9 @@ class WebsocketClosed(RequestException):
self.raw_message: str = raw_message
def __str__(self):
return f"Websocket has been closed. received: {self.received}, raw_message: {self.raw_message}"
return (
f"Websocket has been closed. received: {self.received}, raw_message: {self.raw_message}"
)
class LoginException(RequestException):

View File

@@ -8,9 +8,9 @@ from typing import TYPE_CHECKING
from dateutil.parser import isoparse
from src.config.constants import State, URLType
from src.config.constants import State
from src.models.channel import Channel
from src.models.drop import TimedDrop, remove_dimensions
from src.models.drop import TimedDrop
from src.models.game import Game
@@ -201,6 +201,4 @@ class DropsCampaign:
first_drop.display()
def has_wanted_unclaimed_benefits(self, allowed_benefits: dict[str, bool]) -> bool:
return any(
drop.has_wanted_unclaimed_benefits(allowed_benefits) for drop in self.drops
)
return any(drop.has_wanted_unclaimed_benefits(allowed_benefits) for drop in self.drops)

View File

@@ -139,9 +139,12 @@ class BaseDrop:
return delim.join(benefit.name for benefit in self.benefits)
def has_wanted_unclaimed_benefits(self, allowed_benefits: dict[str, bool]) -> bool:
return len(self.get_wanted_unclaimed_benefits(allowed_benefits)) > 0
def get_wanted_unclaimed_benefits(self, allowed_benefits: dict[str, bool]) -> list[str]:
if self.is_claimed:
return False
return any(benefit.is_wanted(allowed_benefits) for benefit in self.benefits)
return []
return [benefit.name for benefit in self.benefits if benefit.is_wanted(allowed_benefits)]
async def claim(self) -> bool:
result = await self._claim()

View File

@@ -0,0 +1,78 @@
from datetime import datetime, timedelta, timezone
from src.config.settings import Settings
from src.models.campaign import DropsCampaign
from src.models.game import Game
class StreamSelector:
def _get_wanted_game_tree(
self, settings: Settings, campaigns: list[DropsCampaign]
) -> list[dict]:
"""
Get the hierarchical tree of wanted items (Games -> Campaigns -> Drops -> Benefits).
Ignoring 'can earn within' time constraint.
"""
wanted_games = []
games_to_watch = settings.games_to_watch
mining_benefits = settings.mining_benefits
next_hour = datetime.now(timezone.utc) + timedelta(hours=1)
for game_name in games_to_watch:
wanted_campaigns = []
game_obj = None
game_name_lower = game_name.lower()
# Find all campaigns for this game
for campaign in campaigns:
if campaign.game.name.lower() != game_name_lower:
continue
if game_obj is None:
game_obj = campaign.game
if not campaign.can_earn_within(next_hour):
continue
wanted_drops = []
for drop in campaign.drops:
if drop.is_claimed:
continue
filtered_benefits = drop.get_wanted_unclaimed_benefits(mining_benefits)
if len(filtered_benefits) > 0:
wanted_drops.append({"name": drop.name, "benefits": filtered_benefits})
if len(wanted_drops) > 0:
wanted_campaigns.append(
{
"id": campaign.id,
"name": campaign.name,
"url": campaign.campaign_url,
"drops": wanted_drops,
}
)
if len(wanted_campaigns) > 0:
wanted_games.append(
{
"game_id": game_obj.id if game_obj else None,
"game_name": game_name,
"game_icon": game_obj.box_art_url if game_obj else None,
"game_obj": game_obj,
"campaigns": wanted_campaigns,
}
)
return wanted_games
def get_wanted_game_tree(
self, settings: Settings, campaigns: list[DropsCampaign]
) -> list[dict]:
return [
{**game, "game_obj": None} for game in self._get_wanted_game_tree(settings, campaigns)
]
def get_wanted_games(self, settings: Settings, campaigns: list[DropsCampaign]) -> list[Game]:
return [game["game_obj"] for game in self._get_wanted_game_tree(settings, campaigns)]

View File

@@ -212,9 +212,10 @@ async def update_settings(settings: SettingsUpdate):
@app.post("/api/settings/verify-proxy")
async def verify_proxy(request: ProxyVerifyRequest):
"""Verify proxy connectivity"""
import aiohttp
import time
import aiohttp
proxy_url = request.proxy.strip()
if not proxy_url:
return {"success": False, "message": "Proxy URL is empty"}
@@ -222,23 +223,23 @@ async def verify_proxy(request: ProxyVerifyRequest):
try:
start_time = time.time()
# Test connection to Twitch
async with aiohttp.ClientSession() as session:
async with session.get(
"https://www.twitch.tv", proxy=proxy_url, timeout=10
) as response:
# Just checking if we can connect and get a response
if response.status < 500:
latency = round((time.time() - start_time) * 1000)
return {
"success": True,
"message": f"Connected! ({latency}ms)",
"latency": latency,
}
else:
return {
"success": False,
"message": f"Proxy reachable but returned {response.status}",
}
async with (
aiohttp.ClientSession() as session,
session.get("https://www.twitch.tv", proxy=proxy_url, timeout=10) as response,
):
# Just checking if we can connect and get a response
if response.status < 500:
latency = round((time.time() - start_time) * 1000)
return {
"success": True,
"message": f"Connected! ({latency}ms)",
"latency": latency,
}
else:
return {
"success": False,
"message": f"Proxy reachable but returned {response.status}",
}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
@@ -246,9 +247,10 @@ async def verify_proxy(request: ProxyVerifyRequest):
@app.get("/api/version")
async def get_version():
"""Get current application version and check for updates"""
from src.version import __version__
import aiohttp
from src.version import __version__
current_version = __version__
latest_version = None
update_available = False
@@ -256,19 +258,20 @@ async def get_version():
try:
# Check GitHub API for latest release
async with aiohttp.ClientSession() as session:
async with session.get(
"https://api.github.com/repos/rangermix/TwitchDropsMiner/releases/latest",
timeout=5
) as response:
if response.status == 200:
data = await response.json()
latest_version = data.get('tag_name', '').lstrip('v')
download_url = data.get('html_url')
async with (
aiohttp.ClientSession() as session,
session.get(
"https://api.github.com/repos/rangermix/TwitchDropsMiner/releases/latest", timeout=5
) as response,
):
if response.status == 200:
data = await response.json()
latest_version = data.get("tag_name", "").lstrip("v")
download_url = data.get("html_url")
# Compare versions (simple string comparison works for semantic versioning)
if latest_version and latest_version > current_version:
update_available = True
# Compare versions (simple string comparison works for semantic versioning)
if latest_version and latest_version > current_version:
update_available = True
except Exception as e:
logger.warning(f"Failed to check for updates: {str(e)}")
@@ -276,7 +279,7 @@ async def get_version():
"current_version": current_version,
"latest_version": latest_version,
"update_available": update_available,
"download_url": download_url or "https://github.com/rangermix/TwitchDropsMiner/releases"
"download_url": download_url or "https://github.com/rangermix/TwitchDropsMiner/releases",
}
@@ -357,7 +360,7 @@ async def connect(sid, environ):
"login": gui_manager.login.get_status(),
"manual_mode": twitch_client.get_manual_mode_info(),
"current_drop": gui_manager.progress.get_current_drop(),
"wanted_items": gui_manager.get_wanted_tree(),
"wanted_items": gui_manager.get_wanted_game_tree(),
},
room=sid,
)
@@ -389,11 +392,7 @@ async def request_reload(sid):
async def get_wanted_items(sid):
"""Client requested wanted items list"""
if gui_manager:
await sio.emit(
"wanted_items_update",
gui_manager.get_wanted_tree(),
to=sid
)
await sio.emit("wanted_items_update", gui_manager.get_wanted_game_tree(), to=sid)
# Mount static files (CSS, JS, images)

View File

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from src.config import State
from src.models.game import Game
from src.services.stream_selector import StreamSelector
from src.web.managers.broadcaster import WebSocketBroadcaster
from src.web.managers.cache import ImageCache
from src.web.managers.campaigns import CampaignProgressManager
@@ -55,18 +56,16 @@ class WebGUIManager:
self.login = LoginFormManager(self._broadcaster, self)
self.inv = InventoryManager(self._broadcaster, ImageCache(self))
self.login = LoginFormManager(self._broadcaster, self)
# Callback to trigger game update when relevant settings change
on_settings_change = self._twitch.state_change(State.GAMES_UPDATE)
on_settings_change = self._twitch.get_change_state_callable(State.GAMES_UPDATE)
self.settings = SettingsManager(
self._broadcaster,
twitch.settings,
self.output,
on_change=on_settings_change
self._broadcaster, twitch.settings, self.output, on_change=on_settings_change
)
# Selected channel tracking (set by web client)
self._selected_channel_id: int | None = None
self._stream_selector = StreamSelector()
# Start message
logger.info("Web GUI Manager initialized")
@@ -82,14 +81,6 @@ class WebGUIManager:
"""
self._broadcaster.set_socketio(sio)
def save(self, *, force: bool = False):
"""Save GUI state and settings.
Args:
force: Force save even if no changes detected
"""
self._twitch.settings.save(force=force)
def print(self, message: str):
"""Print message to console output.
@@ -165,70 +156,14 @@ class WebGUIManager:
"""
asyncio.create_task(self._broadcaster.emit("manual_mode_update", manual_mode_info))
def get_wanted_tree(self) -> list[dict]:
"""
Get the hierarchical tree of wanted items (Games -> Campaigns -> Drops -> Benefits).
Ignoring 'can earn within' time constraint.
"""
wanted_tree = []
games_to_watch = self._twitch.settings.games_to_watch
mining_benefits = self._twitch.settings.mining_benefits
for game_name in games_to_watch:
game_matches = []
game_obj = None
# Find all campaigns for this game
for campaign in self._twitch.inventory:
if campaign.game.name.lower() != game_name.lower():
continue
if game_obj is None:
game_obj = campaign.game
wanted_drops = []
for drop in campaign.drops:
if drop.is_claimed:
continue
filtered_benefits = [
b.name for b in drop.benefits
if b.is_wanted(mining_benefits)
]
if filtered_benefits:
wanted_drops.append({
"name": drop.name,
"benefits": filtered_benefits
})
if wanted_drops:
game_matches.append({
"id": campaign.id,
"name": campaign.name,
"url": campaign.campaign_url,
"drops": wanted_drops
})
if game_matches:
# Use the game object from the first matching campaign to get metadata
# If no game object found (shouldn't happen if game_matches has items),
# we can't easily get the icon unless we search known games or similar.
# But here we are iterating games_to_watch, so we know the name at least.
icon_url = game_obj.box_art_url if game_obj else None
wanted_tree.append({
"game_id": game_obj.id if game_obj else None,
"game_name": game_name,
"game_icon": icon_url,
"campaigns": game_matches
})
return wanted_tree
def get_wanted_game_tree(self) -> list[dict]:
return self._stream_selector.get_wanted_game_tree(
self._twitch.settings, self._twitch.inventory
)
def broadcast_wanted_items(self):
"""Broadcast the list of wanted items to connected clients."""
tree = self.get_wanted_tree()
tree = self.get_wanted_game_tree()
asyncio.create_task(self._broadcaster.emit("wanted_items_update", tree))

View File

@@ -12,11 +12,12 @@ if TYPE_CHECKING:
from src.web.managers.broadcaster import WebSocketBroadcaster
import logging
logger = logging.getLogger("TwitchDrops")
class ConsoleOutputManager:
"""Manages console output display in the web interface.

View File

@@ -3,16 +3,17 @@
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any, Callable
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from src.i18n.translator import _
from src.models.game import Game
import logging
logger = logging.getLogger("TwitchDrops")
if TYPE_CHECKING:
from src.config.settings import Settings
from src.web.managers.broadcaster import WebSocketBroadcaster
@@ -45,17 +46,8 @@ class SettingsManager:
Returns:
Dictionary containing all user-configurable settings
"""
return {
"language": self._settings.language,
"dark_mode": self._settings.dark_mode,
"games_to_watch": list(self._settings.games_to_watch),
"games_available": self._available_games,
"proxy": str(self._settings.proxy),
"connection_quality": self._settings.connection_quality,
"minimum_refresh_interval_minutes": self._settings.minimum_refresh_interval_minutes,
"inventory_filters": self._settings.inventory_filters,
"mining_benefits": self._settings.mining_benefits,
}
settings = vars(self._settings).copy()
return settings
def get_languages(self) -> dict[str, Any]:
"""Get available languages and current selection.
@@ -79,70 +71,62 @@ class SettingsManager:
settings_data: Dictionary of settings to update
"""
should_trigger_update = False
if "games_to_watch" in settings_data:
self._settings.games_to_watch = settings_data["games_to_watch"]
self._log_change(f"Setting changed: games_to_watch = {len(self._settings.games_to_watch)} games")
should_trigger_update = True
if "dark_mode" in settings_data:
self._settings.dark_mode = settings_data["dark_mode"]
self._log_change(f"Setting changed: dark_mode = {self._settings.dark_mode}")
if "language" in settings_data:
language = settings_data["language"]
try:
_.set_language(language)
self._settings.language = language
self._log_change(f"Setting changed: language = {language}")
# Notify clients that translations need to be reloaded
asyncio.create_task(
self._broadcaster.emit("language_changed", {"language": language})
)
except ValueError as e:
# Invalid language, log warning
logger.warning(f"Invalid language '{language}': {e}")
if "connection_quality" in settings_data:
self._settings.connection_quality = settings_data["connection_quality"]
self._log_change(f"Setting changed: connection_quality = {self._settings.connection_quality}")
should_trigger_update |= self.check_and_update_setting(
"games_to_watch", settings_data.get("games_to_watch"), True
)
should_trigger_update |= self.check_and_update_setting(
"dark_mode", settings_data.get("dark_mode")
)
should_trigger_update |= self.check_and_update_setting(
"language", settings_data.get("language"), False, self._set_language
)
should_trigger_update |= self.check_and_update_setting(
"connection_quality", settings_data.get("connection_quality")
)
if "proxy" in settings_data:
from yarl import URL
proxy_value = settings_data["proxy"]
should_trigger_update |= self.check_and_update_setting(
"proxy",
str(proxy_value).strip() if proxy_value else "",
True,
lambda proxy: self._log_change("Proxy cleared") if proxy == "" else None,
)
should_trigger_update |= self.check_and_update_setting(
"minimum_refresh_interval_minutes",
settings_data.get("minimum_refresh_interval_minutes"),
)
should_trigger_update |= self.check_and_update_setting(
"inventory_filters", settings_data.get("inventory_filters")
)
should_trigger_update |= self.check_and_update_setting(
"mining_benefits", settings_data.get("mining_benefits"), True
)
proxy_str = settings_data["proxy"].strip()
if proxy_str:
if self._settings.proxy != URL(proxy_str):
self._settings.proxy = URL(proxy_str)
self._log_change(f"Proxy set to: {proxy_str}")
else:
if self._settings.proxy != URL():
self._settings.proxy = URL()
self._log_change("Proxy cleared")
if "minimum_refresh_interval_minutes" in settings_data:
self._settings.minimum_refresh_interval_minutes = settings_data[
"minimum_refresh_interval_minutes"
]
self._log_change(f"Setting changed: minimum_refresh_interval_minutes = {self._settings.minimum_refresh_interval_minutes}")
if "inventory_filters" in settings_data:
self._settings.inventory_filters = settings_data["inventory_filters"]
self._log_change("Setting changed: inventory_filters updated")
if "mining_benefits" in settings_data:
self._settings.mining_benefits = settings_data["mining_benefits"]
self._log_change(f"Setting changed: mining_benefits = {self._settings.mining_benefits}")
should_trigger_update = True
self._settings.alter()
# Persist settings to disk immediately
self._settings.save()
asyncio.create_task(self._broadcaster.emit("settings_updated", self.get_settings()))
if should_trigger_update and self._on_change:
self._on_change()
def check_and_update_setting(
self,
key: str,
new_value: Any,
should_trigger_update: bool = False,
action: Callable[[Any], None] = lambda x: None,
):
if new_value is None or getattr(self._settings, key, None) == new_value:
return False
setattr(self._settings, key, new_value)
self._log_change(f"Setting changed: {key} = {new_value}")
action(new_value)
return should_trigger_update
def _set_language(self, language: str):
_.set_language(language)
# Notify clients that translations need to be reloaded
asyncio.create_task(self._broadcaster.emit("language_changed", {"language": language}))
def set_games(self, games: set[Game]):
"""Update the list of available games for settings panel.

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio
import json
import logging
import traceback
from contextlib import suppress
from time import time
from typing import TYPE_CHECKING
@@ -17,11 +16,11 @@ from src.utils import (
CHARS_ASCII,
AwaitableValue,
ExponentialBackoff,
chunk,
create_nonce,
format_traceback,
json_minify,
task_wrapper,
chunk,
)
@@ -164,7 +163,9 @@ class Websocket:
session = await self._twitch.get_session()
backoff = ExponentialBackoff(**kwargs)
proxy = self._twitch.settings.proxy or None
ws_logger.info(f"Websocket[{self._idx}] connecting with {'no' if proxy is None else str(proxy)} proxy")
ws_logger.info(
f"Websocket[{self._idx}] connecting with {'no' if proxy is None else proxy} proxy"
)
for delay in backoff:
try:
async with session.ws_connect(ws_url, proxy=proxy) as websocket:
@@ -226,13 +227,19 @@ class Websocket:
)
elif self._closed.is_set():
# we closed it - exit
ws_logger.debug(f"Websocket[{self._idx}] to wss://pubsub-edge.twitch.tv/v1 stopped.")
ws_logger.debug(
f"Websocket[{self._idx}] to wss://pubsub-edge.twitch.tv/v1 stopped."
)
self.set_status(_.t["gui"]["websocket"]["disconnected"])
return
except Exception:
ws_logger.exception(f"Exception in Websocket[{self._idx}] to wss://pubsub-edge.twitch.tv/v1")
ws_logger.exception(
f"Exception in Websocket[{self._idx}] to wss://pubsub-edge.twitch.tv/v1"
)
self.set_status(_.t["gui"]["websocket"]["reconnecting"])
ws_logger.warning(f"Websocket[{self._idx}] to wss://pubsub-edge.twitch.tv/v1 reconnecting...")
ws_logger.warning(
f"Websocket[{self._idx}] to wss://pubsub-edge.twitch.tv/v1 reconnecting..."
)
async def _handle_ping(self):
"""Handle ping/pong heartbeat to keep connection alive."""