diff --git a/CLAUDE.md b/CLAUDE.md index df759eb..8ec938d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -283,7 +283,7 @@ login_text = _.t["login"]["status"]["logged_in"] # Returns "Logged in" - **src/config/paths.py** - Path management and Docker environment detection - **src/config/client_info.py** - Twitch client info (Client-Id, User-Agent) - **src/config/settings.py** - Application settings with JSON persistence -- **src/exceptions.py** - Custom exceptions (LoginException, CaptchaRequired, ExitRequest, etc.) +- **src/exceptions.py** - Custom exceptions (MinerException, ExitRequest, RequestException, RequestInvalid, WebsocketClosed, LoginException, CaptchaRequired, GQLException) - **src/utils/** - Helper utilities (string_utils, json_utils, async_helpers, rate_limiter, backoff) - **src/i18n/** - Internationalization package with TypedDict schema and Translator class - **translator.py** - Translator class with typed translation schema (Translation TypedDict) @@ -296,7 +296,21 @@ login_text = _.t["login"]["status"]["logged_in"] # Returns "Logged in" ## Testing -The project does not include a test suite. Manual testing workflow: +### Automated Tests + +The project includes a test suite in the `tests/` directory: + +```bash +# Activate virtual environment and run tests +source env/bin/activate && python -m pytest tests/ +``` + +**Test Files:** + +- `tests/test_proxy_settings.py` - Tests for proxy settings configuration +- `tests/test_verify_proxy.py` - Tests for proxy verification functionality + +### Manual Testing 1. Run with `-vvv` for maximum verbosity (levels: -v, -vv, -vvv, -vvvv) 2. Use `--dump` to generate debug data dumps diff --git a/proxy_test.py b/proxy_test.py new file mode 100644 index 0000000..80923c7 --- /dev/null +++ b/proxy_test.py @@ -0,0 +1,58 @@ + +import http.server +import socketserver +import urllib.request +import logging +import shutil + +PORT = 8888 + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("ProxyServer") + +class Proxy(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + logger.info(f"Proxy request: {self.path}") + try: + with urllib.request.urlopen(self.path) as response: + self.send_response(response.status) + for header, value in response.headers.items(): + self.send_header(header, value) + self.end_headers() + shutil.copyfileobj(response, self.wfile) + except Exception as e: + self.send_error(500, str(e)) + + def do_POST(self): + logger.info(f"Proxy request (POST): {self.path}") + length = int(self.headers['Content-Length']) + post_data = self.rfile.read(length) + req = urllib.request.Request(self.path, data=post_data, method='POST') + try: + with urllib.request.urlopen(req) as response: + self.send_response(response.status) + for header, value in response.headers.items(): + self.send_header(header, value) + self.end_headers() + shutil.copyfileobj(response, self.wfile) + except Exception as e: + self.send_error(500, str(e)) + + def do_CONNECT(self): + logger.info(f"CONNECT request: {self.path}") + self.wfile.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + # In a real proxy we would tunnel. + # For verification of "reachability", getting the 200 is often enough for simple clients, + # but aiohttp might try to read/write through the tunnel. + # Minimal tunnel implementation: + return + +if __name__ == "__main__": + # Reuse address to avoid port conflicts + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(("", PORT), Proxy) as httpd: + print(f"Serving proxy at port {PORT}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nShutting down proxy") diff --git a/src/__main__.py b/src/__main__.py index fbc9bf4..ac63e20 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -108,33 +108,28 @@ if __name__ == "__main__": if settings.language: _.set_language(settings.language) - # logging.getLogger("TwitchDrops.gql").setLevel(settings.debug_gql) - # logging.getLogger("TwitchDrops.websocket").setLevel(settings.debug_ws) - logger.info("=== TwitchDropsMiner Starting ===") logger.info(f"Version: {__version__}") logger.info(f"Python version: {sys.version}") 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") exit_status = 0 - logger.info("Creating Twitch client") client = Twitch(settings) # Initialize web GUI - logger.info("Initializing web GUI mode") from src.web import app as webapp from src.web.gui_manager import WebGUIManager # Set up web GUI - logger.debug("Creating WebGUIManager") client.gui = WebGUIManager(client) # Set up webapp references - logger.debug("Setting up webapp managers") webapp.set_managers(client.gui, client) # Start web server in background logger.info("Starting web server on http://0.0.0.0:8080") web_server_task = asyncio.create_task(webapp.run_server(host="0.0.0.0", port=8080)) - logger.info("Web server task created") loop = asyncio.get_running_loop() if sys.platform == "linux": diff --git a/src/config/settings.py b/src/config/settings.py index 0320463..c2cc39b 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -18,6 +18,10 @@ class InventoryFilters(TypedDict): show_upcoming: bool show_expired: bool show_finished: bool + show_benefit_item: bool + show_benefit_badge: bool + show_benefit_emote: bool + show_benefit_other: bool game_name_search: list[str] @@ -44,6 +48,10 @@ default_settings: SettingsFile = { "show_upcoming": True, "show_expired": False, "show_finished": False, + "show_benefit_item": True, + "show_benefit_badge": True, + "show_benefit_emote": True, + "show_benefit_other": True, "game_name_search": [], }, } diff --git a/src/exceptions.py b/src/exceptions.py index 81596f2..316204e 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -54,12 +54,16 @@ class WebsocketClosed(RequestException): `True` if the closing was caused by our side receiving a close frame, `False` otherwise. """ - def __init__(self, *args: object, received: bool = False): + def __init__(self, *args: object, received: bool = False, raw_message: str = ""): if args: super().__init__(*args) else: super().__init__("Websocket has been closed") self.received: bool = received + self.raw_message: str = raw_message + + def __str__(self): + return f"Websocket has been closed. received: {self.received}, raw_message: {self.raw_message}" class LoginException(RequestException): diff --git a/src/web/app.py b/src/web/app.py index f147f80..429a80f 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -73,9 +73,15 @@ class SettingsUpdate(BaseModel): language: str | None = None proxy: str | None = None connection_quality: int | None = None + proxy: str | None = None + connection_quality: int | None = None minimum_refresh_interval_minutes: int | None = None +class ProxyVerifyRequest(BaseModel): + proxy: str + + # ==================== REST API Endpoints ==================== @@ -203,6 +209,40 @@ async def update_settings(settings: SettingsUpdate): return {"success": True, "settings": gui_manager.settings.get_settings()} +@app.post("/api/settings/verify-proxy") +async def verify_proxy(request: ProxyVerifyRequest): + """Verify proxy connectivity""" + import aiohttp + import time + + proxy_url = request.proxy.strip() + if not proxy_url: + return {"success": False, "message": "Proxy URL is empty"} + + 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}", + } + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + @app.post("/api/login") async def submit_login(login_data: LoginRequest): """Submit login credentials""" diff --git a/src/web/gui_manager.py b/src/web/gui_manager.py index 30004fa..e574c1a 100644 --- a/src/web/gui_manager.py +++ b/src/web/gui_manager.py @@ -52,7 +52,7 @@ class WebGUIManager: self.channels = ChannelListManager(self._broadcaster, self) self.inv = InventoryManager(self._broadcaster, ImageCache(self)) self.login = LoginFormManager(self._broadcaster, self) - self.settings = SettingsManager(self._broadcaster, twitch.settings) + self.settings = SettingsManager(self._broadcaster, twitch.settings, self.output) # Selected channel tracking (set by web client) self._selected_channel_id: int | None = None diff --git a/src/web/managers/settings.py b/src/web/managers/settings.py index 05b836a..bc7f4f4 100644 --- a/src/web/managers/settings.py +++ b/src/web/managers/settings.py @@ -12,6 +12,7 @@ from src.models.game import Game if TYPE_CHECKING: from src.config.settings import Settings from src.web.managers.broadcaster import WebSocketBroadcaster + from src.web.managers.console import ConsoleOutputManager class SettingsManager: @@ -21,9 +22,15 @@ class SettingsManager: game priorities, proxy configuration, and UI preferences. """ - def __init__(self, broadcaster: WebSocketBroadcaster, settings: Settings): + def __init__( + self, + broadcaster: WebSocketBroadcaster, + settings: Settings, + console: ConsoleOutputManager, + ): self._broadcaster = broadcaster self._settings = settings + self._console = console self._available_games: list[str] = [] def get_settings(self) -> dict[str, Any]: @@ -80,7 +87,18 @@ class SettingsManager: if "connection_quality" in settings_data: self._settings.connection_quality = settings_data["connection_quality"] if "proxy" in settings_data: - self._settings.proxy = settings_data["proxy"] + from yarl import URL + + proxy_str = settings_data["proxy"].strip() + if proxy_str: + if self._settings.proxy != URL(proxy_str): + self._settings.proxy = URL(proxy_str) + self._console.print(f"Proxy set to: {proxy_str}") + else: + if self._settings.proxy != URL(): + self._settings.proxy = URL() + self._console.print("Proxy cleared") + if "minimum_refresh_interval_minutes" in settings_data: self._settings.minimum_refresh_interval_minutes = settings_data[ "minimum_refresh_interval_minutes" diff --git a/src/websocket/websocket.py b/src/websocket/websocket.py index be19174..35dde46 100644 --- a/src/websocket/websocket.py +++ b/src/websocket/websocket.py @@ -3,6 +3,7 @@ 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 @@ -20,6 +21,7 @@ from src.utils import ( format_traceback, json_minify, task_wrapper, + chunk, ) @@ -162,6 +164,7 @@ 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") for delay in backoff: try: async with session.ws_connect(ws_url, proxy=proxy) as websocket: @@ -219,17 +222,17 @@ class Websocket: if exc.received: # server closed the connection, not us - reconnect ws_logger.warning( - f"Websocket[{self._idx}] closed unexpectedly: {websocket.close_code}" + f"Websocket[{self._idx}] to wss://pubsub-edge.twitch.tv/v1 closed unexpectedly: {websocket.close_code}" ) elif self._closed.is_set(): # we closed it - exit - ws_logger.debug(f"Websocket[{self._idx}] 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}]") + 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}] 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.""" @@ -257,30 +260,32 @@ class Websocket: if removed: topics_list = list(map(str, removed)) ws_logger.debug(f"Websocket[{self._idx}]: Removing topics: {', '.join(topics_list)}") - await self.send( - { - "type": "UNLISTEN", - "data": { - "topics": topics_list, - "auth_token": auth_state.access_token, - }, - } - ) + for topics in chunk(topics_list, 10): + await self.send( + { + "type": "UNLISTEN", + "data": { + "topics": topics, + "auth_token": auth_state.access_token, + }, + } + ) self._submitted.difference_update(removed) # handle added topics added = current.difference(self._submitted) if added: topics_list = list(map(str, added)) ws_logger.debug(f"Websocket[{self._idx}]: Adding topics: {', '.join(topics_list)}") - await self.send( - { - "type": "LISTEN", - "data": { - "topics": topics_list, - "auth_token": auth_state.access_token, - }, - } - ) + for topics in chunk(topics_list, 10): + await self.send( + { + "type": "LISTEN", + "data": { + "topics": topics, + "auth_token": auth_state.access_token, + }, + } + ) self._submitted.update(added) async def _gather_recv(self, messages: list[JsonType], timeout: float = 0.5): @@ -303,16 +308,16 @@ class Websocket: message: JsonType = json.loads(raw_message.data) messages.append(message) elif raw_message.type is WSMsgType.CLOSE: - raise WebsocketClosed(received=True) + raise WebsocketClosed(received=True, raw_message=raw_message.data) elif raw_message.type is WSMsgType.CLOSED: - raise WebsocketClosed(received=False) + raise WebsocketClosed(received=False, raw_message=raw_message.data) elif raw_message.type is WSMsgType.CLOSING: pass # skip these elif raw_message.type is WSMsgType.ERROR: ws_logger.error( f"Websocket[{self._idx}] error: {format_traceback(raw_message.data)}" ) - raise WebsocketClosed() + raise WebsocketClosed(raw_message=raw_message.data) else: ws_logger.error(f"Websocket[{self._idx}] error: Unknown message: {raw_message}") diff --git a/tests/test_proxy_settings.py b/tests/test_proxy_settings.py new file mode 100644 index 0000000..f9a05c7 --- /dev/null +++ b/tests/test_proxy_settings.py @@ -0,0 +1,63 @@ + +import unittest +import asyncio +from unittest.mock import MagicMock +from yarl import URL + +# Mock the imports that depend on application structure if needed, +# or just import them if PYTHONPATH is set correctly. +# Assuming run from root, imports should work. +from src.config.settings import Settings +from src.web.managers.settings import SettingsManager +from src.web.managers.console import ConsoleOutputManager + +class TestProxySettings(unittest.TestCase): + def setUp(self): + self.mock_broadcaster = MagicMock() + # Mock emit to be awaitable + f = asyncio.Future() + f.set_result(None) + self.mock_broadcaster.emit = MagicMock(return_value=f) + + self.mock_settings = MagicMock(spec=Settings) + # Setup properties + self.mock_settings.proxy = URL() + self.mock_settings.language = "en" + self.mock_settings.dark_mode = False + self.mock_settings.games_to_watch = [] + self.mock_settings.connection_quality = 1 + self.mock_settings.minimum_refresh_interval_minutes = 30 + + self.mock_console = MagicMock(spec=ConsoleOutputManager) + + # Mock asyncio.create_task + self.create_task_patcher = unittest.mock.patch('asyncio.create_task') + self.mock_create_task = self.create_task_patcher.start() + + def tearDown(self): + self.create_task_patcher.stop() + + def test_update_proxy_setting(self): + manager = SettingsManager(self.mock_broadcaster, self.mock_settings, self.mock_console) + + # Test setting a proxy + proxy_url = "http://user:pass@localhost:8080" + manager.update_settings({"proxy": proxy_url}) + + self.assertEqual(self.mock_settings.proxy, URL(proxy_url)) + self.mock_console.print.assert_called_with(f"Proxy set to: {proxy_url}") + + # Test clearing a proxy + manager.update_settings({"proxy": ""}) + self.assertEqual(self.mock_settings.proxy, URL()) + self.mock_console.print.assert_called_with("Proxy cleared") + + def test_proxy_persistence_trigger(self): + manager = SettingsManager(self.mock_broadcaster, self.mock_settings, self.mock_console) + manager.update_settings({"proxy": "http://1.2.3.4:8080"}) + + self.mock_settings.alter.assert_called() + self.mock_settings.save.assert_called() + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verify_proxy.py b/tests/test_verify_proxy.py new file mode 100644 index 0000000..9235e38 --- /dev/null +++ b/tests/test_verify_proxy.py @@ -0,0 +1,86 @@ + +import asyncio +import unittest +from unittest.mock import MagicMock, patch, AsyncMock +from src.web.app import verify_proxy +from src.web.app import ProxyVerifyRequest + +class MockResponseContext: + def __init__(self, response_or_exc): + self.response_or_exc = response_or_exc + + async def __aenter__(self): + if isinstance(self.response_or_exc, Exception): + raise self.response_or_exc + return self.response_or_exc + + async def __aexit__(self, exc_type, exc, tb): + pass + +class TestVerifyProxy(unittest.TestCase): + def setUp(self): + # Patch aiohttp.ClientSession + self.session_patcher = patch('aiohttp.ClientSession') + self.mock_session_cls = self.session_patcher.start() + # session object itself is not async, it has async methods/CMs + self.mock_session = MagicMock() + # Ensure the session context manager returns our mock session + # ClientSession() -> CM -> __aenter__ -> session + self.mock_session_cls.return_value.__aenter__.return_value = self.mock_session + + def tearDown(self): + self.session_patcher.stop() + + def test_verify_proxy_success(self): + # Mock successful response + mock_response = AsyncMock() + mock_response.status = 200 + + # Configure get to return our custom context manager + self.mock_session.get.side_effect = lambda *args, **kwargs: MockResponseContext(mock_response) + + request = ProxyVerifyRequest(proxy="http://valid-proxy:8080") + + # Run async function + result = asyncio.run(verify_proxy(request)) + + self.assertTrue(result['success']) + self.assertIn("Connected!", result['message']) + self.assertIn("latency", result) + + def test_verify_proxy_failure_status(self): + # Mock error status response + mock_response = AsyncMock() + mock_response.status = 503 + + self.mock_session.get.side_effect = lambda *args, **kwargs: MockResponseContext(mock_response) + + request = ProxyVerifyRequest(proxy="http://bad-proxy:8080") + + result = asyncio.run(verify_proxy(request)) + + self.assertFalse(result['success']) + + # The expected message in app.py is: f"Proxy reachable but returned {response.status}" + self.assertIn("Proxy reachable but returned 503", result['message']) + + def test_verify_proxy_connection_error(self): + # Mock connection error + error = Exception("Connection refused") + self.mock_session.get.side_effect = lambda *args, **kwargs: MockResponseContext(error) + + request = ProxyVerifyRequest(proxy="http://down-proxy:8080") + + result = asyncio.run(verify_proxy(request)) + + self.assertFalse(result['success']) + self.assertIn("Connection failed", result['message']) + + def test_verify_proxy_empty(self): + request = ProxyVerifyRequest(proxy="") + result = asyncio.run(verify_proxy(request)) + self.assertFalse(result['success']) + self.assertEqual(result['message'], "Proxy URL is empty") + +if __name__ == "__main__": + unittest.main() diff --git a/web/index.html b/web/index.html index 76b8fac..65d76ef 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,6 @@ +
@@ -7,6 +8,7 @@ +Select games to watch. Order matters - drag to reorder priority (top = highest priority).
+Select games to watch. Order matters - drag to reorder priority (top = highest + priority).