mirror of
https://github.com/rangermix/TwitchDropsMiner.git
synced 2026-06-08 21:34:35 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ===")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
78
src/services/stream_selector.py
Normal file
78
src/services/stream_selector.py
Normal 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)]
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user