From 5b736e3bb1d71fb6119051f804ed2dd749f04dd0 Mon Sep 17 00:00:00 2001 From: Fengqing Liu Date: Thu, 16 Oct 2025 21:54:43 +1100 Subject: [PATCH] Migrate to Docker-ready web UI and remove legacy desktop GUI Replace the legacy desktop/Tkinter client and packaging artifacts with a Docker-first, web-hosted approach. - Add Docker quickstart and run-from-source instructions to the README to simplify deployment. - Simplify launcher to invoke the new web backend module instead of the old desktop entrypoint. - Update dependencies for a web UI stack (FastAPI, Uvicorn, python-socketio, Jinja2, etc.) and remove desktop/tray-specific packages. - Remove legacy GUI, packaging and platform-specific helper code, along with obsolete build/pack scripts and AppImage assets to declutter the repo. - Tidy project ignore rules to add runtime logs and editor metadata. Rationale: streamline deployment, favor a browser-accessible interface, and reduce maintenance overhead from multiple platform-specific GUI/packaging implementations. --- .dockerignore | 62 + .gitignore | 9 +- CLAUDE.md | 313 +++ Dockerfile | 40 + README.md | 135 +- appimage/AppImageBuilder.yml | 164 -- appimage/pickaxe.png | Bin 2287 -> 0 bytes constants.py | 495 ---- docker-compose.yml | 33 + gui.py | 2759 -------------------- main.py | 207 +- manual.txt | 31 - pack.bat | 24 - registry.py | 112 - requirements.txt | 13 +- run_dev.bat | 11 - setup_env.bat | 48 - src/__init__.py | 4 + src/__main__.py | 263 ++ src/api/__init__.py | 16 + src/api/gql_client.py | 220 ++ src/api/http_client.py | 228 ++ src/auth/__init__.py | 5 + src/auth/auth_state.py | 453 ++++ src/config/__init__.py | 113 + src/config/client_info.py | 113 + src/config/constants.py | 160 ++ src/config/operations.py | 157 ++ src/config/paths.py | 121 + settings.py => src/config/settings.py | 16 +- src/core/__init__.py | 0 twitch.py => src/core/client.py | 997 ++----- exceptions.py => src/exceptions.py | 0 src/i18n/__init__.py | 59 + translate.py => src/i18n/translator.py | 8 +- src/models/__init__.py | 19 + src/models/benefit.py | 34 + src/models/campaign.py | 199 ++ channel.py => src/models/channel.py | 25 +- inventory.py => src/models/drop.py | 238 +- src/models/game.py | 45 + src/services/__init__.py | 0 src/services/channel_service.py | 177 ++ src/services/inventory_service.py | 271 ++ src/services/maintenance.py | 93 + src/services/message_handlers.py | 273 ++ src/services/watch_service.py | 256 ++ src/utils/__init__.py | 64 + src/utils/async_helpers.py | 137 + src/utils/backoff.py | 86 + cache.py => src/utils/cache.py | 6 +- src/utils/json_utils.py | 158 ++ src/utils/rate_limiter.py | 75 + src/utils/string_utils.py | 34 + version.py => src/version.py | 0 src/web/__init__.py | 0 src/web/api/__init__.py | 0 src/web/app.py | 299 +++ src/web/gui_manager.py | 233 ++ src/web/managers/__init__.py | 41 + src/web/managers/broadcaster.py | 33 + src/web/managers/cache.py | 32 + src/web/managers/campaigns.py | 61 + src/web/managers/channels.py | 100 + src/web/managers/console.py | 44 + src/web/managers/inventory.py | 108 + src/web/managers/login.py | 132 + src/web/managers/settings.py | 75 + src/web/managers/status.py | 74 + src/web/managers/tray.py | 55 + src/websocket/__init__.py | 16 + src/websocket/pool.py | 132 + websocket.py => src/websocket/websocket.py | 189 +- utils.py | 436 ---- web/index.html | 177 ++ web/static/app.js | 724 +++++ web/static/styles.css | 713 +++++ 77 files changed, 7852 insertions(+), 5401 deletions(-) create mode 100644 .dockerignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile delete mode 100644 appimage/AppImageBuilder.yml delete mode 100644 appimage/pickaxe.png delete mode 100644 constants.py create mode 100644 docker-compose.yml delete mode 100644 gui.py delete mode 100644 manual.txt delete mode 100644 pack.bat delete mode 100644 registry.py delete mode 100644 run_dev.bat delete mode 100755 setup_env.bat create mode 100644 src/__init__.py create mode 100644 src/__main__.py create mode 100644 src/api/__init__.py create mode 100644 src/api/gql_client.py create mode 100644 src/api/http_client.py create mode 100644 src/auth/__init__.py create mode 100644 src/auth/auth_state.py create mode 100644 src/config/__init__.py create mode 100644 src/config/client_info.py create mode 100644 src/config/constants.py create mode 100644 src/config/operations.py create mode 100644 src/config/paths.py rename settings.py => src/config/settings.py (86%) create mode 100644 src/core/__init__.py rename twitch.py => src/core/client.py (54%) rename exceptions.py => src/exceptions.py (100%) create mode 100644 src/i18n/__init__.py rename translate.py => src/i18n/translator.py (98%) create mode 100644 src/models/__init__.py create mode 100644 src/models/benefit.py create mode 100644 src/models/campaign.py rename channel.py => src/models/channel.py (96%) rename inventory.py => src/models/drop.py (58%) create mode 100644 src/models/game.py create mode 100644 src/services/__init__.py create mode 100644 src/services/channel_service.py create mode 100644 src/services/inventory_service.py create mode 100644 src/services/maintenance.py create mode 100644 src/services/message_handlers.py create mode 100644 src/services/watch_service.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/async_helpers.py create mode 100644 src/utils/backoff.py rename cache.py => src/utils/cache.py (97%) create mode 100644 src/utils/json_utils.py create mode 100644 src/utils/rate_limiter.py create mode 100644 src/utils/string_utils.py rename version.py => src/version.py (100%) create mode 100644 src/web/__init__.py create mode 100644 src/web/api/__init__.py create mode 100644 src/web/app.py create mode 100644 src/web/gui_manager.py create mode 100644 src/web/managers/__init__.py create mode 100644 src/web/managers/broadcaster.py create mode 100644 src/web/managers/cache.py create mode 100644 src/web/managers/campaigns.py create mode 100644 src/web/managers/channels.py create mode 100644 src/web/managers/console.py create mode 100644 src/web/managers/inventory.py create mode 100644 src/web/managers/login.py create mode 100644 src/web/managers/settings.py create mode 100644 src/web/managers/status.py create mode 100644 src/web/managers/tray.py create mode 100644 src/websocket/__init__.py create mode 100644 src/websocket/pool.py rename websocket.py => src/websocket/websocket.py (76%) delete mode 100644 utils.py create mode 100644 web/index.html create mode 100644 web/static/app.js create mode 100644 web/static/styles.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd8f2c1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,62 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual Environment +env/ +venv/ +ENV/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Git +.git/ +.gitignore +.gitattributes + +# GitHub +.github/ + +# CI/CD +.gitlab-ci.yml + +# Documentation +*.md +!README.md + +# OS +.DS_Store +Thumbs.db + +# Application data (will be mounted as volumes) +data/ +cache/ +cookies.jar +settings.json +log.txt +dump.dat +lock.file +logs/ +.claude/ + +# Build artifacts +*.exe +*.AppImage +*.deb +*.rpm + +# Other +.env +.env.local diff --git a/.gitignore b/.gitignore index 90b9f7e..d84fdf3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,6 @@ log.txt /lock.file settings.json /lang/English.json -# AppImage -/AppDir -/appimage-builder -/appimage-build -/*.AppImage -/*.AppImage.zsync + +logs/ +.claude/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..68ea69a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,313 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Twitch Drops Miner is a Python application that automatically mines timed Twitch drops without downloading stream data. It uses Twitch's GraphQL API and websocket connections to simulate watching streams while tracking drop progress. + +**Key Characteristics:** +- Python 3.10+ required +- Web-based GUI using FastAPI and Socket.IO +- Async/await architecture with asyncio +- Session persistence via cookies +- No stream video/audio download (bandwidth-efficient) +- Docker-ready for easy deployment + +## Development Commands + +**IMPORTANT: Always activate the virtual environment first!** + +The project uses a virtual environment located at `env/`. All Python commands must be run within this environment: + +```bash +# Activate the virtual environment (required before any Python commands) +source env/bin/activate +``` + +### Running the Application + +```bash +# Run from source (remember to activate venv first!) +source env/bin/activate && python main.py + +# With verbose logging (stackable: -vv, -vvv) +source env/bin/activate && python main.py -v + +# Enable legacy log file (logs always go to ./logs/TDM.TIMESTAMP.log) +source env/bin/activate && python main.py --log + +# Create data dump for debugging +source env/bin/activate && python main.py --dump + +# Access the web interface at http://localhost:8080 +``` + +### Development Setup + +The application requires: +- Python 3.10+ +- Virtual environment at `env/` (must be activated before running commands) +- Dependencies from `requirements.txt` (includes FastAPI, uvicorn, Socket.IO) + +Docker deployment: +```bash +# Build and run with docker-compose +docker-compose up -d + +# Access at http://localhost:8080 +``` + +## Architecture + +**⚠️ IMPORTANT: The codebase has been refactored into a modular structure.** + +The application now uses a clean `src/` package structure with clear separation of concerns. See [REFACTORING_SUMMARY.md](REFACTORING_SUMMARY.md) for complete details. + +### Project Structure + +``` +src/ +├── models/ # Domain models (Game, Channel, Campaign, Drop, Benefit) +├── config/ # Configuration (constants, paths, operations, settings, client_info) +├── utils/ # Pure utilities (time, string, JSON, async helpers, cache, rate_limiter, backoff, registry) +├── i18n/ # Translation system (translator) +├── auth/ # Authentication (auth_state for OAuth and token management) +├── api/ # External API (HTTP client, GraphQL client) +├── websocket/ # Real-time updates (websocket connection, pool) +├── web/ # Web GUI (app, gui_manager, api/) +│ └── managers/ # Individual UI managers (status, console, channels, campaigns, inventory, login, settings, cache, tray, broadcaster) +├── services/ # Business logic services (channel, inventory, watch, maintenance, message_handlers) +├── core/ # Core client (Twitch client) +├── exceptions.py # Custom exceptions +├── version.py # Version string +└── __main__.py # Entry point (replaces old main.py) +``` + +### Core Components + +**main.py** - Simple launcher: +- Runs the `src` package as a module using `runpy.run_module("src")` +- All application logic is now in `src/__main__.py` + +**src/__main__.py** - Entry point: +- Parses command-line arguments +- Initializes Settings, Twitch client, and WebGUIManager +- Starts the FastAPI web server (uvicorn on port 8080) +- Runs the main asyncio event loop +- Handles signals (SIGINT, SIGTERM on Linux) and exit codes + +**src/core/client.py** - Central client (`Twitch` class): +- State machine: IDLE, INVENTORY_FETCH, GAMES_UPDATE, CHANNELS_CLEANUP, CHANNELS_FETCH, CHANNEL_SWITCH, EXIT +- Composes `_AuthState`, `HTTPClient`, and `GQLClient` +- Delegates to service layer for business logic +- Drop progress monitoring via periodic "watch" payloads +- Manages WebsocketPool and maintenance tasks + +**src/services/** - Business logic layer (fully implemented): +- `ChannelService`: Channel management and selection logic +- `InventoryService`: Campaign and drop inventory operations +- `WatchService`: Drop mining watch payload logic +- `MaintenanceService`: Periodic maintenance tasks +- `MessageHandlerService`: Websocket message routing and handling + +Note: The services layer is complete and handles all business logic that was previously in the monolithic client file. + +**src/models/channel.py** - Channel and Stream: +- `Channel` class: Twitch channel with online/offline status +- `Stream` class: Active stream with game, viewers, drop status +- Stream URL fetching and validation +- ACL-based vs directory channels + +**src/models/campaign.py** - Drop campaigns: +- `DropsCampaign`: Campaign with game, timeframe, allowed channels +- Time-based eligibility and progress tracking + +**src/models/drop.py** - Drop types: +- `TimedDrop`: Drops with minute requirements and progress +- `BaseDrop`: Base class with claim logic +- Precondition chains for sequential drops + +**src/web/gui_manager.py** - Web GUI: +- `WebGUIManager`: Main GUI coordinator +- Composes individual managers for different UI concerns (status, console, channels, campaigns, inventory, login, settings, cache, tray) +- Uses `WebSocketBroadcaster` for real-time Socket.IO updates +- Pure asyncio, no tkinter dependency + +**src/web/app.py** - FastAPI application: +- REST API endpoints: `/api/status`, `/api/channels`, `/api/campaigns`, `/api/settings`, `/api/login`, `/api/oauth/confirm`, `/api/reload`, `/api/close` +- Socket.IO server for real-time bi-directional communication +- Serves static web frontend from `web/` directory +- Integrates with WebGUIManager via `set_managers()` + +**src/websocket/pool.py** - WebSocket management: +- Sharded connections (up to 50 topics per socket, max 199 channels) +- Topics: User.Drops, User.Notifications, Channel.StreamState, Channel.StreamUpdate +- Automatic reconnection with exponential backoff +- Message routing to registered callbacks + +**src/config/settings.py** - Application settings: +- Games to watch list (auto-populated from available campaigns if empty) +- Connection quality multiplier +- Language selection +- Proxy support +- Logging and dump flags from command-line arguments +- Persistence to JSON file (`settings.json`) in DATA_DIR + +### State Machine Flow + +1. **IDLE** - Waiting for campaigns or user action +2. **INVENTORY_FETCH** - Fetch campaigns from GraphQL, claim completed drops +3. **GAMES_UPDATE** - Determine wanted games based on priority/exclude lists +4. **CHANNELS_CLEANUP** - Remove channels not streaming wanted games +5. **CHANNELS_FETCH** - Discover channels via ACL lists or game directories +6. **CHANNEL_SWITCH** - Select best channel to watch based on priority/ACL +7. Loop between CHANNEL_SWITCH and periodic INVENTORY_FETCH (hourly) + +### Authentication + +- Uses OAuth device code flow (user enters code at twitch.tv/activate) +- Managed by `src/auth/auth_state.py` (`_AuthState` class) +- Access tokens stored in `cookies.jar` in DATA_DIR +- Device ID from Twitch's `unique_id` cookie +- Session ID generated per run +- Client info defined in `src/config/client_info.py` (presents as Android app with Client-Id and User-Agent spoofing) + +### Drop Mining Mechanism + +The application sends periodic "watch" payloads to a spade URL every ~20 seconds: +- Payload contains minute-watched events with channel/broadcast IDs +- Twitch reports progress via websocket (User.Drops topic) +- If websocket updates stop, fallback to GQL CurrentDrop query +- Extrapolation via "bump minutes" when no updates received + +### GraphQL Operations + +Defined in `src/config/operations.py` as `GQL_OPERATIONS`: +- **Inventory** - Fetch in-progress campaigns and claimed benefits +- **Campaigns** - List available active/upcoming campaigns +- **CampaignDetails** - Detailed drop info for a campaign +- **GameDirectory** - Find live streams for a game with drops enabled +- **GetStreamInfo** - Check if channel is online and get stream details +- **CurrentDrop** - Query currently mined drop progress +- **ClaimDrop** - Claim a completed drop +- **AvailableDrops** - Check which campaigns a channel qualifies for (badge validation) +- **NotificationsDelete** - Delete Twitch notifications + +### Channel Selection Priority + +1. Selected channel (if user clicked one) +2. ACL-based channels over directory channels +3. Game priority order (from settings) +4. Viewer count (descending) +5. Maximum 199 channels tracked simultaneously + +### Maintenance Task + +Runs in background to trigger: +- Channel cleanup when drops start/end (based on time_triggers) +- Inventory reload every ~60 minutes + +## Key Files + +- **src/config/constants.py** - Core enums (State, WebsocketTopic), logging config, type aliases +- **src/config/operations.py** - GraphQL operation definitions (GQL_OPERATIONS) +- **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, ReloadRequest, etc.) +- **src/utils/** - Helper utilities (time, string, JSON, async helpers, cache, rate limiter, backoff, registry) +- **src/i18n/translator.py** - i18n support with JSON translation files +- **src/version.py** - Version string +- **src/web/app.py** - FastAPI application with REST API and Socket.IO +- **web/** - Frontend assets (index.html, static/app.js, static/styles.css) + +## Testing + +The project does not include a test suite. Manual testing workflow: +1. Run with `-vvv` for maximum verbosity (levels: -v, -vv, -vvv, -vvvv) +2. Use `--dump` to generate debug data dumps +3. Check timestamped log files in `./logs/` directory (always created as `TDM.TIMESTAMP.log`) +4. Use `--log` flag to also create legacy `log.txt` file +5. Use `--debug-ws` for websocket debug logging +6. Use `--debug-gql` for GraphQL debug logging +7. Monitor web GUI console output and browser developer tools + +## Web GUI Architecture + +The application uses a web-based interface accessible via browser: + +### Web GUI Components + +**src/web/gui_manager.py** - WebGUIManager class: +- Managers: StatusManager, ConsoleOutputManager, ChannelListManager, CampaignProgressManager, InventoryManager, LoginFormManager, SettingsManager, CacheManager, TrayManager +- Uses WebSocketBroadcaster to push real-time updates to connected clients via Socket.IO +- Pure async/await implementation + +**src/web/app.py** - FastAPI application: +- REST API endpoints: `/api/status`, `/api/channels`, `/api/campaigns`, `/api/console`, `/api/settings`, `/api/login`, `/api/oauth/confirm`, `/api/reload`, `/api/close` +- Socket.IO events for real-time bi-directional communication +- Serves static web frontend from `web/` directory +- Integrates with WebGUIManager via `set_managers(gui, twitch)` + +**web/** - Frontend assets: +- `index.html` - Single-page application layout with tabs +- `static/app.js` - Socket.IO client, real-time UI updates, API calls +- `static/styles.css` - Responsive design with dark mode support + +### Communication Protocol + +**Server → Client (Socket.IO events):** +- `initial_state` - Full state on connect +- `status_update` - Status bar changes +- `console_output` - New log lines +- `channel_add/update/remove` - Channel list changes +- `drop_progress` - Drop mining progress +- `campaign_add` - New campaign added +- `login_required` - Prompt for credentials +- `settings_updated` - Settings changed + +**Client → Server:** +- REST API for actions (login, settings, channel selection) +- Socket.IO for connection management + +### Docker Integration + +**src/config/paths.py:** +- Detects Docker environment via `DOCKER_ENV` env var or `/.dockerenv` file +- Uses `/app` for code, `/app/data` for persistent storage +- All user data (cookies, settings, cache, logs) stored in DATA_DIR +- Provides `_resource_path()` helper for locating resources + +**Dockerfile:** +- Based on `python:3.11-slim` +- Installs dependencies from `requirements.txt` +- Exposes port 8080 +- Health check on `/api/status` + +**docker-compose.yml:** +- Volume mounts `./data:/app/data` for persistence +- Port mapping `8080:8080` +- Auto-restart policy +- Timezone configuration + +### Key Design Decisions + +- **WebSocket for real-time** - Socket.IO chosen for reliability (fallback to polling) +- **Single-page app** - Simpler than full framework (React/Vue), fast load times +- **Direct Docker support** - Environment detection, proper path handling +- **OAuth device code flow** - Works great for web-based deployment + +## Project Scope + +**Supported:** +- ✅ Web GUI - browser-based interface +- ✅ Docker deployment - containerized for any platform +- ✅ Remote access - access from any device on network +- ✅ Headless operation - no display server required + +**NOT supported:** +- Multi-account support +- Channel points mining +- Mining for unlinked campaigns +- Desktop GUI (removed in favor of web-only) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e3d9b04 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + DOCKER_ENV=1 \ + PORT=8080 + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements file +COPY requirements-web.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements-web.txt + +# Copy application code +COPY *.py ./ +COPY lang/ ./lang/ +COPY icons/ ./icons/ +COPY web/ ./web/ + +# Create data directory for persistent storage +RUN mkdir -p /app/data && chmod 777 /app/data + +# Expose web port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/status')" || exit 1 + +# Run with web GUI +CMD ["python", "main.py", "--web", "-v"] diff --git a/README.md b/README.md index 5b03b97..ddc30e4 100644 --- a/README.md +++ b/README.md @@ -19,17 +19,44 @@ Every several seconds, the application pretends to watch a particular stream by ### Usage: -- Download and unzip [the latest release](https://github.com/DevilXD/TwitchDropsMiner/releases) - it's recommended to keep it in the folder it comes in. -- Run it and login/connect the miner to your Twitch account by using the in-app login form. -- After a successful login, the app should fetch a list of all available campaigns and games you can mine drops for - you can then select and add games of choice to the Priority List available on the Settings tab, and then press on the `Reload` button to start processing. It will fetch a list of all applicable streams it can watch, and start mining right away. You can also manually switch to a different channel as needed. -- If you wish to keep the miner occupied with mining anything it can, beyond what you've selected via the Priority List, you can use the Priority Mode setting to specify the mining order for the rest of the games. -- Make sure to link your Twitch account to game accounts on the [campaigns page](https://www.twitch.tv/drops/campaigns), to enable more games to be mined. +**Quick Start with Docker (Recommended):** -### Pictures: +```bash +# Clone the repository +git clone https://github.com/DevilXD/TwitchDropsMiner.git +cd TwitchDropsMiner -![Main](https://user-images.githubusercontent.com/4180725/164298155-c0880ad7-6423-4419-8d73-f3c053730a1b.png) -![Inventory](https://user-images.githubusercontent.com/4180725/164298315-81cae0d2-24a4-4822-a056-154fd763c284.png) -![Settings](https://user-images.githubusercontent.com/4180725/164298391-b13ad40d-3881-436c-8d4c-34e2bbe33a78.png) +# Start with docker-compose +docker-compose up -d + +# Access the web interface at http://localhost:8080 +``` + +**Running from Source:** + +```bash +# Install Python 3.10+ and dependencies +pip install -r requirements.txt + +# Run the application +python main.py + +# Access the web interface at http://localhost:8080 +``` + +**Using the Application:** + +- Open the web interface in your browser at `http://localhost:8080` +- Login/connect the miner to your Twitch account using the OAuth device code flow +- After a successful login, the app will fetch all available campaigns and games you can mine drops for +- Select and add games to the Priority List on the Settings tab, then press `Reload` to start processing +- The miner will fetch applicable streams and start mining automatically +- You can manually switch to a different channel as needed +- Make sure to link your Twitch account to game accounts on the [campaigns page](https://www.twitch.tv/drops/campaigns) + +### Screenshots: + +The application features a modern web-based interface accessible from any browser on your network. ### Notes: @@ -50,24 +77,42 @@ Every several seconds, the application pretends to watch a particular stream by > [!NOTE] > The source code requires Python 3.10 or higher to run. -### Notes about the Windows build: +### Docker Deployment: -- To achieve a portable-executable format, the application is packaged with PyInstaller into an `EXE`. Some antivirus engines (including Windows Defender) might report the packaged executable as a trojan, because PyInstaller has been used by others to package malicious Python code in the past. These reports can be safely ignored. If you absolutely do not trust the executable, you'll have to install Python yourself and run everything from source. -- The executable uses the `%TEMP%` directory for temporary runtime storage of files, that don't need to be exposed to the user (like compiled code and translation files). For persistent storage, the directory the executable resides in is used instead. -- The autostart feature is implemented as a registry entry to the current user's (`HKCU`) autostart key. It is only altered when toggling the respective option. If you relocate the app to a different directory, the autostart feature will stop working, until you toggle the option off and back on again +The application is designed for Docker deployment, making it easy to run on any platform: -### Notes about the Linux build: +```bash +# Build and run with docker-compose +docker-compose up -d -- The Linux app is built and distributed using two distinct portable-executable formats: [AppImage](https://appimage.org/) and [PyInstaller](https://pyinstaller.org/). -- There are no major differences between the two formats, but if you're looking for a recommendation, use the AppImage. -- The Linux app should work out of the box on any modern distribution, as long as it has `glibc>=2.35`, plus a working display server. -- Every feature of the app is expected to work on Linux just as well as it does on Windows. If you find something that's broken, please [open a new issue](https://github.com/DevilXD/TwitchDropsMiner/issues/new). -- The size of the Linux app is significantly larger than the Windows app due to the inclusion of the `gtk3` library (and its dependencies), which is required for proper system tray/notifications support. -- As an alternative to the native Linux app, you can run the Windows app via [Wine](https://www.winehq.org/) instead. It works really well! +# Or build and run manually +docker build -t twitch-drops-miner . +docker run -d -p 8080:8080 -v $(pwd)/data:/app/data twitch-drops-miner +``` -### Advanced Usage: +**Important Docker notes:** +- All persistent data (cookies, settings, logs) is stored in the `data/` directory +- Login uses OAuth device code flow - you'll be given a code to enter at twitch.tv/activate +- Browser notifications supported (requires permission) +- Health checks and automatic restart included in docker-compose +- Configure timezone with `TZ` environment variable -If you'd be interested in running the latest master from source or building your own executable, see the wiki page explaining how to do so: https://github.com/DevilXD/TwitchDropsMiner/wiki/Setting-up-the-environment,-building-and-running +### Running from Source: + +For development or customization: + +```bash +# Install Python 3.10+ +# Create virtual environment (recommended) +python -m venv env +source env/bin/activate # On Windows: env\Scripts\activate + +# Install dependencies +pip install -r requirements.txt + +# Run the application +python main.py +``` ### Support @@ -84,37 +129,29 @@ If you'd be interested in running the latest master from source or building your ### Project goals: -Twitch Drops Miner (TDM for short) has been designed with a couple of simple goals in mind. These are, specifically: +Twitch Drops Miner (TDM for short) has been designed with a couple of simple goals in mind: -- Twitch Drops oriented - it's in the name. That's what I made it for. -- Easy to use for an average person. Includes a nice looking GUI and is packaged as a ready-to-go executable, without requiring an existing Python installation to work. -- Intended as a helper tool that starts together with your PC, runs in the background through out the day, and then closes together with your PC shutting down at the end of the day. If it can run continuously for 24 hours at minimum, and not run into any errors, I'd call that good enough already. -- Requiring a minimum amount of attention during operation - check it once or twice through out the day to see if everything's fine with it. -- Underlying service friendly - the amount of interactions done with the Twitch site is kept to the minimum required for reliable operation, at a level achievable by a diligent site user. +**What TDM is:** +- **Twitch Drops focused** - Automatic mining of timed Twitch drops +- **Easy to use** - Simple web interface accessible from any browser +- **Reliable** - Designed to run continuously with minimal attention needed +- **Efficient** - Minimal interactions with Twitch, respecting their service +- **Docker-ready** - Easy deployment on any platform or server -TDM is not intended for/as: +**What TDM is NOT:** +- Mining channel points +- Mining other platforms besides Twitch +- Multi-account support +- Mining unlinked campaigns +- 100% guaranteed uptime (expect occasional errors) -- Mining channel points - again, it's about the drops: only. -- Mining anything else besides Twitch drops - no, I won't be adding support for a random 3rd party site that also happens to rely on watching Twitch streams. -- Unattended operation: worst case scenario, it'll stop working and you'll hopefully notice that at some point. Hopefully. -- 100% uptime application, due to the underlying nature of it, expect fatal errors to happen every so often. -- Being hosted on a remote server as a 24/7 miner. -- Being used with more than one managed account. -- Mining campaigns the managed account isn't linked to. +**Limitations:** +- Single account per instance +- No automatic error recovery (monitor periodically) +- No additional notification systems (email, webhook, etc.) +- Campaigns must be linked to your Twitch account -This means that features such as: - -- It being possible to run it without a GUI, or with only a console attached. -- Any form of automatic restart when an error happens. -- Docker or any other form of remote deployment. -- Using it with more than one managed account. -- Making it possible to mine campaigns that the managed account isn't linked to. -- Anything that increases the site processing load caused by the application. -- Any form of additional notifications system (email, webhook, etc.), beyond what's already implemented. - -..., are most likely not going to be a feature, ever. You're welcome to search through the existing issues to comment on your point of view on the relevant matters, where applicable. Otherwise, most of the new issues that go against these goals will be closed and the user will be pointed to this paragraph. - -For more context about these goals, please check out these issues: [#161](https://github.com/DevilXD/TwitchDropsMiner/issues/161), [#105](https://github.com/DevilXD/TwitchDropsMiner/issues/105), [#84](https://github.com/DevilXD/TwitchDropsMiner/issues/84) +This is a web-only application designed for Docker deployment and headless operation. ### Credits: diff --git a/appimage/AppImageBuilder.yml b/appimage/AppImageBuilder.yml deleted file mode 100644 index 68b9f28..0000000 --- a/appimage/AppImageBuilder.yml +++ /dev/null @@ -1,164 +0,0 @@ -# Useful links: -# https://appimage-builder.readthedocs.io/en/latest/reference/recipe.html - -version: 1 - -script: - # Clean up any previous builds. - - rm -rf "$BUILD_DIR" "$TARGET_APPDIR" || true - - mkdir -p "$BUILD_DIR" "$TARGET_APPDIR" - - cd "$BUILD_DIR" - - # Build a recent version of libXft first. This fixes an issue where the app won't start with libXft 2.3.3 if an emoji font is installed on the system. - - curl -fL https://xorg.freedesktop.org/releases/individual/lib/libXft-2.3.9.tar.xz -o libXft.tar.xz - - sha256sum libXft.tar.xz - - tar xvf libXft.tar.xz - - cd libXft-* - - ./configure --prefix="$TARGET_APPDIR/usr" --disable-static - - make -j$(nproc) - - make install-strip - - # Package the app. - - mkdir -p "$TARGET_APPDIR"/usr/{src,share/icons/hicolor/128x128/apps} - - cp -r "$SOURCE_DIR/../lang" "$SOURCE_DIR/../icons" "$SOURCE_DIR"/../*.py "$TARGET_APPDIR/usr/src" - - cp "$SOURCE_DIR/pickaxe.png" "$TARGET_APPDIR/usr/share/icons/hicolor/128x128/apps/io.github.devilxd.twitchdropsminer.png" - - # Create a virtual environment and install up-to-date versions of meson and ninja. - - python3 -m venv env && ./env/bin/python3 -m pip install meson ninja - # Install requirements. - - PATH="$PWD/env/bin:$PATH" python3 -m pip install --prefix=/usr --root="$TARGET_APPDIR" -r "$SOURCE_DIR/../requirements.txt" - # Generate byte-code files and package them for a slightly faster app startup. - - python3 -m compileall "$TARGET_APPDIR/usr/src/"*.py - - # Recursively remove ELF debug symbols, trimming roughly 10 MB off the AppDir. - - find "$TARGET_APPDIR" -type f -print0 | xargs -0 file | grep -F 'not stripped' | cut -f1 -d':' | sort -V | xargs -r strip -s -v - -AppDir: - app_info: - id: io.github.devilxd.twitchdropsminer - name: Twitch Drops Miner - icon: io.github.devilxd.twitchdropsminer - version: '{{APP_VERSION}}' - exec: usr/bin/python3 - exec_args: '${APPDIR}/usr/src/main.py $@' - - apt: - arch: - - '{{ARCH_APT}}' - sources: - - sourceline: deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main - - sourceline: deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates main - - sourceline: deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-security main - - sourceline: deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ jammy main - - sourceline: deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ jammy-updates main - - sourceline: deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ jammy-security main - key_url: http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920D1991BC93C - - include: - - gir1.2-ayatanaappindicator3-0.1 - - python3-tk - - exclude: - - adwaita-icon-theme - - dconf-service - - glib-networking-services - - gsettings-desktop-schemas - - hicolor-icon-theme - - humanity-icon-theme - - libavahi-client3 - - libavahi-common3 - - libbrotli1 - - libcolord2 - - libcups2 - - libdb5.3 - - libdconf1 - - libgmp10 - - libgnutls30 - - libgssapi-krb5-2 - - libhogweed5 - - libicu66 - - libjson-glib-1.0-0 - - libk5crypto3 - - libkrb5-3 - - libkrb5support0 - - liblcms2-2 - - libncursesw6 - - libnettle7 - - libp11-kit0 - - libpangoxft-1.0-0 - - libpsl5 - - librest-0.7-0 - - libsoup2.4-1 - - libsoup-gnome2.4-1 - - libsqlite3-0 - - libtasn1-6 - - libtiff5 - - libtinfo6 - - libunistring2 - - libwebp6 - - libxft2 # We'll ship our own, updated version of this library. - - libxml2 - - mime-support - - readline-common - - tzdata - - xkb-data - - files: - exclude: - - etc - - usr/bin/normalizer - - usr/bin/pdb3* - - usr/bin/py3* - - usr/bin/pydoc* - - usr/bin/pygettext3* - - usr/include - - usr/lib/binfmt.d - - usr/lib/blt2.5 - - usr/lib/libBLTlite.2.5* - # The next 2 files come from our own build of libXft, and they can be removed. - - usr/lib/libXft.la - - usr/lib/pkgconfig - - usr/lib/python3 - - usr/lib/python3.11 - - usr/lib/python3*/pydoc_data - - usr/lib/python3*/test - - usr/lib/python3*/unittest - - usr/lib/valgrind - - usr/lib/*-linux-gnu/audit - - usr/lib/*-linux-gnu/engines-3 - - usr/lib/*-linux-gnu/gconv - - usr/lib/*-linux-gnu/glib-2.0 - - usr/lib/*-linux-gnu/gtk-3.0 - - usr/lib/*-linux-gnu/libgtk-3-0 - - usr/lib/*-linux-gnu/ossl-modules - - usr/sbin - - usr/share/applications - - usr/share/binfmts - - usr/share/bug - - usr/share/doc - - usr/share/doc-base - - usr/share/gcc - - usr/share/gdb - - usr/share/glib-2.0 - - usr/share/lintian - - usr/share/man - - usr/share/pixmaps - - usr/share/python3 - - usr/share/themes - - runtime: - env: - PATH: '${APPDIR}/usr/bin:${PATH}' - PYTHONHOME: '${APPDIR}/usr' - PYTHONPATH: '${APPDIR}/usr/lib/python3.10/tkinter:${APPDIR}/usr/lib/python3.10/site-packages' - APPDIR_LIBRARY_PATH: '${APPDIR}/usr/lib:${APPDIR}/usr/lib/{{ARCH}}-linux-gnu:${APPDIR}/lib/{{ARCH}}-linux-gnu' - TCL_LIBRARY: '${APPDIR}/usr/share/tcltk/tcl8.6' - TK_LIBRARY: '${APPDIR}/usr/lib/tcltk/{{ARCH}}-linux-gnu/tk8.6' - TKPATH: '${APPDIR}/usr/lib/tcltk/{{ARCH}}-linux-gnu/tk8.6' - -AppImage: - arch: '{{ARCH}}' - file_name: Twitch.Drops.Miner-{{ARCH}}.AppImage - comp: zstd - sign-key: None - update-information: guess diff --git a/appimage/pickaxe.png b/appimage/pickaxe.png deleted file mode 100644 index 40ceefa21a0db962cb2618c2ddb8361cdc842a88..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2287 zcmeH{X;2eb631T*gi{XJa0G+^9=LKOgqxrwCL)j+jv&kEMDD{FK_=l)ju2r%@BmQ} zK?tG(igAQsmiv-JV31`IlmJE-h(KU~K`xbSrnYLUcB}ULe%Pw+{#U>5Uv<}eRqu-D z3D=#<2xS1kPPgL(Z<(@xcSWcyu8MLuWr7So?o9^Z;(pnA8US0r*FOWmLIE%r0D$9N z0JNiv>b%R_`?NRqQG<`6BWd;U25tmUBsL#0P;`V z2smo|*h1m_Q9YtYtEH0u-d`^3c(@G>A-xG+IOFu8psW|jLFJcxd}O7!dF|W7{FlD- z^z92bO!hkE3mXcA6HDovTGg1}`W7XN5=oGMcZ^y01Jf7r#*=Ru!dUEip>xygNoszQ z@dSFkz-+_AqCirEnQf%SMZqTz7t9~A@jRD!y>DFb@D}Md2JO5^DRJOC#*b{?6?l{` z7T$dJP_mk1-0H+>`xw`!x}MQ-Q^#vLMNL;Pv>{G2NEx?G+nfK4OL{+zw-}|ns%5^& z>ZuoD@kYB38?sHC`c7b z`>QTUZSN&AR-&3ri*sy5=|WW}e2eB<)~k z7pu)ARb3NhIY><&loWRvYUek~ok2h~Ot^#J=2w2s=eoAJ=BQ4m0gd%volXqZEcPpFfUsvF;QUS zlyKF{Nq_7a6nrq^V1{unKTM=DV%o&b5Uk>GlIB5Q?eF(qAwF~+B`p}A627uPl0<|E zIfI_@q>$|RfDV93CL{7%)oN=qtsXMQ0(GIS>xan*u_gzt4{PuO-$q_FekI=w+F2fP zgaGWPN4tJhRM|}zj}n4ydS8BqRBSOqZnF>0ym%HY{+#ivZZ& zSKMA`MrzepbNVGjSafw(SkvF!dcXF7c(jhnp%HwJ+4t2NVFmtvI`1kMr)ZK9PqV6nPPJ8#0A{u%@r)KNtJetu z@W-mjv@7+vaH173Z#&ydr`Emhot*`6Ew)YSt?5pTBcP-CRSn1elAi>D#!oDWMd8&x zSSlnsCRbjg<4Xga1x`+PYEp15u6wv!PAW*L!qA;ts`daiFwapPSvykxGZ0Y>aCRe; zz6$TAIzN^rV-=s@hKP1OSHb$`?PGyC$gJmNzygHD)}7qbB<4~JePQgwlW*UR%RhAH zsR6w{Gmh946Jgo51Q8kB5Se*fL;&_anm+beFBAa+@>u(FUFyH9AON?kUQbA@PniYm z{G#kqA|Kg2xC5{TE|+FJm?1j@Pcc?oqo!;R88oXjkka%MzBuZ+ti|Tk*a^U1>Y~_Z^pvm?BoIYMHrAeI% z;Zl|%x2?d{^pUci(AK0!8BEzKYm!Vz5#e@R(2zR7IC+ikC4&>i8*zLAa|{$c3v}-F zh`9#9zIL1YUVwc{aX^MPt~){8@lgHCz*9ZtXs{N0^uB84#^@4b%U?N`!|3Q6%rYi@ZW*$S?(?wVEV5K%t%J)Ps~6@^nWmD8%JyVe-05` R7_yTwz>Ro Path: - """ - Get an absolute path to a bundled resource. - - Works for dev and for PyInstaller. - """ - if IS_APPIMAGE: - base_path = Path(sys.argv[0]).resolve().parent - elif IS_PACKAGED: - # PyInstaller's folder where the one-file app is unpacked - meipass: str = getattr(sys, "_MEIPASS") - base_path = Path(meipass) - else: - base_path = WORKING_DIR - return base_path.joinpath(relative_path) - - -def _merge_vars(base_vars: JsonType, vars: JsonType) -> None: - # NOTE: This modifies base in place - for k, v in vars.items(): - if k not in base_vars: - base_vars[k] = v - elif isinstance(v, dict): - if isinstance(base_vars[k], dict): - _merge_vars(base_vars[k], v) - elif base_vars[k] is Ellipsis: - # unspecified base, use the passed in var - base_vars[k] = v - else: - raise RuntimeError(f"Var is a dict, base is not: '{k}'") - elif isinstance(base_vars[k], dict): - raise RuntimeError(f"Base is a dict, var is not: '{k}'") - else: - # simple overwrite - base_vars[k] = v - # ensure none of the vars are ellipsis (unset value) - for k, v in base_vars.items(): - if v is Ellipsis: - raise RuntimeError(f"Unspecified variable: '{k}'") - - -# Base Paths -if IS_APPIMAGE: - SELF_PATH = Path(os.environ["APPIMAGE"]).resolve() -else: - # NOTE: pyinstaller will set sys.argv[0] to its own executable when building - # NOTE: sys.argv[0] will point to gui.py when running the gui.py directly for GUI debug - # detect these and use __file__ and main.py redirection instead - SELF_PATH = Path(sys.argv[0]).resolve() - if SELF_PATH.stem == "pyinstaller" or SELF_PATH.name == "gui.py": - SELF_PATH = Path(__file__).with_name("main.py").resolve() -WORKING_DIR = SELF_PATH.parent -# Development paths -VENV_PATH = Path(WORKING_DIR, "env") -SITE_PACKAGES_PATH = Path(VENV_PATH, SYS_SITE_PACKAGES) -SCRIPTS_PATH = Path(VENV_PATH, SYS_SCRIPTS) -# Translations path -# NOTE: These don't have to be available to the end-user, so the path points to the internal dir -LANG_PATH = _resource_path("lang") -# Other Paths -LOG_PATH = Path(WORKING_DIR, "log.txt") -DUMP_PATH = Path(WORKING_DIR, "dump.dat") -LOCK_PATH = Path(WORKING_DIR, "lock.file") -CACHE_PATH = Path(WORKING_DIR, "cache") -CACHE_DB = Path(CACHE_PATH, "mapping.json") -COOKIES_PATH = Path(WORKING_DIR, "cookies.jar") -SETTINGS_PATH = Path(WORKING_DIR, "settings.json") -# Typing -JsonType = Dict[str, Any] -URLType = NewType("URLType", str) -TopicProcess: TypeAlias = "abc.Callable[[int, JsonType], Any]" -# Values -MAX_INT = sys.maxsize -MAX_EXTRA_MINUTES = 15 -BASE_TOPICS = 2 -MAX_WEBSOCKETS = 8 -WS_TOPICS_LIMIT = 50 -TOPICS_PER_CHANNEL = 2 -MAX_TOPICS = (MAX_WEBSOCKETS * WS_TOPICS_LIMIT) - BASE_TOPICS -MAX_CHANNELS = MAX_TOPICS // TOPICS_PER_CHANNEL -# Misc -DEFAULT_LANG = "English" -# Intervals and Delays -PING_INTERVAL = timedelta(minutes=3) -PING_TIMEOUT = timedelta(seconds=10) -ONLINE_DELAY = timedelta(seconds=120) -WATCH_INTERVAL = timedelta(seconds=59) -# Strings -WINDOW_TITLE = f"Twitch Drops Miner v{__version__} (by DevilXD)" -# Logging -LOGGING_LEVELS = { - 0: logging.ERROR, - 1: logging.WARNING, - 2: logging.INFO, - 3: CALL, - 4: logging.DEBUG, -} -FILE_FORMATTER = logging.Formatter( - "{asctime}.{msecs:03.0f}:\t{levelname:>7}:\t{message}", - style='{', - datefmt="%Y-%m-%d %H:%M:%S", -) -OUTPUT_FORMATTER = logging.Formatter("{levelname}: {message}", style='{', datefmt="%H:%M:%S") - - -class ClientInfo: - def __init__(self, client_url: URL, client_id: str, user_agents: str | list[str]) -> None: - self.CLIENT_URL: URL = client_url - self.CLIENT_ID: str = client_id - self.USER_AGENT: str - if isinstance(user_agents, list): - self.USER_AGENT = random.choice(user_agents) - else: - self.USER_AGENT = user_agents - - def __iter__(self): - return iter((self.CLIENT_URL, self.CLIENT_ID, self.USER_AGENT)) - - -class ClientType: - WEB = ClientInfo( - URL("https://www.twitch.tv"), - "kimne78kx3ncx6brgo4mv6wki5h1ko", - ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" - ), - ) - MOBILE_WEB = ClientInfo( - URL("https://m.twitch.tv"), - "r8s4dac0uhzifbpu9sjdiwzctle17ff", - [ - # Chrome versioning is done fully on android only, - # other platforms only use the major version - ( - "Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" - ), - ( - "Mozilla/5.0 (Linux; Android 16; SM-A205U) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" - ), - ( - "Mozilla/5.0 (Linux; Android 16; SM-A102U) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" - ), - ( - "Mozilla/5.0 (Linux; Android 16; SM-G960U) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" - ), - ( - "Mozilla/5.0 (Linux; Android 16; SM-N960U) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" - ), - ( - "Mozilla/5.0 (Linux; Android 16; LM-Q720) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" - ), - ( - "Mozilla/5.0 (Linux; Android 16; LM-X420) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" - ), - ] - ) - ANDROID_APP = ClientInfo( - URL("https://www.twitch.tv"), - "kd1unb4b3q4t58fwlpcbzcbnm76a8fp", - [ - ( - "Dalvik/2.1.0 (Linux; U; Android 16; SM-S911B Build/TP1A.220624.014) " - "tv.twitch.android.app/25.3.0/2503006" - ), - ( - "Dalvik/2.1.0 (Linux; U; Android 16; SM-S938B Build/BP2A.250605.031) " - "tv.twitch.android.app/25.3.0/2503006" - ), - ( - "Dalvik/2.1.0 (Linux; Android 16; SM-X716N Build/UP1A.231005.007) " - "tv.twitch.android.app/25.3.0/2503006" - ), - ( - "Dalvik/2.1.0 (Linux; U; Android 15; SM-G990B Build/AP3A.240905.015.A2) " - "tv.twitch.android.app/25.3.0/2503006" - ), - ( - "Dalvik/2.1.0 (Linux; U; Android 15; SM-G970F Build/AP3A.241105.008) " - "tv.twitch.android.app/25.3.0/2503006" - ), - ( - "Dalvik/2.1.0 (Linux; U; Android 15; SM-A566E Build/AP3A.240905.015.A2) " - "tv.twitch.android.app/25.3.0/2503006" - ), - ( - "Dalvik/2.1.0 (Linux; U; Android 14; SM-X306B Build/UP1A.231005.007) " - "tv.twitch.android.app/25.3.0/2503006" - ), - ] - ) - SMARTBOX = ClientInfo( - URL("https://android.tv.twitch.tv"), - "ue6666qo983tsx6so1t0vnawi233wa", - ( - "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" - ), - ) - - -class State(Enum): - IDLE = auto() - INVENTORY_FETCH = auto() - GAMES_UPDATE = auto() - CHANNELS_FETCH = auto() - CHANNELS_CLEANUP = auto() - CHANNEL_SWITCH = auto() - EXIT = auto() - - -class PriorityMode(Enum): - PRIORITY_ONLY = 0 - ENDING_SOONEST = 1 - LOW_AVBL_FIRST = 2 - - -class GQLOperation(JsonType): - def __init__(self, name: str, sha256: str, *, variables: JsonType | None = None): - super().__init__( - operationName=name, - extensions={ - "persistedQuery": { - "version": 1, - "sha256Hash": sha256, - } - } - ) - if variables is not None: - self.__setitem__("variables", variables) - - def with_variables(self, variables: JsonType) -> GQLOperation: - modified = deepcopy(self) - if "variables" in self: - existing_variables: JsonType = modified["variables"] - _merge_vars(existing_variables, variables) - else: - modified["variables"] = variables - return modified - - -GQL_OPERATIONS: dict[str, GQLOperation] = { - # returns stream information for a particular channel - "GetStreamInfo": GQLOperation( - "VideoPlayerStreamInfoOverlayChannel", - "198492e0857f6aedead9665c81c5a06d67b25b58034649687124083ff288597d", - variables={ - "channel": ..., # channel login - }, - ), - # can be used to claim channel points - "ClaimCommunityPoints": GQLOperation( - "ClaimCommunityPoints", - "46aaeebe02c99afdf4fc97c7c0cba964124bf6b0af229395f1f6d1feed05b3d0", - variables={ - "input": { - "claimID": ..., # points claim_id - "channelID": ..., # channel ID as a str - }, - }, - ), - # can be used to claim a drop - "ClaimDrop": GQLOperation( - "DropsPage_ClaimDropRewards", - "a455deea71bdc9015b78eb49f4acfbce8baa7ccbedd28e549bb025bd0f751930", - variables={ - "input": { - "dropInstanceID": ..., # drop claim_id - }, - }, - ), - # returns current state of points (balance, claim available) for a particular channel - "ChannelPointsContext": GQLOperation( - "ChannelPointsContext", - "374314de591e69925fce3ddc2bcf085796f56ebb8cad67a0daa3165c03adc345", - variables={ - "channelLogin": ..., # channel login - }, - ), - # returns all in-progress campaigns - "Inventory": GQLOperation( - "Inventory", - "d86775d0ef16a63a33ad52e80eaff963b2d5b72fada7c991504a57496e1d8e4b", - variables={ - "fetchRewardCampaigns": False, - } - ), - # returns current state of drops (current drop progress) - "CurrentDrop": GQLOperation( - "DropCurrentSessionContext", - "4d06b702d25d652afb9ef835d2a550031f1cf762b193523a92166f40ea3d142b", - variables={ - "channelID": ..., # watched channel ID as a str - "channelLogin": "", # always empty string - }, - ), - # returns all available campaigns - "Campaigns": GQLOperation( - "ViewerDropsDashboard", - "5a4da2ab3d5b47c9f9ce864e727b2cb346af1e3ea8b897fe8f704a97ff017619", - variables={ - "fetchRewardCampaigns": False, - } - ), - # returns extended information about a particular campaign - "CampaignDetails": GQLOperation( - "DropCampaignDetails", - "039277bf98f3130929262cc7c6efd9c141ca3749cb6dca442fc8ead9a53f77c1", - variables={ - "channelLogin": ..., # user login - "dropID": ..., # campaign ID - }, - ), - # returns drops available for a particular channel - "AvailableDrops": GQLOperation( - "DropsHighlightService_AvailableDrops", - "9a62a09bce5b53e26e64a671e530bc599cb6aab1e5ba3cbd5d85966d3940716f", - variables={ - "channelID": ..., # channel ID as a str - }, - ), - # retuns stream playback access token - "PlaybackAccessToken": GQLOperation( - "PlaybackAccessToken", - "ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9", - variables={ - "isLive": True, - "isVod": False, - "login": ..., # channel login - "platform": "web", - "playerType": "site", - "vodID": "", - }, - ), - # returns live channels for a particular game - "GameDirectory": GQLOperation( - "DirectoryPage_Game", - "98a996c3c3ebb1ba4fd65d6671c6028d7ee8d615cb540b0731b3db2a911d3649", - variables={ - "limit": 30, # limit of channels returned - "slug": ..., # game slug - "imageWidth": 50, - "includeCostreaming": False, - "options": { - "broadcasterLanguages": [], - "freeformTags": None, - "includeRestricted": ["SUB_ONLY_LIVE"], - "recommendationsContext": {"platform": "web"}, - "sort": "RELEVANCE", # also accepted: "VIEWER_COUNT" - "systemFilters": [], - "tags": [], - "requestID": "JIRA-VXP-2397", - }, - "sortTypeIsRecency": False, - }, - ), - "SlugRedirect": GQLOperation( # can be used to turn game name -> game slug - "DirectoryGameRedirect", - "1f0300090caceec51f33c5e20647aceff9017f740f223c3c532ba6fa59f6b6cc", - variables={ - "name": ..., # game name - }, - ), - "NotificationsView": GQLOperation( # unused, triggers notifications "update-summary" - "OnsiteNotifications_View", - "e8e06193f8df73d04a1260df318585d1bd7a7bb447afa058e52095513f2bfa4f", - variables={ - "input": {}, - }, - ), - "NotificationsList": GQLOperation( # unused - "OnsiteNotifications_ListNotifications", - "11cdb54a2706c2c0b2969769907675680f02a6e77d8afe79a749180ad16bfea6", - variables={ - "cursor": "", - "displayType": "VIEWER", - "language": "en", - "limit": 10, - "shouldLoadLastBroadcast": False, - }, - ), - "NotificationsDelete": GQLOperation( - "OnsiteNotifications_DeleteNotification", - "13d463c831f28ffe17dccf55b3148ed8b3edbbd0ebadd56352f1ff0160616816", - variables={ - "input": { - "id": "", # ID of the notification to delete - } - }, - ), -} - - -class WebsocketTopic: - def __init__( - self, - category: Literal["User", "Channel"], - topic_name: str, - target_id: int, - process: TopicProcess, - ): - assert isinstance(target_id, int) - self._id: str = self.as_str(category, topic_name, target_id) - self._target_id = target_id - self._process: TopicProcess = process - - @classmethod - def as_str( - cls, category: Literal["User", "Channel"], topic_name: str, target_id: int - ) -> str: - return f"{WEBSOCKET_TOPICS[category][topic_name]}.{target_id}" - - def __call__(self, message: JsonType): - return self._process(self._target_id, message) - - def __str__(self) -> str: - return self._id - - def __repr__(self) -> str: - return f"Topic({self._id})" - - def __eq__(self, other) -> bool: - if isinstance(other, WebsocketTopic): - return self._id == other._id - elif isinstance(other, str): - return self._id == other - return NotImplemented - - def __hash__(self) -> int: - return hash((self.__class__.__name__, self._id)) - - -WEBSOCKET_TOPICS: dict[str, dict[str, str]] = { - "User": { # Using user_id - "Presence": "presence", # unused - "Drops": "user-drop-events", - "Notifications": "onsite-notifications", - "CommunityPoints": "community-points-user-v1", - }, - "Channel": { # Using channel_id - "Drops": "channel-drop-events", # unused - "StreamState": "video-playback-by-id", - "StreamUpdate": "broadcast-settings-update", - "CommunityPoints": "community-points-channel-v1", # unused - }, -} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1cc4440 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + twitch-drops-miner: + build: . + container_name: twitch-drops-miner + image: twitch-drops-miner:latest + ports: + - "8080:8080" + volumes: + # Mount data directory for persistent storage + - ./data:/app/data + environment: + # Set timezone (optional, defaults to UTC) + - TZ=UTC + # Docker environment flag + - DOCKER_ENV=1 + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/status')"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + # Optional: Limit resources + # deploy: + # resources: + # limits: + # cpus: '1' + # memory: 512M + # reservations: + # cpus: '0.5' + # memory: 256M diff --git a/gui.py b/gui.py deleted file mode 100644 index 758b8d5..0000000 --- a/gui.py +++ /dev/null @@ -1,2759 +0,0 @@ -from __future__ import annotations - -import os -import re -import sys -import ctypes -import asyncio -import logging -import tkinter as tk -from pathlib import Path -from collections import abc -from textwrap import dedent -from math import log10, ceil -from dataclasses import dataclass -from tkinter.font import Font, nametofont -from functools import partial, cached_property -from datetime import datetime, timedelta, timezone -from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar -from typing import Any, Union, Tuple, TypedDict, NoReturn, Generic, TYPE_CHECKING - -import pystray -from yarl import URL -from PIL.ImageTk import PhotoImage -from PIL import Image as Image_module - -if sys.platform == "win32": - import win32api - import win32con - import win32gui - -from translate import _ -from cache import ImageCache -from exceptions import MinerException, ExitRequest -from utils import resource_path, set_root_icon, webopen, Game, _T -from constants import ( - SELF_PATH, - IS_PACKAGED, - SCRIPTS_PATH, - WINDOW_TITLE, - LOGGING_LEVELS, - MAX_WEBSOCKETS, - WS_TOPICS_LIMIT, - OUTPUT_FORMATTER, - State, - PriorityMode, -) -if sys.platform == "win32": - from registry import RegistryKey, ValueType, ValueNotFound - - -if TYPE_CHECKING: - from twitch import Twitch - from channel import Channel - from settings import Settings - from inventory import DropsCampaign, TimedDrop - - -TK_PADDING = Union[int, Tuple[int, int], Tuple[int, int, int], Tuple[int, int, int, int]] -DIGITS = ceil(log10(WS_TOPICS_LIMIT)) - - -###################### -# GUI ELEMENTS START # -###################### - - -class _TKOutputHandler(logging.Handler): - def __init__(self, output: GUIManager): - super().__init__() - self._output = output - - def emit(self, record): - self._output.print(self.format(record)) - - -class PlaceholderEntry(ttk.Entry): - def __init__( - self, - master: ttk.Widget, - *args: Any, - placeholder: str, - prefill: str = '', - placeholdercolor: str = "grey60", - **kwargs: Any, - ): - super().__init__(master, *args, **kwargs) - self._prefill: str = prefill - self._show: str = kwargs.get("show", '') - self._text_color: str = kwargs.get("foreground", '') - self._ph_color: str = placeholdercolor - self._ph_text: str = placeholder - self.bind("", self._focus_in) - self.bind("", self._focus_out) - if isinstance(self, ttk.Combobox): - # only bind this for comboboxes - self.bind("<>", self._combobox_select) - self._ph: bool = False - self._insert_placeholder() - - def _insert_placeholder(self) -> None: - """ - If we're empty, insert a placeholder, set placeholder text color and make sure it's shown. - If we're not empty, leave the box as is. - """ - if not super().get(): - self._ph = True - super().config(foreground=self._ph_color, show='') - super().insert("end", self._ph_text) - - def _remove_placeholder(self) -> None: - """ - If we've had a placeholder, clear the box and set normal text colour and show. - """ - if self._ph: - self._ph = False - super().delete(0, "end") - super().config(foreground=self._text_color, show=self._show) - if self._prefill: - super().insert("end", self._prefill) - - def _focus_in(self, event: tk.Event[PlaceholderEntry]) -> None: - self._remove_placeholder() - - def _focus_out(self, event: tk.Event[PlaceholderEntry]) -> None: - self._insert_placeholder() - - def _combobox_select(self, event: tk.Event[PlaceholderEntry]): - # combobox clears and inserts the selected value internally, bypassing the insert method. - # disable the placeholder flag and set the color here, so _focus_in doesn't clear the entry - self._ph = False - super().config(foreground=self._text_color, show=self._show) - - def _store_option( - self, options: dict[str, object], name: str, attr: str, *, remove: bool = False - ) -> None: - if name in options: - if remove: - value = options.pop(name) - else: - value = options[name] - setattr(self, attr, value) - - def configure(self, *args: Any, **kwargs: Any) -> Any: - options: dict[str, Any] = {} - if args and args[0] is not None: - options.update(args[0]) - if kwargs: - options.update(kwargs) - self._store_option(options, "show", "_show") - self._store_option(options, "foreground", "_text_color") - self._store_option(options, "placeholder", "_ph_text", remove=True) - self._store_option(options, "prefill", "_prefill", remove=True) - self._store_option(options, "placeholdercolor", "_ph_color", remove=True) - return super().configure(**kwargs) - - def config(self, *args: Any, **kwargs: Any) -> Any: - # because 'config = configure' makes mypy complain - self.configure(*args, **kwargs) - - def get(self) -> str: - if self._ph: - return '' - return super().get() - - def insert(self, index: str | int, content: str) -> None: - # when inserting into the entry externally, disable the placeholder flag - if not content: - # if an empty string was passed in - return - self._remove_placeholder() - super().insert(index, content) - - def delete(self, first: str | int, last: str | int | None = None) -> None: - super().delete(first, last) - self._insert_placeholder() - - def clear(self) -> None: - self.delete(0, "end") - - def replace(self, content: str) -> None: - super().delete(0, "end") - self.insert("end", content) - - -class PlaceholderCombobox(PlaceholderEntry, ttk.Combobox): - pass - - -class PaddedListbox(tk.Listbox): - def __init__(self, master: ttk.Widget, *args, padding: TK_PADDING = (0, 0, 0, 0), **kwargs): - # we place the listbox inside a frame with the same background - # this means we need to forward the 'grid' method to the frame, not the listbox - self._frame = tk.Frame(master) - self._frame.rowconfigure(0, weight=1) - self._frame.columnconfigure(0, weight=1) - super().__init__(self._frame) - # mimic default listbox style with sunken relief and borderwidth of 1 - if "relief" not in kwargs: - kwargs["relief"] = "sunken" - if "borderwidth" not in kwargs: - kwargs["borderwidth"] = 1 - self.configure(*args, padding=padding, **kwargs) - - def grid(self, *args, **kwargs): - return self._frame.grid(*args, **kwargs) - - def grid_remove(self) -> None: - return self._frame.grid_remove() - - def grid_info(self) -> tk._GridInfo: - return self._frame.grid_info() - - def grid_forget(self) -> None: - return self._frame.grid_forget() - - def configure(self, *args: Any, **kwargs: Any) -> Any: - options: dict[str, Any] = {} - if args and args[0] is not None: - options.update(args[0]) - if kwargs: - options.update(kwargs) - # NOTE on processed options: - # • relief is applied to the frame only - # • background is copied, so that both listbox and frame change color - # • borderwidth is applied to the frame only - # bg is folded into background for easier processing - if "bg" in options: - options["background"] = options.pop("bg") - frame_options = {} - if "relief" in options: - frame_options["relief"] = options.pop("relief") - if "background" in options: - frame_options["background"] = options["background"] # copy - if "borderwidth" in options: - frame_options["borderwidth"] = options.pop("borderwidth") - self._frame.configure(frame_options) - # update padding - if "padding" in options: - padding: TK_PADDING = options.pop("padding") - padx1: tk._ScreenUnits - padx2: tk._ScreenUnits - pady1: tk._ScreenUnits - pady2: tk._ScreenUnits - if not isinstance(padding, tuple) or len(padding) == 1: - if isinstance(padding, tuple): - padding = padding[0] - padx1 = padx2 = pady1 = pady2 = padding - elif len(padding) == 2: - padx1 = padx2 = padding[0] - pady1 = pady2 = padding[1] - elif len(padding) == 3: - padx1, padx2 = padding[0], padding[1] - pady1 = pady2 = padding[2] - else: - padx1, padx2, pady1, pady2 = padding - super().grid(column=0, row=0, padx=(padx1, padx2), pady=(pady1, pady2), sticky="nsew") - else: - super().grid(column=0, row=0, sticky="nsew") - # listbox uses flat relief to blend in with the inside of the frame - options["relief"] = "flat" - return super().configure(options) - - def config(self, *args: Any, **kwargs: Any) -> Any: - # because 'config = configure' makes mypy complain - self.configure(*args, **kwargs) - - def configure_theme(self, *, bg: str, fg: str, sel_bg: str, sel_fg: str): - # Apply basic colors for dark/light mode - super().config(bg=bg, fg=fg, selectbackground=sel_bg, selectforeground=sel_fg) - - -class MouseOverLabel(ttk.Label): - def __init__(self, *args, alt_text: str = '', reverse: bool = False, **kwargs) -> None: - self._org_text: str = '' - self._alt_text: str = '' - self._alt_reverse: bool = reverse - self._bind_enter: str | None = None - self._bind_leave: str | None = None - super().__init__(*args, **kwargs) - self.configure(text=kwargs.get("text", ''), alt_text=alt_text, reverse=reverse) - - def _set_org(self, event: tk.Event[MouseOverLabel]): - super().config(text=self._org_text) - - def _set_alt(self, event: tk.Event[MouseOverLabel]): - super().config(text=self._alt_text) - - def configure(self, *args: Any, **kwargs: Any) -> Any: - options: dict[str, Any] = {} - if args and args[0] is not None: - options.update(args[0]) - if kwargs: - options.update(kwargs) - applicable_options: set[str] = set(( - "text", - "reverse", - "alt_text", - )) - if applicable_options.intersection(options.keys()): - # we need to pop some options, because they can't be passed down to the label, - # as that will result in an error later down the line - events_change: bool = False - if "text" in options: - if bool(self._org_text) != bool(options["text"]): - events_change = True - self._org_text = options["text"] - if "alt_text" in options: - if bool(self._alt_text) != bool(options["alt_text"]): - events_change = True - self._alt_text = options.pop("alt_text") - if "reverse" in options: - if bool(self._alt_reverse) != bool(options["reverse"]): - events_change = True - self._alt_reverse = options.pop("reverse") - if self._org_text and not self._alt_text: - options["text"] = self._org_text - elif (not self._org_text or self._alt_reverse) and self._alt_text: - options["text"] = self._alt_text - if events_change: - if self._bind_enter is not None: - self.unbind(self._bind_enter) - self._bind_enter = None - if self._bind_leave is not None: - self.unbind(self._bind_leave) - self._bind_leave = None - if self._org_text and self._alt_text: - if self._alt_reverse: - self._bind_enter = self.bind("", self._set_org) - self._bind_leave = self.bind("", self._set_alt) - else: - self._bind_enter = self.bind("", self._set_alt) - self._bind_leave = self.bind("", self._set_org) - return super().configure(options) - - def config(self, *args: Any, **kwargs: Any) -> Any: - # because 'config = configure' makes mypy complain - self.configure(*args, **kwargs) - - -class LinkLabel(ttk.Label): - def __init__(self, *args, link: str, **kwargs) -> None: - self._link: str = link - # style provides font and foreground color - if "style" not in kwargs: - kwargs["style"] = "Link.TLabel" - elif not kwargs["style"]: - super().__init__(*args, **kwargs) - return - if "cursor" not in kwargs: - kwargs["cursor"] = "hand2" - if "padding" not in kwargs: - # W, N, E, S - kwargs["padding"] = (0, 2, 0, 2) - super().__init__(*args, **kwargs) - self.bind("", lambda e: webopen(self._link)) - - -class SelectMenu(tk.Menubutton, Generic[_T]): - def __init__( - self, - master: tk.Misc, - *args: Any, - tearoff: bool = False, - options: dict[str, _T], - command: abc.Callable[[_T], Any] | None = None, - default: str | None = None, - relief: tk._Relief = "solid", - background: str = "white", - **kwargs: Any, - ): - width = max((len(k) for k in options.keys()), default=20) - super().__init__( - master, *args, background=background, relief=relief, width=width, **kwargs - ) - self._menu_options: dict[str, _T] = options - self._command = command - self.menu = tk.Menu(self, tearoff=tearoff) - self.config(menu=self.menu) - for name in options.keys(): - self.menu.add_command(label=name, command=partial(self._select, name)) - if default is not None and default in self._menu_options: - self.config(text=default) - - def _select(self, option: str) -> None: - self.config(text=option) - if self._command is not None: - self._command(self._menu_options[option]) - - def get(self) -> _T: - return self._menu_options[self.cget("text")] - - -class SelectCombobox(ttk.Combobox): - def __init__( - self, - master: tk.Misc, - *args, - width_offset: int = 0, - width: int | None = None, - textvariable: tk.StringVar, - values: list[str] | tuple[str, ...], - command: abc.Callable[[tk.Event[SelectCombobox]], None] | None = None, - **kwargs, - ) -> None: - if width is None: - font = Font(master, ttk.Style().lookup("TCombobox", "font")) - # font.measure returns width in pixels, using '0' as the average character, - # which is 6 pixels wide. We can convert it to width in characters by dividing. - width = max(font.measure(v) // 6 + 1 for v in values) - width += width_offset - super().__init__( - master, - *args, - width=width, - values=values, - state="readonly", - exportselection=False, - textvariable=textvariable, - **kwargs, - ) - if command is not None: - self.bind("<>", command) - - -########################################### -# GUI ELEMENTS END / GUI DEFINITION START # -########################################### - - -class StatusBar: - def __init__(self, manager: GUIManager, master: ttk.Widget): - frame = ttk.LabelFrame(master, text=_("gui", "status", "name"), padding=(4, 0, 4, 4)) - frame.grid(column=0, row=0, columnspan=3, sticky="nsew", padx=2) - self._label = ttk.Label(frame) - self._label.grid(column=0, row=0, sticky="nsew") - - def update(self, text: str): - self._label.config(text=text) - - def clear(self): - self._label.config(text='') - - -class _WSEntry(TypedDict): - status: str - topics: int - - -class WebsocketStatus: - def __init__(self, manager: GUIManager, master: ttk.Widget): - frame = ttk.LabelFrame(master, text=_("gui", "websocket", "name"), padding=(4, 0, 4, 4)) - frame.grid(column=0, row=1, sticky="nsew", padx=2) - self._status_var = StringVar(frame) - self._topics_var = StringVar(frame) - ttk.Label( - frame, - text='\n'.join( - _("gui", "websocket", "websocket").format(id=i) - for i in range(1, MAX_WEBSOCKETS + 1) - ), - style="MS.TLabel", - ).grid(column=0, row=0) - ttk.Label( - frame, - textvariable=self._status_var, - width=16, - justify="left", - style="MS.TLabel", - ).grid(column=1, row=0) - ttk.Label( - frame, - textvariable=self._topics_var, - width=(DIGITS * 2 + 1), - justify="right", - style="MS.TLabel", - ).grid(column=2, row=0) - self._items: dict[int, _WSEntry | None] = {i: None for i in range(MAX_WEBSOCKETS)} - self._update() - - def update(self, idx: int, status: str | None = None, topics: int | None = None): - if status is None and topics is None: - raise TypeError("You need to provide at least one of: status, topics") - entry = self._items.get(idx) - if entry is None: - entry = self._items[idx] = _WSEntry( - status=_("gui", "websocket", "disconnected"), topics=0 - ) - if status is not None: - entry["status"] = status - if topics is not None: - entry["topics"] = topics - self._update() - - def remove(self, idx: int): - if idx in self._items: - del self._items[idx] - self._update() - - def _update(self): - status_lines: list[str] = [] - topic_lines: list[str] = [] - for idx in range(MAX_WEBSOCKETS): - if (item := self._items.get(idx)) is None: - status_lines.append('') - topic_lines.append('') - else: - status_lines.append(item["status"]) - topic_lines.append(f"{item['topics']:>{DIGITS}}/{WS_TOPICS_LIMIT}") - self._status_var.set('\n'.join(status_lines)) - self._topics_var.set('\n'.join(topic_lines)) - - -@dataclass -class LoginData: - username: str - password: str - token: str - - -class LoginForm: - def __init__(self, manager: GUIManager, master: ttk.Widget): - self._manager = manager - self._var = StringVar(master) - frame = ttk.LabelFrame(master, text=_("gui", "login", "name"), padding=(4, 0, 4, 4)) - frame.grid(column=1, row=1, sticky="nsew", padx=2) - frame.columnconfigure(0, weight=2) - frame.columnconfigure(1, weight=1) - frame.rowconfigure(4, weight=1) - ttk.Label(frame, text=_("gui", "login", "labels")).grid(column=0, row=0) - ttk.Label(frame, textvariable=self._var, justify="center").grid(column=1, row=0) - self._login_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "username")) - # self._login_entry.grid(column=0, row=1, columnspan=2) - self._pass_entry = PlaceholderEntry( - frame, placeholder=_("gui", "login", "password"), show='•' - ) - # self._pass_entry.grid(column=0, row=2, columnspan=2) - self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code")) - # self._token_entry.grid(column=0, row=3, columnspan=2) - - self._confirm = asyncio.Event() - self._button = ttk.Button( - frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled" - ) - self._button.grid(column=0, row=4, columnspan=2) - self.update(_("gui", "login", "logged_out"), None) - - def clear(self, login: bool = False, password: bool = False, token: bool = False): - clear_all = not login and not password and not token - if login or clear_all: - self._login_entry.clear() - if password or clear_all: - self._pass_entry.clear() - if token or clear_all: - self._token_entry.clear() - - async def wait_for_login_press(self) -> None: - self._confirm.clear() - try: - self._button.config(state="normal") - await self._manager.coro_unless_closed(self._confirm.wait()) - finally: - self._button.config(state="disabled") - - async def ask_login(self) -> LoginData: - self.update(_("gui", "login", "required"), None) - # ensure the window isn't hidden into tray when this runs - self._manager.grab_attention(sound=False) - while True: - self._manager.print(_("gui", "login", "request")) - await self.wait_for_login_press() - login_data = LoginData( - self._login_entry.get().strip(), - self._pass_entry.get(), - self._token_entry.get().strip(), - ) - # basic input data validation: 3-25 characters in length, only ascii and underscores - if ( - not 3 <= len(login_data.username) <= 25 - and re.match(r'^[a-zA-Z0-9_]+$', login_data.username) - ): - self.clear(login=True) - continue - if len(login_data.password) < 8: - self.clear(password=True) - continue - if login_data.token and len(login_data.token) < 6: - self.clear(token=True) - continue - return login_data - - async def ask_enter_code(self, page_url: URL, user_code: str) -> None: - self.update(_("gui", "login", "required"), None) - # ensure the window isn't hidden into tray when this runs - self._manager.grab_attention(sound=False) - self._manager.print(_("gui", "login", "request")) - await self.wait_for_login_press() - self._manager.print(f"Enter this code on the Twitch's device activation page: {user_code}") - await asyncio.sleep(4) - webopen(page_url) - - def update(self, status: str, user_id: int | None): - if user_id is not None: - user_str = str(user_id) - else: - user_str = "-" - self._var.set(f"{status}\n{user_str}") - - -class _BaseVars(TypedDict): - progress: DoubleVar - percentage: StringVar - remaining: StringVar - - -class _CampaignVars(_BaseVars): - name: StringVar - game: StringVar - - -class _DropVars(_BaseVars): - rewards: StringVar - - -class _ProgressVars(TypedDict): - campaign: _CampaignVars - drop: _DropVars - - -class CampaignProgress: - BAR_LENGTH = 420 - ALMOST_DONE_SECONDS = 10 - - def __init__(self, manager: GUIManager, master: ttk.Widget): - self._manager = manager - self._vars: _ProgressVars = { - "campaign": { - "name": StringVar(master), # campaign name - "game": StringVar(master), # game name - "progress": DoubleVar(master), # controls the progress bar - "percentage": StringVar(master), # percentage display string - "remaining": StringVar(master), # time remaining string, filled via _update_time - }, - "drop": { - "rewards": StringVar(master), # drop rewards - "progress": DoubleVar(master), # as above - "percentage": StringVar(master), # as above - "remaining": StringVar(master), # as above - }, - } - self._frame = frame = ttk.LabelFrame( - master, text=_("gui", "progress", "name"), padding=(4, 0, 4, 4) - ) - frame.grid(column=0, row=2, columnspan=2, sticky="nsew", padx=2) - frame.columnconfigure(0, weight=2) - frame.columnconfigure(1, weight=1) - game_campaign = ttk.Frame(frame) - game_campaign.grid(column=0, row=0, columnspan=2, sticky="nsew") - game_campaign.columnconfigure(0, weight=1) - game_campaign.columnconfigure(1, weight=1) - ttk.Label(game_campaign, text=_("gui", "progress", "game")).grid(column=0, row=0) - ttk.Label(game_campaign, textvariable=self._vars["campaign"]["game"]).grid(column=0, row=1) - ttk.Label(game_campaign, text=_("gui", "progress", "campaign")).grid(column=1, row=0) - ttk.Label(game_campaign, textvariable=self._vars["campaign"]["name"]).grid(column=1, row=1) - ttk.Label( - frame, text=_("gui", "progress", "campaign_progress") - ).grid(column=0, row=2, rowspan=2) - ttk.Label(frame, textvariable=self._vars["campaign"]["percentage"]).grid(column=1, row=2) - ttk.Label(frame, textvariable=self._vars["campaign"]["remaining"]).grid(column=1, row=3) - ttk.Progressbar( - frame, - mode="determinate", - length=self.BAR_LENGTH, - maximum=1, - variable=self._vars["campaign"]["progress"], - ).grid(column=0, row=4, columnspan=2) - ttk.Separator( - frame, orient="horizontal" - ).grid(row=5, columnspan=2, sticky="ew", pady=(4, 0)) - ttk.Label(frame, text=_("gui", "progress", "drop")).grid(column=0, row=6, columnspan=2) - ttk.Label( - frame, textvariable=self._vars["drop"]["rewards"] - ).grid(column=0, row=7, columnspan=2) - ttk.Label( - frame, text=_("gui", "progress", "drop_progress") - ).grid(column=0, row=8, rowspan=2) - ttk.Label(frame, textvariable=self._vars["drop"]["percentage"]).grid(column=1, row=8) - ttk.Label(frame, textvariable=self._vars["drop"]["remaining"]).grid(column=1, row=9) - ttk.Progressbar( - frame, - mode="determinate", - length=self.BAR_LENGTH, - maximum=1, - variable=self._vars["drop"]["progress"], - ).grid(column=0, row=10, columnspan=2) - self._drop: TimedDrop | None = None - self._seconds: int = 0 - self._timer_task: asyncio.Task[None] | None = None - self.display(None) - - def _divmod(self, minutes: int) -> tuple[int, int]: - if self._seconds < 60 and minutes > 0: - minutes -= 1 - hours, minutes = divmod(minutes, 60) - return (hours, minutes) - - def _update_time(self, seconds: int | None = None): - if seconds is not None: - self._seconds = seconds - drop = self._drop - if drop is not None: - drop_minutes = drop.remaining_minutes - campaign_minutes = drop.campaign.remaining_minutes - else: - drop_minutes = 0 - campaign_minutes = 0 - drop_vars: _DropVars = self._vars["drop"] - campaign_vars: _CampaignVars = self._vars["campaign"] - dseconds = self._seconds % 60 - hours, minutes = self._divmod(drop_minutes) - drop_vars["remaining"].set( - _("gui", "progress", "remaining").format(time=f"{hours:>2}:{minutes:02}:{dseconds:02}") - ) - hours, minutes = self._divmod(campaign_minutes) - campaign_vars["remaining"].set( - _("gui", "progress", "remaining").format(time=f"{hours:>2}:{minutes:02}:{dseconds:02}") - ) - - async def _timer_loop(self): - self._update_time(60) - while self._seconds > 0: - await asyncio.sleep(1) - self._seconds -= 1 - self._update_time() - self._timer_task = None - - def start_timer(self): - if self._timer_task is None: - if self._drop is None or self._drop.remaining_minutes <= 0: - # if we're starting the timer at 0 drop minutes, - # all we need is a single instant time update setting seconds to 60, - # to avoid substracting a minute from campaign minutes - self._update_time(60) - else: - self._timer_task = asyncio.create_task(self._timer_loop()) - - def stop_timer(self): - if self._timer_task is not None: - self._timer_task.cancel() - self._timer_task = None - - def minute_almost_done(self) -> bool: - # already or almost done - return self._timer_task is None or self._seconds <= self.ALMOST_DONE_SECONDS - - def display(self, drop: TimedDrop | None, *, countdown: bool = True, subone: bool = False): - self._drop = drop - vars_drop = self._vars["drop"] - vars_campaign = self._vars["campaign"] - self.stop_timer() - if drop is None: - # clear the drop display - vars_drop["rewards"].set("...") - vars_drop["progress"].set(0.0) - vars_drop["percentage"].set("-%") - vars_campaign["name"].set("...") - vars_campaign["game"].set("...") - vars_campaign["progress"].set(0.0) - vars_campaign["percentage"].set("-%") - self._update_time(0) - return - vars_drop["rewards"].set(drop.rewards_text()) - vars_drop["progress"].set(drop.progress) - vars_drop["percentage"].set(f"{drop.progress:6.1%}") - campaign = drop.campaign - vars_campaign["name"].set(campaign.name) - vars_campaign["game"].set(campaign.game.name) - vars_campaign["progress"].set(campaign.progress) - vars_campaign["percentage"].set( - f"{campaign.progress:6.1%} ({campaign.claimed_drops}/{campaign.total_drops})" - ) - if countdown: - # restart our seconds update timer - self.start_timer() - elif subone: - # display the current remaining time at 0 seconds (after substracting the minute) - # this is because the watch loop will substract this minute - # right after the first watch payload returns with a time update - self._update_time(0) - else: - # display full time with no substracting - self._update_time(60) - - -class ConsoleOutput: - def __init__(self, manager: GUIManager, master: ttk.Widget): - frame = ttk.LabelFrame(master, text=_("gui", "output"), padding=(4, 0, 4, 4)) - frame.grid(column=0, row=3, columnspan=3, sticky="nsew", padx=2) - # tell master frame that the containing row can expand - master.rowconfigure(3, weight=1) - frame.rowconfigure(0, weight=1) # let the frame expand - frame.columnconfigure(0, weight=1) - xscroll = ttk.Scrollbar(frame, orient="horizontal") - yscroll = ttk.Scrollbar(frame, orient="vertical") - self._text = tk.Text( - frame, - width=52, - height=10, - wrap="none", - state="disabled", - exportselection=False, - xscrollcommand=xscroll.set, - yscrollcommand=yscroll.set, - ) - xscroll.config(command=self._text.xview) - yscroll.config(command=self._text.yview) - self._text.grid(column=0, row=0, sticky="nsew") - xscroll.grid(column=0, row=1, sticky="ew") - yscroll.grid(column=1, row=0, sticky="ns") - - def print(self, message: str): - stamp = datetime.now().strftime("%X") - if '\n' in message: - message = message.replace('\n', f"\n{stamp}: ") - self._text.config(state="normal") - self._text.insert("end", f"{stamp}: {message}\n") - self._text.see("end") # scroll to the newly added line - self._text.config(state="disabled") - - def configure_theme(self, *, bg: str, fg: str, sel_bg: str, sel_fg: str): - # Apply colors to the Tk Text widget used for console output - self._text.config( - bg=bg, - fg=fg, - insertbackground=fg, - selectbackground=sel_bg, - selectforeground=sel_fg, - ) - - -class _Buttons(TypedDict): - frame: ttk.Frame - switch: ttk.Button - - -class ChannelList: - def __init__(self, manager: GUIManager, master: ttk.Widget): - self._manager = manager - frame = ttk.LabelFrame(master, text=_("gui", "channels", "name"), padding=(4, 0, 4, 4)) - frame.grid(column=2, row=1, rowspan=2, sticky="nsew", padx=2) - # tell master frame that the containing column can expand - master.columnconfigure(2, weight=1) - frame.rowconfigure(1, weight=1) - frame.columnconfigure(0, weight=1) - buttons_frame = ttk.Frame(frame) - self._buttons: _Buttons = { - "frame": buttons_frame, - "switch": ttk.Button( - buttons_frame, - text=_("gui", "channels", "switch"), - state="disabled", - command=manager._twitch.state_change(State.CHANNEL_SWITCH), - ), - } - buttons_frame.grid(column=0, row=0, columnspan=2) - self._buttons["switch"].grid(column=0, row=0) - scroll = ttk.Scrollbar(frame, orient="vertical") - self._table = table = ttk.Treeview( - frame, - # columns definition is updated by _add_column - yscrollcommand=scroll.set, - ) - scroll.config(command=table.yview) - table.grid(column=0, row=1, sticky="nsew") - scroll.grid(column=1, row=1, sticky="ns") - self._font = Font(frame, manager._style.lookup("Treeview", "font")) - self._const_width: set[str] = set() - table.tag_configure("watching", background="gray70") - table.bind("", self._disable_column_resize) - table.bind("<>", self._selected) - self._add_column("#0", '', width=0) - self._add_column( - "channel", _("gui", "channels", "headings", "channel"), width=100, anchor='w' - ) - self._add_column( - "status", - _("gui", "channels", "headings", "status"), - width_template=[ - _("gui", "channels", "online"), - _("gui", "channels", "pending"), - _("gui", "channels", "offline"), - ], - ) - self._add_column("game", _("gui", "channels", "headings", "game"), width=50) - self._add_column("drops", "🎁", width_template="✔") - self._add_column( - "viewers", _("gui", "channels", "headings", "viewers"), width_template="1234567" - ) - self._add_column("acl_base", "📋", width_template="✔") - self._channel_map: dict[str, Channel] = {} - - def _add_column( - self, - cid: str, - name: str, - *, - anchor: tk._Anchor = "center", - width: int | None = None, - width_template: str | list[str] | None = None, - ): - table = self._table - # NOTE: we don't do this for the icon column - if cid != "#0": - # we need to save the column settings and headings before modifying the columns... - columns: tuple[str, ...] = table.cget("columns") or () - column_settings: dict[str, tuple[str, tk._Anchor, int, int]] = {} - for s_cid in columns: - s_column = table.column(s_cid) - assert s_column is not None - s_heading = table.heading(s_cid) - assert s_heading is not None - column_settings[s_cid] = ( - s_heading["text"], s_heading["anchor"], s_column["width"], s_column["minwidth"] - ) - # ..., then add the column - table.config(columns=columns + (cid,)) - # ..., and then restore column settings and headings afterwards - for s_cid, (s_name, s_anchor, s_width, s_minwidth) in column_settings.items(): - table.heading(s_cid, text=s_name, anchor=s_anchor) - table.column(s_cid, minwidth=s_minwidth, width=s_width, stretch=False) - # set heading and column settings for the new column - if width_template is not None: - if isinstance(width_template, str): - width = self._measure(width_template) - else: - width = max((self._measure(template) for template in width_template), default=20) - self._const_width.add(cid) - assert width is not None - table.heading(cid, text=name, anchor=anchor) - table.column(cid, minwidth=width, width=width, stretch=False) - - def _disable_column_resize(self, event): - if self._table.identify_region(event.x, event.y) == "separator": - return "break" - - def _selected(self, event): - selection = self._table.selection() - if selection: - self._buttons["switch"].config(state="normal") - else: - self._buttons["switch"].config(state="disabled") - - def _measure(self, text: str) -> int: - # we need this because columns have 9-10 pixels of padding that cuts text off - return self._font.measure(text) + 10 - - def _redraw(self): - # this forces a redraw that recalculates widget width - self._table.event_generate("<>") - - def _adjust_width(self, column: str, value: str): - # causes the column to expand if the value's width is greater than the current width - if column in self._const_width: - return - value_width = self._measure(value) - curr_width = self._table.column(column, "width") - if value_width > curr_width: - self._table.column(column, width=value_width) - self._redraw() - - def shrink(self): - # causes the columns to shrink back after long values have been removed from it - columns = self._table.cget("columns") - iids = self._table.get_children() - for column in columns: - if column in self._const_width: - continue - if iids: - # table has at least one item - width = max(self._measure(self._table.set(i, column)) for i in iids) - self._table.column(column, width=width) - else: - # no items - use minwidth - minwidth = self._table.column(column, "minwidth") - self._table.column(column, width=minwidth) - self._redraw() - - def _set(self, iid: str, column: str, value: str): - self._table.set(iid, column, value) - self._adjust_width(column, value) - - def _insert(self, iid: str, values: dict[str, str]): - to_insert: list[str] = [] - for cid in self._table.cget("columns"): - value = values[cid] - to_insert.append(value) - self._adjust_width(cid, value) - self._table.insert(parent='', index="end", iid=iid, values=to_insert) - - def clear_watching(self): - for iid in self._table.tag_has("watching"): - self._table.item(iid, tags='') - - def set_watching(self, channel: Channel): - self.clear_watching() - iid = channel.iid - self._table.item(iid, tags="watching") - self._table.see(iid) - - def get_selection(self) -> Channel | None: - if not self._channel_map: - return None - selection = self._table.selection() - if not selection: - return None - return self._channel_map[selection[0]] - - def clear_selection(self): - self._table.selection_set('') - - def clear(self): - iids = self._table.get_children() - self._table.delete(*iids) - self._channel_map.clear() - self.shrink() - - def display(self, channel: Channel, *, add: bool = False): - iid = channel.iid - if not add and iid not in self._channel_map: - # the channel isn't on the list and we're not supposed to add it - return - # ACL-based - acl_based = "✔" if channel.acl_based else "❌" - # status - if channel.online: - status = _("gui", "channels", "online") - elif channel.pending_online: - status = _("gui", "channels", "pending") - else: - status = _("gui", "channels", "offline") - # game - game = str(channel.game or '') - # drops - drops = "✔" if channel.drops_enabled else "❌" - # viewers - viewers = '' - if channel.viewers is not None: - viewers = str(channel.viewers) - if iid in self._channel_map: - self._set(iid, "game", game) - self._set(iid, "drops", drops) - self._set(iid, "status", status) - self._set(iid, "viewers", viewers) - self._set(iid, "acl_base", acl_based) - elif add: - self._channel_map[iid] = channel - self._insert( - iid, - { - "game": game, - "drops": drops, - "status": status, - "viewers": viewers, - "acl_base": acl_based, - "channel": channel.name, - }, - ) - - def remove(self, channel: Channel): - iid = channel.iid - del self._channel_map[iid] - self._table.delete(iid) - - -class TrayIcon: - TITLE = "Twitch Drops Miner" - - def __init__(self, manager: GUIManager, master: ttk.Widget): - self._manager = manager - self.icon: pystray.Icon | None = None # type: ignore[unused-ignore] - self._icon_images: dict[str, Image_module.Image] = { - "pickaxe": Image_module.open(resource_path("icons/pickaxe.ico")), - "active": Image_module.open(resource_path("icons/active.ico")), - "idle": Image_module.open(resource_path("icons/idle.ico")), - "error": Image_module.open(resource_path("icons/error.ico")), - "maint": Image_module.open(resource_path("icons/maint.ico")), - } - self._icon_state: str = "pickaxe" - self._button = ttk.Button(master, command=self.minimize, text=_("gui", "tray", "minimize")) - self._button.grid(column=0, row=0, sticky="ne") - - def __del__(self) -> None: - self.stop() - for icon_image in self._icon_images.values(): - icon_image.close() - - def _shorten(self, text: str, by_len: int, min_len: int) -> str: - if (text_len := len(text)) <= min_len + 3 or by_len <= 0: - # cannot shorten - return text - return text[:-min(by_len + 3, text_len - min_len)] + "..." - - def get_title(self, drop: TimedDrop | None) -> str: - if drop is None: - return self.TITLE - campaign = drop.campaign - title_parts: list[str] = [ - f"{self.TITLE}\n", - f"{campaign.game.name}\n", - drop.rewards_text(), - f" {drop.progress:.1%} ({campaign.claimed_drops}/{campaign.total_drops})" - ] - min_len: int = 30 - max_len: int = 127 - missing_len = len(''.join(title_parts)) - max_len - if missing_len > 0: - # try shortening the reward text - title_parts[2] = self._shorten(title_parts[2], missing_len, min_len) - missing_len = len(''.join(title_parts)) - max_len - if missing_len > 0: - # try shortening the game name - title_parts[1] = self._shorten(title_parts[1], missing_len, min_len) - missing_len = len(''.join(title_parts)) - max_len - if missing_len > 0: - raise MinerException(f"Title couldn't be shortened: {''.join(title_parts)}") - return ''.join(title_parts) - - def _start(self): - loop = asyncio.get_running_loop() - drop = self._manager.progress._drop - - # we need this because tray icon lives in a separate thread - def bridge(func): - return lambda: loop.call_soon_threadsafe(func) - - menu = pystray.Menu( - pystray.MenuItem(_("gui", "tray", "show"), bridge(self.restore), default=True), - pystray.Menu.SEPARATOR, - pystray.MenuItem(_("gui", "tray", "quit"), bridge(self.quit)), - ) - self.icon = pystray.Icon( - "twitch_miner", self._icon_images[self._icon_state], self.get_title(drop), menu - ) - # self.icon.run_detached() - loop.run_in_executor(None, self.icon.run) - - def stop(self): - if self.icon is not None: - self.icon.stop() - self.icon = None - - def quit(self): - self._manager.close() - - def minimize(self): - if self.icon is None: - self._start() - else: - self.icon.visible = True - self._manager._root.withdraw() - - def restore(self): - if self.icon is not None: - # self.stop() - self.icon.visible = False - self._manager._root.deiconify() - - def notify( - self, message: str, title: str | None = None, duration: float = 10 - ) -> asyncio.Task[None] | None: - # do nothing if the user disabled notifications - if not self._manager._twitch.settings.tray_notifications: - return None - if self.icon is not None: - icon = self.icon # nonlocal scope bind - - async def notifier(): - icon.notify(message, title) - await asyncio.sleep(duration) - icon.remove_notification() - - return asyncio.create_task(notifier()) - return None - - def update_title(self, drop: TimedDrop | None): - if self.icon is not None: - self.icon.title = self.get_title(drop) - - def change_icon(self, state: str): - if state not in self._icon_images: - raise ValueError("Invalid icon state") - self._icon_state = state - if self.icon is not None: - self.icon.icon = self._icon_images[state] - - -class Notebook: - def __init__(self, manager: GUIManager, master: ttk.Widget): - self._nb = ttk.Notebook(master) - self._nb.grid(column=0, row=0, sticky="nsew") - master.rowconfigure(0, weight=1) - master.columnconfigure(0, weight=1) - # prevent entries from being selected after switching tabs - self._nb.bind("<>", lambda event: manager._root.focus_set()) - - def add_tab(self, widget: ttk.Widget, *, name: str, **kwargs): - kwargs.pop("text", None) - if "sticky" not in kwargs: - kwargs["sticky"] = "nsew" - self._nb.add(widget, text=name, **kwargs) - - def current_tab(self) -> int: - return self._nb.index("current") - - def add_view_event(self, callback: abc.Callable[[tk.Event[ttk.Notebook]], Any]): - self._nb.bind("<>", callback, True) - - -class CampaignDisplay(TypedDict): - frame: ttk.Frame - status: ttk.Label - - -class InventoryOverview: - def __init__(self, manager: GUIManager, master: ttk.Widget): - self._manager = manager - self._cache: ImageCache = manager._cache - self._settings: Settings = manager._twitch.settings - self._filters = { - "not_linked": IntVar( - master, self._settings.priority_mode is PriorityMode.PRIORITY_ONLY - ), - "upcoming": IntVar(master, 1), - "expired": IntVar(master, 0), - "excluded": IntVar(master, 0), - "finished": IntVar(master, 0), - } - manager.tabs.add_view_event(self._on_tab_switched) - # Filtering options - filter_frame = ttk.LabelFrame( - master, text=_("gui", "inventory", "filter", "name"), padding=(4, 0, 4, 4) - ) - LABEL_SPACING = 20 - filter_frame.grid(column=0, row=0, columnspan=2, sticky="nsew") - ttk.Label( - filter_frame, text=_("gui", "inventory", "filter", "show"), padding=(0, 0, 10, 0) - ).grid(column=0, row=0) - icolumn = 0 - ttk.Checkbutton( - filter_frame, variable=self._filters["not_linked"] - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Label( - filter_frame, - text=_("gui", "inventory", "filter", "not_linked"), - padding=(0, 0, LABEL_SPACING, 0), - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Checkbutton( - filter_frame, variable=self._filters["upcoming"] - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Label( - filter_frame, - text=_("gui", "inventory", "filter", "upcoming"), - padding=(0, 0, LABEL_SPACING, 0), - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Checkbutton( - filter_frame, variable=self._filters["expired"] - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Label( - filter_frame, - text=_("gui", "inventory", "filter", "expired"), - padding=(0, 0, LABEL_SPACING, 0), - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Checkbutton( - filter_frame, variable=self._filters["excluded"] - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Label( - filter_frame, - text=_("gui", "inventory", "filter", "excluded"), - padding=(0, 0, LABEL_SPACING, 0), - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Checkbutton( - filter_frame, variable=self._filters["finished"] - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Label( - filter_frame, - text=_("gui", "inventory", "filter", "finished"), - padding=(0, 0, LABEL_SPACING, 0), - ).grid(column=(icolumn := icolumn + 1), row=0) - ttk.Button( - filter_frame, text=_("gui", "inventory", "filter", "refresh"), command=self.refresh - ).grid(column=(icolumn := icolumn + 1), row=0) - # Inventory view - self._canvas = tk.Canvas(master, scrollregion=(0, 0, 0, 0)) - self._canvas.grid(column=0, row=1, sticky="nsew") - master.rowconfigure(1, weight=1) - master.columnconfigure(0, weight=1) - xscroll = ttk.Scrollbar(master, orient="horizontal", command=self._canvas.xview) - xscroll.grid(column=0, row=2, sticky="ew") - yscroll = ttk.Scrollbar(master, orient="vertical", command=self._canvas.yview) - yscroll.grid(column=1, row=1, sticky="ns") - self._canvas.configure(xscrollcommand=xscroll.set, yscrollcommand=yscroll.set) - self._canvas.bind("", self._canvas_update) - self._main_frame = ttk.Frame(self._canvas) - self._canvas.bind( - "", lambda e: self._canvas.bind_all("", self._on_mousewheel) - ) - self._canvas.bind("", lambda e: self._canvas.unbind_all("")) - self._canvas.create_window(0, 0, anchor="nw", window=self._main_frame) - self._campaigns: dict[DropsCampaign, CampaignDisplay] = {} - self._drops: dict[str, ttk.Label] = {} - - def configure_theme(self, *, bg: str): - # Canvas background needs manual control - self._canvas.configure(bg=bg) - - def _update_visibility(self, campaign: DropsCampaign): - # True if the campaign is supposed to show, False makes it hidden. - frame = self._campaigns[campaign]["frame"] - not_linked = bool(self._filters["not_linked"].get()) - expired = bool(self._filters["expired"].get()) - excluded = bool(self._filters["excluded"].get()) - upcoming = bool(self._filters["upcoming"].get()) - finished = bool(self._filters["finished"].get()) - priority_only = self._settings.priority_mode is PriorityMode.PRIORITY_ONLY - if ( - campaign.required_minutes > 0 # don't show sub-only campaigns - and (not_linked or campaign.eligible) - and (campaign.active or upcoming and campaign.upcoming or expired and campaign.expired) - and ( - excluded or ( - campaign.game.name not in self._settings.exclude - and not priority_only or campaign.game.name in self._settings.priority - ) - ) - and (finished or not campaign.finished) - ): - frame.grid() - else: - frame.grid_remove() - - def _on_tab_switched(self, event: tk.Event[ttk.Notebook]) -> None: - if self._manager.tabs.current_tab() == 1: - # refresh only if we're switching to the tab - self.refresh() - - def get_status(self, campaign: DropsCampaign) -> tuple[str, str]: - if campaign.active: - status_text: str = _("gui", "inventory", "status", "active") - status_color: str = "green" - elif campaign.upcoming: - status_text = _("gui", "inventory", "status", "upcoming") - status_color = "goldenrod" - else: - status_text = _("gui", "inventory", "status", "expired") - status_color = "red" - return (status_text, status_color) - - def refresh(self): - for campaign in self._campaigns: - # status - status_label = self._campaigns[campaign]["status"] - status_text, status_color = self.get_status(campaign) - status_label.config(text=status_text, foreground=status_color) - # visibility - self._update_visibility(campaign) - self._canvas_update() - - def _canvas_update(self, event: tk.Event[tk.Canvas] | None = None): - self._canvas.update_idletasks() - self._canvas.configure(scrollregion=self._canvas.bbox("all")) - - def _on_mousewheel(self, event: tk.Event[tk.Misc]): - delta = -1 if event.delta > 0 else 1 - state: int = event.state if isinstance(event.state, int) else 0 - if state & 1: - scroll = self._canvas.xview_scroll - else: - scroll = self._canvas.yview_scroll - scroll(delta, "units") - - async def add_campaign(self, campaign: DropsCampaign) -> None: - campaign_frame = ttk.Frame(self._main_frame, relief="ridge", borderwidth=1, padding=4) - campaign_frame.grid(column=0, row=len(self._campaigns), sticky="nsew", pady=3) - campaign_frame.rowconfigure(4, weight=1) - campaign_frame.columnconfigure(1, weight=1) - campaign_frame.columnconfigure(3, weight=10000) - # Name - ttk.Label( - campaign_frame, text=campaign.name, takefocus=False, width=45 - ).grid(column=0, row=0, columnspan=2, sticky="w") - # Status - status_text, status_color = self.get_status(campaign) - status_label = ttk.Label( - campaign_frame, text=status_text, takefocus=False, foreground=status_color - ) - status_label.grid(column=1, row=1, sticky="w", padx=4) - # NOTE: We have to save the campaign's frame and status before any awaits happen, - # otherwise the len(self._campaigns) call may overwrite an existing frame, - # if the campaigns are added concurrently. - self._campaigns[campaign] = { - "frame": campaign_frame, - "status": status_label, - } - # Starts / Ends - MouseOverLabel( - campaign_frame, - text=_("gui", "inventory", "ends").format( - time=campaign.ends_at.astimezone().replace(microsecond=0, tzinfo=None) - ), - alt_text=_("gui", "inventory", "starts").format( - time=campaign.starts_at.astimezone().replace(microsecond=0, tzinfo=None) - ), - reverse=campaign.upcoming, - takefocus=False, - ).grid(column=1, row=2, sticky="w", padx=4) - # Linking status - if campaign.eligible: - link_kwargs = { - "style": '', - "text": _("gui", "inventory", "status", "linked"), - "foreground": "green", - } - else: - link_kwargs = { - "text": _("gui", "inventory", "status", "not_linked"), - "foreground": "red", - } - LinkLabel( - campaign_frame, - link=campaign.link_url, - takefocus=False, - padding=0, - **link_kwargs, - ).grid(column=1, row=3, sticky="w", padx=4) - # ACL channels - acl = campaign.allowed_channels - if acl: - if len(acl) <= 5: - allowed_text: str = '\n'.join(ch.name for ch in acl) - else: - allowed_text = '\n'.join(ch.name for ch in acl[:4]) - allowed_text += ( - f"\n{_('gui', 'inventory', 'and_more').format(amount=len(acl) - 4)}" - ) - else: - allowed_text = _("gui", "inventory", "all_channels") - ttk.Label( - campaign_frame, - text=f"{_('gui', 'inventory', 'allowed_channels')}\n{allowed_text}", - takefocus=False, - ).grid(column=1, row=4, sticky="nw", padx=4) - # Image - campaign_image = await self._cache.get(campaign.image_url, size=(108, 144)) - ttk.Label(campaign_frame, image=campaign_image).grid(column=0, row=1, rowspan=4) - # Drops separator - ttk.Separator( - campaign_frame, orient="vertical", takefocus=False - ).grid(column=2, row=0, rowspan=5, sticky="ns") - # Drops display - drops_row = ttk.Frame(campaign_frame) - drops_row.grid(column=3, row=0, rowspan=5, sticky="nsew", padx=4) - drops_row.rowconfigure(0, weight=1) - for i, drop in enumerate(campaign.drops): - drop_frame = ttk.Frame(drops_row, relief="ridge", borderwidth=1, padding=5) - drop_frame.grid(column=i, row=0, padx=4) - benefits_frame = ttk.Frame(drop_frame) - benefits_frame.grid(column=0, row=0) - benefit_images: list[PhotoImage] = await asyncio.gather( - *(self._cache.get(benefit.image_url, (80, 80)) for benefit in drop.benefits) - ) - for i, benefit, image in zip(range(len(drop.benefits)), drop.benefits, benefit_images): - ttk.Label( - benefits_frame, - text=benefit.name, - image=image, - compound="bottom", - ).grid(column=i, row=0, padx=5) - self._drops[drop.id] = label = ttk.Label(drop_frame, justify=tk.CENTER) - self.update_progress(drop, label) - label.grid(column=0, row=1) - if self._manager.tabs.current_tab() == 1: - self._update_visibility(campaign) - self._canvas_update() - - def clear(self) -> None: - for child in self._main_frame.winfo_children(): - child.destroy() - self._drops.clear() - self._campaigns.clear() - - def update_progress(self, drop: TimedDrop, label: ttk.Label) -> None: - progress_text: str - progress_color: str = '' - if drop.is_claimed: - progress_color = "green" - progress_text = _("gui", "inventory", "status", "claimed") - elif drop.can_claim: - progress_color = "goldenrod" - progress_text = _("gui", "inventory", "status", "ready_to_claim") - elif drop.current_minutes or drop.can_earn(): - progress_text = _("gui", "inventory", "percent_progress").format( - percent=f"{drop.progress:3.1%}", - minutes=drop.required_minutes, - ) - if drop.ends_at < drop.campaign.ends_at: - # this drop becomes unavailable earlier than the campaign ends - progress_text += '\n' + _("gui", "inventory", "ends").format( - time=drop.ends_at.astimezone().replace(microsecond=0, tzinfo=None) - ) - else: - if drop.required_minutes > 0: - progress_text = _("gui", "inventory", "minutes_progress").format( - minutes=drop.required_minutes - ) - else: - # required_minutes is zero for subscription-based drops - progress_text = '' - if datetime.now(timezone.utc) < drop.starts_at > drop.campaign.starts_at: - # this drop can only be earned later than the campaign start - progress_text += '\n' + _("gui", "inventory", "starts").format( - time=drop.starts_at.astimezone().replace(microsecond=0, tzinfo=None) - ) - elif drop.ends_at < drop.campaign.ends_at: - # this drop becomes unavailable earlier than the campaign ends - progress_text += '\n' + _("gui", "inventory", "ends").format( - time=drop.ends_at.astimezone().replace(microsecond=0, tzinfo=None) - ) - label.config(text=progress_text, foreground=progress_color) - - def update_drop(self, drop: TimedDrop) -> None: - label = self._drops.get(drop.id) - if label is None: - return - self.update_progress(drop, label) - - -def proxy_validate(entry: PlaceholderEntry, settings: Settings) -> bool: - raw_url = entry.get().strip() - entry.replace(raw_url) - url = URL(raw_url) - valid = url.host is not None and url.port is not None - if not valid: - entry.clear() - url = URL() - settings.proxy = url - return valid - - -class _SettingsVars(TypedDict): - tray: IntVar - proxy: StringVar - autostart: IntVar - dark_mode: IntVar - language: StringVar - priority_mode: StringVar - tray_notifications: IntVar - - -class SettingsPanel: - AUTOSTART_NAME: str = "TwitchDropsMiner" - AUTOSTART_KEY: str = "HKCU/Software/Microsoft/Windows/CurrentVersion/Run" - - @cached_property - def PRIORITY_MODES(self) -> dict[PriorityMode, str]: - # NOTE: Translation calls have to be deferred here, - # to allow changing the language before the settings panel is initialized. - return { - PriorityMode.PRIORITY_ONLY: _("gui", "settings", "priority_modes", "priority_only"), - PriorityMode.ENDING_SOONEST: _("gui", "settings", "priority_modes", "ending_soonest"), - PriorityMode.LOW_AVBL_FIRST: _( - "gui", "settings", "priority_modes", "low_availability" - ), - } - - def __init__(self, manager: GUIManager, master: ttk.Widget): - self._manager = manager - self._settings: Settings = manager._twitch.settings - priority_mode = self._settings.priority_mode - if priority_mode not in self.PRIORITY_MODES: - priority_mode = PriorityMode.PRIORITY_ONLY - self._settings.priority_mode = priority_mode - self._vars: _SettingsVars = { - "autostart": IntVar(master, 0), - "language": StringVar(master, _.current), - "proxy": StringVar(master, str(self._settings.proxy)), - "tray": IntVar(master, self._settings.autostart_tray), - "dark_mode": IntVar(master, int(self._settings.dark_mode)), - "priority_mode": StringVar(master, self.PRIORITY_MODES[priority_mode]), - "tray_notifications": IntVar(master, self._settings.tray_notifications), - } - self._game_names: set[str] = set() - master.rowconfigure(0, weight=1) - master.columnconfigure(0, weight=1) - # use a frame to center the content within the tab - center_frame = ttk.Frame(master) - center_frame.grid(column=0, row=0) - # General section - general_frame = ttk.LabelFrame( - center_frame, padding=(4, 0, 4, 4), text=_("gui", "settings", "general", "name") - ) - general_frame.grid(column=0, row=0, sticky="nsew") - # use another frame to center the options within the section - # NOTE: this can be adjusted or removed later on if more options were to be added - general_frame.rowconfigure(0, weight=1) - general_frame.columnconfigure(0, weight=1) - center_frame2 = ttk.Frame(general_frame) - center_frame2.grid(column=0, row=0) - - # language frame - language_frame = ttk.Frame(center_frame2) - language_frame.grid(column=0, row=0) - ttk.Label(language_frame, text="Language 🌐 (requires restart): ").grid(column=0, row=0) - SelectCombobox( - language_frame, - values=list(_.languages), - textvariable=self._vars["language"], - command=lambda e: setattr(self._settings, "language", self._vars["language"].get()), - ).grid(column=1, row=0) - - # checkboxes frame - checkboxes_frame = ttk.Frame(center_frame2) - checkboxes_frame.grid(column=0, row=1) - ttk.Label( - checkboxes_frame, text=_("gui", "settings", "general", "autostart") - ).grid(column=0, row=(irow := 0), sticky="e") - ttk.Checkbutton( - checkboxes_frame, variable=self._vars["autostart"], command=self.update_autostart - ).grid(column=1, row=irow, sticky="w") - ttk.Label( - checkboxes_frame, text=_("gui", "settings", "general", "tray") - ).grid(column=0, row=(irow := irow + 1), sticky="e") - ttk.Checkbutton( - checkboxes_frame, variable=self._vars["tray"], command=self.update_autostart - ).grid(column=1, row=irow, sticky="w") - ttk.Label( - checkboxes_frame, text=_("gui", "settings", "general", "tray_notifications") - ).grid(column=0, row=(irow := irow + 1), sticky="e") - ttk.Checkbutton( - checkboxes_frame, - variable=self._vars["tray_notifications"], - command=self.update_notifications, - ).grid(column=1, row=irow, sticky="w") - ttk.Label( - checkboxes_frame, text=_("gui", "settings", "general", "dark_mode") - ).grid(column=0, row=(irow := irow + 1), sticky="e") - ttk.Checkbutton( - checkboxes_frame, - variable=self._vars["dark_mode"], - command=self.update_dark_mode, - ).grid(column=1, row=irow, sticky="w") - ttk.Label( - checkboxes_frame, text=_("gui", "settings", "general", "priority_mode") - ).grid(column=0, row=(irow := irow + 1), sticky="e") - SelectCombobox( - checkboxes_frame, - command=self.priority_mode, - textvariable=self._vars["priority_mode"], - values=list(self.PRIORITY_MODES.values()), - ).grid(column=1, row=irow, sticky="w") - - # proxy frame - proxy_frame = ttk.Frame(center_frame2) - proxy_frame.grid(column=0, row=2) - ttk.Label(proxy_frame, text=_("gui", "settings", "general", "proxy")).grid(column=0, row=0) - self._proxy = PlaceholderEntry( - proxy_frame, - width=37, - validate="focusout", - prefill="http://", - textvariable=self._vars["proxy"], - placeholder="http://username:password@address:port", - ) - self._proxy.config(validatecommand=partial(proxy_validate, self._proxy, self._settings)) - self._proxy.grid(column=0, row=1) - # Priority section - priority_frame = ttk.LabelFrame( - center_frame, padding=(4, 0, 4, 4), text=_("gui", "settings", "priority") - ) - priority_frame.grid(column=1, row=0, sticky="nsew") - self._priority_entry = PlaceholderCombobox( - priority_frame, placeholder=_("gui", "settings", "game_name"), width=30 - ) - self._priority_entry.grid(column=0, row=0, sticky="ew") - priority_frame.columnconfigure(0, weight=1) - ttk.Button( - priority_frame, text="➕", command=self.priority_add, width=3, style="Large.TButton" - ).grid(column=1, row=0) - self._priority_list = PaddedListbox( - priority_frame, - height=10, - padding=(1, 0), - activestyle="none", - selectmode="single", - highlightthickness=0, - exportselection=False, - ) - self._priority_list.grid(column=0, row=1, rowspan=3, sticky="nsew") - self._priority_list.insert("end", *self._settings.priority) - ttk.Button( - priority_frame, - width=2, - text="▲", - style="Large.TButton", - command=partial(self.priority_move, True), - ).grid(column=1, row=1, sticky="nsew") - priority_frame.rowconfigure(1, weight=1) - ttk.Button( - priority_frame, - width=2, - text="▼", - style="Large.TButton", - command=partial(self.priority_move, False), - ).grid(column=1, row=2, sticky="nsew") - priority_frame.rowconfigure(2, weight=1) - ttk.Button( - priority_frame, text="❌", command=self.priority_delete, width=3, style="Large.TButton" - ).grid(column=1, row=3, sticky="ns") - priority_frame.rowconfigure(3, weight=1) - # Exclude section - exclude_frame = ttk.LabelFrame( - center_frame, padding=(4, 0, 4, 4), text=_("gui", "settings", "exclude") - ) - exclude_frame.grid(column=2, row=0, sticky="nsew") - self._exclude_entry = PlaceholderCombobox( - exclude_frame, placeholder=_("gui", "settings", "game_name"), width=26 - ) - self._exclude_entry.grid(column=0, row=0, sticky="ew") - ttk.Button( - exclude_frame, text="➕", command=self.exclude_add, width=3, style="Large.TButton" - ).grid(column=1, row=0) - self._exclude_list = PaddedListbox( - exclude_frame, - height=10, - padding=(1, 0), - activestyle="none", - selectmode="single", - highlightthickness=0, - exportselection=False, - ) - self._exclude_list.grid(column=0, row=1, columnspan=2, sticky="nsew") - exclude_frame.rowconfigure(1, weight=1) - # insert them alphabetically - self._exclude_list.insert("end", *sorted(self._settings.exclude)) - ttk.Button( - exclude_frame, text="❌", command=self.exclude_delete, width=3, style="Large.TButton" - ).grid(column=0, row=2, columnspan=2, sticky="ew") - # Reload button - reload_frame = ttk.Frame(center_frame) - reload_frame.grid(column=0, row=1, columnspan=3, pady=4) - ttk.Label(reload_frame, text=_("gui", "settings", "reload_text")).grid(column=0, row=0) - ttk.Button( - reload_frame, - text=_("gui", "settings", "reload"), - command=self._manager._twitch.state_change(State.INVENTORY_FETCH), - ).grid(column=1, row=0) - - self._vars["autostart"].set(self._query_autostart()) - - def clear_selection(self) -> None: - self._priority_list.selection_clear(0, "end") - self._exclude_list.selection_clear(0, "end") - - def update_dark_mode(self) -> None: - self._settings.dark_mode = bool(self._vars["dark_mode"].get()) - self._settings.alter() - self._manager.apply_theme(self._settings.dark_mode) - - def update_notifications(self) -> None: - self._settings.tray_notifications = bool(self._vars["tray_notifications"].get()) - - def _get_self_path(self) -> str: - # NOTE: we need double quotes in case the path contains spaces - return f'"{SELF_PATH.resolve()!s}"' - - def _get_autostart_path(self) -> str: - flags: list[str] = [] - # if applicable, include the current logging level as well - for lvl_idx, lvl_value in LOGGING_LEVELS.items(): - if lvl_value == self._settings.logging_level: - if lvl_idx > 0: - flags.append(f"-{'v' * lvl_idx}") - break - if self._vars["tray"].get(): - flags.append("--tray") - if not IS_PACKAGED: - # non-packaged autostart has to be done through the venv path pythonw - return f"\"{SCRIPTS_PATH / 'pythonw'!s}\" {self._get_self_path()} {' '.join(flags)}" - return f"{self._get_self_path()} {' '.join(flags)}" - - def _get_linux_autostart_filepath(self) -> Path: - autostart_folder: Path = Path("~/.config/autostart").expanduser() - if (config_home := os.environ.get("XDG_CONFIG_HOME")) is not None: - config_autostart: Path = Path(config_home, "autostart").expanduser() - if config_autostart.exists(): - autostart_folder = config_autostart - return autostart_folder / f"{self.AUTOSTART_NAME}.desktop" - - def _query_autostart(self) -> bool: - if sys.platform == "win32": - with RegistryKey(self.AUTOSTART_KEY, read_only=True) as key: - try: - value_type, value = key.get(self.AUTOSTART_NAME) - except ValueNotFound: - return False - # TODO: Consider deleting the old value to avoid autostart errors - return ( - value_type is ValueType.REG_SZ - and self._get_self_path() in value - ) - elif sys.platform == "linux": - autostart_file: Path = self._get_linux_autostart_filepath() - if not autostart_file.exists(): - return False - with autostart_file.open('r', encoding="utf8") as file: - # TODO: Consider deleting the old file to avoid autostart errors - return self._get_self_path() not in file.read() - - def update_autostart(self) -> None: - enabled = bool(self._vars["autostart"].get()) - self._settings.autostart_tray = bool(self._vars["tray"].get()) - if sys.platform == "win32": - if enabled: - with RegistryKey(self.AUTOSTART_KEY) as key: - key.set( - self.AUTOSTART_NAME, - ValueType.REG_SZ, - self._get_autostart_path(), - ) - else: - with RegistryKey(self.AUTOSTART_KEY) as key: - key.delete(self.AUTOSTART_NAME, silent=True) - elif sys.platform == "linux": - autostart_file: Path = self._get_linux_autostart_filepath() - if enabled: - file_contents: str = dedent( - f""" - [Desktop Entry] - Type=Application - Name=Twitch Drops Miner - Description=Mine timed drops on Twitch - Exec=sh -c '{self._get_autostart_path()}' - """ - ) - with autostart_file.open('w', encoding="utf8") as file: - file.write(file_contents) - else: - autostart_file.unlink(missing_ok=True) - - def update_excluded_choices(self) -> None: - self._exclude_entry.config( - values=sorted(self._game_names.difference(self._settings.exclude)) - ) - - def update_priority_choices(self) -> None: - self._priority_entry.config( - values=sorted(self._game_names.difference(self._settings.priority)) - ) - - def set_games(self, games: set[Game]) -> None: - self._game_names.update(game.name for game in games) - self.update_excluded_choices() - self.update_priority_choices() - - def priority_add(self) -> None: - game_name: str = self._priority_entry.get() - if not game_name: - # prevent adding empty strings - return - self._priority_entry.clear() - # add it preventing duplicates - try: - existing_idx: int = self._settings.priority.index(game_name) - except ValueError: - # not there, add it - self._priority_list.insert("end", game_name) - self._priority_list.see("end") - self._settings.priority.append(game_name) - self._settings.alter() - self.update_priority_choices() - else: - # already there, set the selection on it - self._priority_list.selection_set(existing_idx) - self._priority_list.see(existing_idx) - - def _priority_idx(self) -> int | None: - selection: tuple[int, ...] = self._priority_list.curselection() - if not selection: - return None - return selection[0] - - def priority_move(self, up: bool) -> None: - idx: int | None = self._priority_idx() - if idx is None: - return - if up and idx == 0 or not up and idx == self._priority_list.size() - 1: - return - swap_idx: int = idx - 1 if up else idx + 1 - item: str = self._priority_list.get(idx) - self._priority_list.delete(idx) - self._priority_list.insert(swap_idx, item) - # reselect the item and scroll the list if needed - self._priority_list.selection_set(swap_idx) - self._priority_list.see(swap_idx) - p = self._settings.priority - p[idx], p[swap_idx] = p[swap_idx], p[idx] - self._settings.alter() - - def priority_delete(self) -> None: - idx: int | None = self._priority_idx() - if idx is None: - return - self._priority_list.delete(idx) - del self._settings.priority[idx] - self._settings.alter() - self.update_priority_choices() - - def priority_mode(self, event: tk.Event[ttk.Combobox]) -> None: - mode_name: str = self._vars["priority_mode"].get() - for value, name in self.PRIORITY_MODES.items(): - if mode_name == name: - self._settings.priority_mode = value - break - - def exclude_add(self) -> None: - game_name: str = self._exclude_entry.get() - if not game_name: - # prevent adding empty strings - return - self._exclude_entry.clear() - if game_name not in self._settings.exclude: - self._settings.exclude.add(game_name) - self._settings.alter() - self.update_excluded_choices() - # insert it alphabetically - for i, item in enumerate(self._exclude_list.get(0, "end")): - if game_name < item: - self._exclude_list.insert(i, game_name) - self._exclude_list.see(i) - break - else: - self._exclude_list.insert("end", game_name) - self._exclude_list.see("end") - else: - # it was already there, select it - for i, item in enumerate(self._exclude_list.get(0, "end")): - if item == game_name: - existing_idx = i - break - else: - # something went horribly wrong and it's not there after all - just return - return - self._exclude_list.selection_set(existing_idx) - self._exclude_list.see(existing_idx) - - def exclude_delete(self) -> None: - selection: tuple[int, ...] = self._exclude_list.curselection() - if not selection: - return None - idx: int = selection[0] - item: str = self._exclude_list.get(idx) - if item in self._settings.exclude: - self._exclude_list.delete(idx) - self._settings.exclude.discard(item) - self._settings.alter() - self.update_excluded_choices() - - -class HelpTab: - WIDTH = 800 - - def __init__(self, manager: GUIManager, master: ttk.Widget): - self._twitch = manager._twitch - master.rowconfigure(0, weight=1) - master.columnconfigure(0, weight=1) - # use a frame to center the content within the tab - center_frame = ttk.Frame(master) - center_frame.grid(column=0, row=0) - irow = 0 - # About - about = ttk.LabelFrame(center_frame, padding=(4, 0, 4, 4), text="About") - about.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2) - about.columnconfigure(2, weight=1) - # About - created by - ttk.Label( - about, text="Application created by: ", anchor="e" - ).grid(column=0, row=0, sticky="nsew") - LinkLabel( - about, link="https://github.com/DevilXD", text="DevilXD" - ).grid(column=1, row=0, sticky="nsew") - # About - repo link - ttk.Label(about, text="Repository: ", anchor="e").grid(column=0, row=1, sticky="nsew") - LinkLabel( - about, - link="https://github.com/DevilXD/TwitchDropsMiner", - text="https://github.com/DevilXD/TwitchDropsMiner", - ).grid(column=1, row=1, sticky="nsew") - # About - donate - ttk.Separator( - about, orient="horizontal" - ).grid(column=0, row=2, columnspan=3, sticky="nsew") - ttk.Label(about, text="Donate: ", anchor="e").grid(column=0, row=3, sticky="nsew") - LinkLabel( - about, - link="https://www.buymeacoffee.com/DevilXD", - text=( - "If you like the application and found it useful, " - "please consider donating a small amount of money to support me. Thank you!" - ), - wraplength=self.WIDTH, - ).grid(column=1, row=3, sticky="nsew") - # Useful links - links = ttk.LabelFrame( - center_frame, padding=(4, 0, 4, 4), text=_("gui", "help", "links", "name") - ) - links.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2) - LinkLabel( - links, - link="https://www.twitch.tv/drops/inventory", - text=_("gui", "help", "links", "inventory"), - ).grid(column=0, row=0, sticky="nsew") - LinkLabel( - links, - link="https://www.twitch.tv/drops/campaigns", - text=_("gui", "help", "links", "campaigns"), - ).grid(column=0, row=1, sticky="nsew") - # How It Works - howitworks = ttk.LabelFrame( - center_frame, padding=(4, 0, 4, 4), text=_("gui", "help", "how_it_works") - ) - howitworks.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2) - ttk.Label( - howitworks, text=_("gui", "help", "how_it_works_text"), wraplength=self.WIDTH - ).grid(sticky="nsew") - getstarted = ttk.LabelFrame( - center_frame, padding=(4, 0, 4, 4), text=_("gui", "help", "getting_started") - ) - getstarted.grid(column=0, row=(irow := irow + 1), sticky="nsew", padx=2) - ttk.Label( - getstarted, text=_("gui", "help", "getting_started_text"), wraplength=self.WIDTH - ).grid(sticky="nsew") - - -########################################## -# GUI DEFINITION END / GUI MANAGER START # -########################################## - - -class GUIManager: - def __init__(self, twitch: Twitch): - self._twitch: Twitch = twitch - self._poll_task: asyncio.Task[NoReturn] | None = None - self._close_requested = asyncio.Event() - self._root = root = Tk(className=WINDOW_TITLE) - # withdraw immediately to prevent the window from flashing - self._root.withdraw() - # root.resizable(False, True) - set_root_icon(root, resource_path("icons/pickaxe.ico")) - root.title(WINDOW_TITLE) # window title - root.bind_all("", self.unfocus) # pressing ESC unfocuses selection - # Image cache for displaying images - self._cache = ImageCache(self) - - # style adjustements - self._style = style = ttk.Style(root) - # theme - theme = '' - # theme = style.theme_names()[6] - # style.theme_use(theme) - # fix treeview's background color from tags not working (also see '_fixed_map') - style.map( - "Treeview", - foreground=self._fixed_map("foreground"), - background=self._fixed_map("background"), - ) - # add padding to the tab names - style.configure("TNotebook.Tab", padding=[8, 4]) - # Skip these for classic theme or macOS - if theme != "classic" and sys.platform != "darwin": - # remove Notebook.focus from the Notebook.Tab layout tree to avoid an ugly dotted line - # on tab selection. We fold the Notebook.focus children into Notebook.padding children. - original = style.layout("TNotebook.Tab") - sublayout = original[0][1]["children"][0][1] - sublayout["children"] = sublayout["children"][0][1]["children"] - style.layout("TNotebook.Tab", original) - # remove Checkbutton.focus dotted line from checkbuttons - style.configure("TCheckbutton", padding=0) - original = style.layout("TCheckbutton") - sublayout = original[0][1]["children"] - sublayout[1] = sublayout[1][1]["children"][0] - del original[0][1]["children"][1] - style.layout("TCheckbutton", original) - # label style - green, yellow and red text - style.configure("green.TLabel", foreground="green") - style.configure("yellow.TLabel", foreground="goldenrod") - style.configure("red.TLabel", foreground="red") - # fonts - default_font = nametofont("TkDefaultFont") - self._fonts: dict[str, Font] = { - "default": default_font, - "large": default_font.copy(), - "monospaced": default_font.copy(), - "underlined": default_font.copy(), - } - self._fonts["large"].config(size=10) - self._fonts["underlined"].config(underline=True) - self._fonts["monospaced"].config(family="Courier New", size=10) - # label style with a monospace font - style.configure("MS.TLabel", font=self._fonts["monospaced"]) - # button style with a larger font - style.configure("Large.TButton", font=self._fonts["large"]) - # label style that mimics links - style.configure("Link.TLabel", font=self._fonts["underlined"], foreground="blue") - # end of style changes - - root_frame = ttk.Frame(root, padding=8) - root_frame.grid(column=0, row=0, sticky="nsew") - root.rowconfigure(0, weight=1) - root.columnconfigure(0, weight=1) - # Notebook - self.tabs = Notebook(self, root_frame) - # Tray icon - place after notebook so it draws on top of the tabs space - self.tray = TrayIcon(self, root_frame) - # Main tab - main_frame = ttk.Frame(root_frame, padding=8) - self.tabs.add_tab(main_frame, name=_("gui", "tabs", "main")) - self.status = StatusBar(self, main_frame) - self.websockets = WebsocketStatus(self, main_frame) - self.login = LoginForm(self, main_frame) - self.progress = CampaignProgress(self, main_frame) - self.output = ConsoleOutput(self, main_frame) - self.channels = ChannelList(self, main_frame) - # Inventory tab - inv_frame = ttk.Frame(root_frame, padding=8) - self.inv = InventoryOverview(self, inv_frame) - self.tabs.add_tab(inv_frame, name=_("gui", "tabs", "inventory")) - # Settings tab - settings_frame = ttk.Frame(root_frame, padding=8) - self.settings = SettingsPanel(self, settings_frame) - self.tabs.add_tab(settings_frame, name=_("gui", "tabs", "settings")) - # Help tab - help_frame = ttk.Frame(root_frame, padding=8) - self.help = HelpTab(self, help_frame) - self.tabs.add_tab(help_frame, name=_("gui", "tabs", "help")) - # clamp minimum window size (update geometry first) - root.update_idletasks() - root.minsize(width=root.winfo_reqwidth(), height=root.winfo_reqheight()) - # register logging handler - self._handler = _TKOutputHandler(self) - self._handler.setFormatter(OUTPUT_FORMATTER) - logger = logging.getLogger("TwitchDrops") - logger.addHandler(self._handler) - if (logging_level := logger.getEffectiveLevel()) < logging.ERROR: - self.print(f"Logging level: {logging.getLevelName(logging_level)}") - # gracefully handle Windows shutdown closing the application - if sys.platform == "win32": - # NOTE: this root.update() is required for the below to work - don't remove - root.update() - self._message_map = { - # window close request - win32con.WM_CLOSE: self.close, - # shutdown request - win32con.WM_QUERYENDSESSION: self.close, - } - # This hooks up the wnd_proc function as the message processor for the root window. - self.old_wnd_proc = win32gui.SetWindowLong( - self._handle, win32con.GWL_WNDPROC, self.wnd_proc - ) - # This ensures all of this works when the application is withdrawn or iconified - ctypes.windll.user32.ShutdownBlockReasonCreate( - self._handle, ctypes.c_wchar_p(_("gui", "status", "exiting")) - ) - # DEV NOTE: use this to remove the reason in the future - # ctypes.windll.user32.ShutdownBlockReasonDestroy(self._handle) - else: - # use old-style window closing protocol for non-windows platforms - root.protocol("WM_DELETE_WINDOW", self.close) - root.protocol("WM_DESTROY_WINDOW", self.close) - # Save current theme and apply palette after widgets are created - try: - self._orig_theme_name = self._style.theme_use() - except Exception: - self._orig_theme_name = '' - self.apply_theme(self._twitch.settings.dark_mode) - # stay hidden in tray if needed, otherwise show the window when everything's ready - if self._twitch.settings.tray: - # NOTE: this starts the tray icon thread - self._root.after_idle(self.tray.minimize) - else: - self._root.after_idle(self._root.deiconify) - - # https://stackoverflow.com/questions/56329342/tkinter-treeview-background-tag-not-working - def _fixed_map(self, option): - # Fix for setting text colour for Tkinter 8.6.9 - # From: https://core.tcl.tk/tk/info/509cafafae - # - # Returns the style map for 'option' with any styles starting with - # ('!disabled', '!selected', ...) filtered out. - - # style.map() returns an empty list for missing options, so this - # should be future-safe. - return [ - elm for elm in self._style.map("Treeview", query_opt=option) - if elm[:2] != ("!disabled", "!selected") - ] - - def wnd_proc(self, hwnd, msg, w_param, l_param): - """ - This function serves as a message processor for all messages sent - to the application by Windows. - """ - if msg == win32con.WM_DESTROY: - win32api.SetWindowLong(self._handle, win32con.GWL_WNDPROC, self.old_wnd_proc) - if msg in self._message_map: - return self._message_map[msg](w_param, l_param) - return win32gui.CallWindowProc(self.old_wnd_proc, hwnd, msg, w_param, l_param) - - @cached_property - def _handle(self) -> int: - return int(self._root.wm_frame(), 16) - - @property - def running(self) -> bool: - return self._poll_task is not None - - @property - def close_requested(self) -> bool: - return self._close_requested.is_set() - - async def wait_until_closed(self): - # wait until the user closes the window - await self._close_requested.wait() - - async def coro_unless_closed(self, coro: abc.Awaitable[_T]) -> _T: - # In Python 3.11, we need to explicitly wrap awaitables - tasks = [asyncio.ensure_future(coro), asyncio.ensure_future(self._close_requested.wait())] - done: set[asyncio.Task[Any]] - pending: set[asyncio.Task[Any]] - done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - for task in pending: - task.cancel() - if self._close_requested.is_set(): - raise ExitRequest() - return await next(iter(done)) - - def prevent_close(self): - self._close_requested.clear() - - def start(self): - if self._poll_task is None: - self._poll_task = asyncio.create_task(self._poll()) - # self.progress.start_timer() - - def stop(self): - self.progress.stop_timer() - if self._poll_task is not None: - self._poll_task.cancel() - self._poll_task = None - - async def _poll(self): - """ - This runs the Tkinter event loop via asyncio instead of calling mainloop. - 0.05s gives similar performance and CPU usage. - Not ideal, but the simplest way to avoid threads, thread safety, - loop.call_soon_threadsafe, futures and all of that. - """ - update = self._root.update - while True: - try: - update() - except tk.TclError: - # root has been destroyed - break - await asyncio.sleep(0.05) - self._poll_task = None - - def close(self, *args) -> int: - """ - Requests the GUI application to close. - The window itself will be closed in the closing sequence later. - """ - self._close_requested.set() - # notify client we're supposed to close - self._twitch.close() - return 0 - - def close_window(self): - """ - Closes the window. Invalidates the logger. - """ - self.tray.stop() - logging.getLogger("TwitchDrops").removeHandler(self._handler) - self._root.destroy() - - def unfocus(self, event): - # support pressing ESC to unfocus - self._root.focus_set() - self.channels.clear_selection() - self.settings.clear_selection() - - # these are here to interface with underlaying GUI components - def save(self, *, force: bool = False) -> None: - self._cache.save(force=force) - - def grab_attention(self, *, sound: bool = True): - self.tray.restore() - self._root.focus_set() - if sound: - self._root.bell() - - def set_games(self, games: set[Game]) -> None: - self.settings.set_games(games) - - def display_drop( - self, drop: TimedDrop, *, countdown: bool = True, subone: bool = False - ) -> None: - self.progress.display(drop, countdown=countdown, subone=subone) # main tab - # inventory overview is updated from within drops themselves via change events - self.tray.update_title(drop) # tray - - def clear_drop(self): - self.progress.display(None) - self.tray.update_title(None) - - def print(self, message: str): - # print to our custom output - self.output.print(message) - - def apply_theme(self, dark: bool) -> None: - """ - Apply dark/light palette to ttk styles and Tk widgets in a minimal, non-invasive way. - """ - # Palette - if dark: - # Switch to a configurable ttk theme for better color control - if self._style.theme_use() != "clam": - self._style.theme_use("clam") - bg = "#1e1e1e" - fg = "#e6e6e6" - sel_bg = "#094771" - sel_fg = "#ffffff" - link = "#4ea3ff" - surface = "#252525" - header = "#2a2a2a" - fieldbg = "#2b2b2b" - border = "#3c3c3c" - muted = "#b3b3b3" - accent = "#0d99ff" - else: - # Restore original theme if we changed it - if getattr(self, "_orig_theme_name", '') and self._style.theme_use() == "clam": - self._style.theme_use(self._orig_theme_name) - # Use platform defaults but ensure toggling back is readable - bg = "#f0f0f0" - fg = "#000000" - sel_bg = "#cce5ff" - sel_fg = "#000000" - link = "blue" - surface = "#ffffff" - header = "#eeeeee" - fieldbg = "#ffffff" - border = "#cccccc" - muted = "#404040" - accent = "#0a84ff" - - s = self._style - # Base containers and labels - s.configure("TFrame", background=bg, foreground=fg) - s.configure("TLabel", background=bg, foreground=fg) - s.configure("TLabelframe", background=bg, foreground=fg) - s.configure("TLabelframe.Label", background=bg, foreground=fg) - s.configure("MS.TLabel", background=bg, foreground=fg) - s.configure("green.TLabel", background=bg) - s.configure("yellow.TLabel", background=bg) - s.configure("red.TLabel", background=bg) - s.configure("Link.TLabel", font=self._fonts["underlined"], background=bg, foreground=link) - # Buttons and checks - s.configure("TButton", background=surface, foreground=fg, bordercolor=border) - s.configure("Large.TButton", background=surface, foreground=fg, bordercolor=border) - s.map( - "TButton", - background=[("active", header), ("pressed", border)], - foreground=[("disabled", muted)], - ) - s.configure( - "TCheckbutton", - background=bg, - foreground=fg, - focuscolor=bg, - bordercolor=border, - ) - s.map( - "TCheckbutton", - # Remove hover visuals by mapping active/pressed to the base background - background=[ - ("active", bg), - ("pressed", bg), - ], - foreground=[("disabled", muted)], - indicatorcolor=[ - ("selected", accent if dark else fg), - ("!selected", border), - ], - ) - # Notebook - s.configure("TNotebook", background=bg, bordercolor=border) - s.configure("TNotebook.Tab", background=surface, foreground=fg, bordercolor=border) - s.map( - "TNotebook.Tab", - background=[("selected", header), ("active", header)], - foreground=[("disabled", muted)], - ) - # Entries/Combos - s.configure( - "TEntry", fieldbackground=fieldbg, background=fieldbg, foreground=fg, insertcolor=fg - ) - s.configure( - "TCombobox", fieldbackground=fieldbg, background=fieldbg, foreground=fg, arrowcolor=fg - ) - # Ensure readability for readonly comboboxes (Language, Priority mode) - s.map( - "TCombobox", - foreground=[("readonly", fg), ("disabled", muted)], - fieldbackground=[("readonly", fieldbg)], - background=[("readonly", fieldbg)], - arrowcolor=[("readonly", fg)], - ) - s.map("TEntry", foreground=[("disabled", muted)]) - # Treeview - s.configure( - "Treeview", - background=surface, - fieldbackground=surface, - foreground=fg, - bordercolor=border, - ) - s.map( - "Treeview", - background=[("selected", sel_bg)], - foreground=[("selected", sel_fg)], - ) - s.configure("Treeview.Heading", background=header, foreground=fg, bordercolor=border) - # Progressbar - s.configure("TProgressbar", background=accent, troughcolor=surface) - # Scrollbars - s.configure( - "Vertical.TScrollbar", - background=surface, - troughcolor=bg, - arrowcolor=fg, - bordercolor=border, - ) - s.configure( - "Horizontal.TScrollbar", - background=surface, - troughcolor=bg, - arrowcolor=fg, - bordercolor=border, - ) - - # Pure Tk widgets - # Console text - self.output.configure_theme(bg=surface, fg=fg, sel_bg=sel_bg, sel_fg=sel_fg) - # Listboxes - self.settings._priority_list.configure_theme( - bg=surface, fg=fg, sel_bg=sel_bg, sel_fg=sel_fg - ) - self.settings._exclude_list.configure_theme( - bg=surface, fg=fg, sel_bg=sel_bg, sel_fg=sel_fg - ) - # Inventory canvas - self.inv.configure_theme(bg=bg) - - # Tk option database for selection/popup list readability (affects Tk-backed widgets) - # Global selection colors and listbox defaults (covers Combobox dropdown) - self._root.option_add("*selectBackground", sel_bg) - self._root.option_add("*selectForeground", sel_fg) - # Combobox dropdown list (Tk Listbox) - for key in ( - "*TCombobox*Listbox.background", - "*TCombobox*Listbox.Background", - "*Listbox.background", - ): - self._root.option_add(key, surface) - for key in ( - "*TCombobox*Listbox.foreground", - "*TCombobox*Listbox.Foreground", - "*Listbox.foreground", - ): - self._root.option_add(key, fg) - for key in ( - "*TCombobox*Listbox.selectBackground", - "*Listbox.selectBackground", - ): - self._root.option_add(key, sel_bg) - for key in ( - "*TCombobox*Listbox.selectForeground", - "*Listbox.selectForeground", - ): - self._root.option_add(key, sel_fg) - - -################### -# GUI MANAGER END # -################### - - -if __name__ == "__main__": - # Everything below is for debug purposes only - import aiohttp - from types import SimpleNamespace - - class StrNamespace(SimpleNamespace): - __hash__ = object.__hash__ # type: ignore - - def __str__(self): - if hasattr(self, "_str__"): - return self._str__(self) - return super().__str__() - - class HashNamespace(SimpleNamespace): - __hash__ = object.__hash__ # type: ignore - - def create_game(id: int, name: str): - return StrNamespace(name=name, id=id, _str__=lambda s: s.name) - - iid = 0 - - def create_channel( - name: str, - status: int, - game: str | None, - drops: bool, - viewers: int, - acl_based: bool, - ): - # status: 0 -> OFFLINE, 1 -> PENDING_ONLINE, 2 -> ONLINE - if status == 1: - status = False - pending = True - else: - pending = False - if game is not None: - game_obj: StrNamespace | None = create_game(0, game) - else: - game_obj = None - global iid - return SimpleNamespace( - name=name, - iid=(iid := iid + 1), - online=bool(status), - pending_online=pending, - game=game_obj, - drops_enabled=drops, - viewers=viewers, - acl_based=acl_based, - ) - - def create_drop( - campaign_name: str, - game_name: str, - rewards: list[str], - claimed_drops: int, - total_drops: int, - current_minutes: int, - total_minutes: int, - ): - cd = claimed_drops - td = total_drops - cm = current_minutes - tm = total_minutes - ref_stamp = datetime.now(timezone.utc) - drop_image_url = ( - "https://static-cdn.jtvnw.net/twitch-quests-assets/" - "REWARD/e0ede26e-b071-47f0-af5f-b80b26fa9fb4.png" - ) - campaign_image_url = "https://static-cdn.jtvnw.net/ttv-boxart/515025-120x160.jpg" - benefits = [SimpleNamespace(name=name, image_url=drop_image_url) for name in rewards] - mock = SimpleNamespace( - id="0", - campaign=HashNamespace( - name=campaign_name, - id="campaign", - game=create_game(0, game_name), - expired=False, - active=False, - upcoming=True, - eligible=False, - finished=False, - link_url="https://google.com", - image_url=campaign_image_url, - allowed_channels=[], - starts_at=ref_stamp, - ends_at=ref_stamp + timedelta(days=7), - timed_drops={}, - claimed_drops=cd, - total_drops=td, - required_minutes=tm, - remaining_drops=td - cd, - progress=(cd * tm + cm) / (td * tm), - remaining_minutes=(td - cd) * tm - cm, - ), - image_url=drop_image_url, - can_claim=False, - can_earn=lambda: False, - is_claimed=False, - preconditions=True, - benefits=benefits, - rewards_text=lambda: ', '.join(b.name for b in benefits), - starts_at=ref_stamp + timedelta(seconds=2), - ends_at=ref_stamp + timedelta(days=7) - timedelta(seconds=2), - progress=cm/tm, - current_minutes=cm, - required_minutes=tm, - remaining_minutes=tm-cm, - ) - mock.campaign.timed_drops["0"] = mock - mock.campaign.drops = mock.campaign.timed_drops.values() - return mock - - async def main(exit_event: asyncio.Event): - # Initialize GUI debug - mock = SimpleNamespace( - settings=SimpleNamespace( - tray=False, - priority=[], - proxy=URL(), - dark_mode=False, - alter=lambda: None, - language="English", - autostart_tray=False, - exclude={"Lit Game"}, - tray_notifications=True, - logging_level=LOGGING_LEVELS[0], - priority_mode=PriorityMode.PRIORITY_ONLY, - ) - ) - mock.change_state = lambda state: mock.gui.print(f"State change: {state.value}") - mock.state_change = lambda state: partial(mock.change_state, state) - mock.request = aiohttp.request - # _.set_language("Русский") - gui = GUIManager(mock) # type: ignore - mock.gui = gui - mock.close = gui.stop - gui.start() - assert gui._poll_task is not None - gui._poll_task.add_done_callback(lambda t: exit_event.set()) - # Login form - gui.login.update("Login required", None) - # Game selector and settings panel games - gui.set_games(set([ - create_game(420690, "Lit Game"), - create_game(123456, "Best Game"), - create_game(654321, "My Game Very Long Name"), - ])) - # Channel list - gui.channels.display( - create_channel( - name="Thomus", - status=0, - game=None, - drops=False, - viewers=0, - acl_based=True, - ), - add=True, - ) - channel = create_channel( - name="Traitus", status=1, game=None, drops=False, viewers=0, acl_based=True - ) - gui.channels.display(channel, add=True) - gui.channels.set_watching(channel) - gui.channels.display( - create_channel( - name="Testus", - status=2, - game="Best Game", - drops=True, - viewers=42, - acl_based=False, - ), - add=True, - ) - gui.channels.display( - create_channel( - name="Livus", - status=2, - game="Best Game", - drops=True, - viewers=69, - acl_based=False, - ), - add=True, - ) - gui._root.update() - gui.channels.get_selection() - # Inventory overview - drop = create_drop( - "Wardrobe Cleaning", "Cleaning Masters", ["Fancy Pants"], 2, 7, 0, 240 - ) - campaign = drop.campaign - await gui.inv.add_campaign(campaign) - - gui.print("Single-line test message") - await asyncio.sleep(1) - gui.print("Multi-line\ntest\nmessage") - - # Tray - # gui.tray.minimize() - await asyncio.sleep(2) - claim_text = ( - f"{campaign.game.name}\n" - f"{drop.rewards_text()} ({campaign.claimed_drops}/{campaign.total_drops})" - ) - gui.tray.notify(claim_text, "Mined Drop") - - # Drop progress - gui.display_drop(drop, countdown=False) - await asyncio.sleep(3) - - gui.progress.start_timer() - await asyncio.sleep(5) - - gui.clear_drop() - await asyncio.sleep(5) - - campaign.can_earn = lambda: True - gui.inv.update_drop(drop) - gui.display_drop(drop) - await asyncio.sleep(10) - - drop.current_minutes = 239 - drop.remaining_minutes = 1 - drop.progress = 239/240 - campaign.remaining_minutes -= 1 - gui.inv.update_drop(drop) - gui.display_drop(drop) - await asyncio.sleep(63) - - drop.current_minutes = 240 - drop.remaining_minutes = 0 - drop.progress = 1.0 - campaign.remaining_minutes -= 1 - campaign.progress = 3/7 - campaign.claimed_drops = 3 - campaign.remaining_drops = 4 - gui.inv.update_drop(drop) - gui.display_drop(drop) - - def main_exit(task: asyncio.Task[None]) -> None: - if task.exception() is not None: - exit_event.set() - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - exit_event = asyncio.Event() - main_task = loop.create_task(main(exit_event)) - main_task.add_done_callback(main_exit) - loop.run_until_complete(exit_event.wait()) - if main_task.done(): - loop.run_until_complete(main_task) diff --git a/main.py b/main.py index d924f34..92889ce 100644 --- a/main.py +++ b/main.py @@ -1,202 +1,13 @@ -from __future__ import annotations - -# import an additional thing for proper PyInstaller freeze support -from multiprocessing import freeze_support +#!/usr/bin/env python3 +""" +TwitchDropsMiner - Main entry point +This is a simple launcher that runs the src package as a module. +All application code is in the src/ directory. +""" if __name__ == "__main__": - freeze_support() - import io - import sys - import signal - import asyncio - import logging - import argparse - import warnings - import traceback - import tkinter as tk - from tkinter import messagebox - from typing import NoReturn, TYPE_CHECKING + import runpy - import truststore - truststore.inject_into_ssl() - - from translate import _ - from twitch import Twitch - from settings import Settings - from version import __version__ - from exceptions import CaptchaRequired - from utils import lock_file, resource_path, set_root_icon - from constants import LOGGING_LEVELS, SELF_PATH, FILE_FORMATTER, LOG_PATH, LOCK_PATH - - if TYPE_CHECKING: - from _typeshed import SupportsWrite - - warnings.simplefilter("default", ResourceWarning) - - # import tracemalloc - # tracemalloc.start(3) - - if sys.version_info < (3, 10): - raise RuntimeError("Python 3.10 or higher is required") - - class Parser(argparse.ArgumentParser): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._message: io.StringIO = io.StringIO() - - def _print_message(self, message: str, file: SupportsWrite[str] | None = None) -> None: - self._message.write(message) - # print(message, file=self._message) - - def exit(self, status: int = 0, message: str | None = None) -> NoReturn: - try: - super().exit(status, message) # sys.exit(2) - finally: - messagebox.showerror("Argument Parser Error", self._message.getvalue()) - - class ParsedArgs(argparse.Namespace): - _verbose: int - _debug_ws: bool - _debug_gql: bool - log: bool - tray: 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 - # NOTE: parser output is shown via message box - # we also need a dummy invisible window for the parser - root = tk.Tk() - root.overrideredirect(True) - root.withdraw() - set_root_icon(root, resource_path("icons/pickaxe.ico")) - root.update() - parser = Parser( - SELF_PATH.name, - 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("--tray", action="store_true") - parser.add_argument("--log", action="store_true") - 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 - ) - args = parser.parse_args(namespace=ParsedArgs()) - # load settings - try: - settings = Settings(args) - except Exception: - messagebox.showerror( - "Settings error", - f"There was an error while loading the settings file:\n\n{traceback.format_exc()}" - ) - sys.exit(4) - # dummy window isn't needed anymore - root.destroy() - # get rid of unneeded objects - del root, parser - - # client run - async def main(): - # set language - try: - _.set_language(settings.language) - except ValueError: - # this language doesn't exist - stick to English - pass - - # handle logging stuff - if settings.logging_level > logging.DEBUG: - # redirect the root logger into a NullHandler, effectively ignoring all logging calls - # that aren't ours. This always runs, unless the main logging level is DEBUG or lower. - logging.getLogger().addHandler(logging.NullHandler()) - logger = logging.getLogger("TwitchDrops") - logger.setLevel(settings.logging_level) - if settings.log: - handler = logging.FileHandler(LOG_PATH) - handler.setFormatter(FILE_FORMATTER) - logger.addHandler(handler) - logging.getLogger("TwitchDrops.gql").setLevel(settings.debug_gql) - logging.getLogger("TwitchDrops.websocket").setLevel(settings.debug_ws) - - exit_status = 0 - client = Twitch(settings) - loop = asyncio.get_running_loop() - if sys.platform == "linux": - loop.add_signal_handler(signal.SIGINT, lambda *_: client.gui.close()) - loop.add_signal_handler(signal.SIGTERM, lambda *_: client.gui.close()) - try: - await client.run() - except CaptchaRequired: - exit_status = 1 - client.prevent_close() - client.print(_("error", "captcha")) - except Exception: - exit_status = 1 - client.prevent_close() - client.print("Fatal error encountered:\n") - client.print(traceback.format_exc()) - finally: - if sys.platform == "linux": - loop.remove_signal_handler(signal.SIGINT) - loop.remove_signal_handler(signal.SIGTERM) - client.print(_("gui", "status", "exiting")) - await client.shutdown() - if not client.gui.close_requested: - # user didn't request the closure - client.gui.tray.change_icon("error") - client.print(_("status", "terminated")) - client.gui.status.update(_("gui", "status", "terminated")) - # notify the user about the closure - client.gui.grab_attention(sound=True) - await client.gui.wait_until_closed() - # save the application state - # NOTE: we have to do it after wait_until_closed, - # because the user can alter some settings between app termination and closing the window - client.save(force=True) - client.gui.stop() - client.gui.close_window() - sys.exit(exit_status) - - try: - # use lock_file to check if we're not already running - success, file = lock_file(LOCK_PATH) - if not success: - # already running - exit - sys.exit(3) - - asyncio.run(main()) - finally: - file.close() + # Run the src package as a module + runpy.run_module("src", run_name="__main__") diff --git a/manual.txt b/manual.txt deleted file mode 100644 index 3a59dcb..0000000 --- a/manual.txt +++ /dev/null @@ -1,31 +0,0 @@ -################################### -# Twitch Drops Miner (by DevilXD) # -################################### - -Available command line arguments: - -• --tray - Start the application as minimised into tray. -• -v - Increase verbosity level. Can be stacked up several times (-vv, -vvv, etc.) to show - increasingly more information during application runtime. -• --log - Enables logging of runtime information into a 'log.txt' file. Verbosity level of this logging - matches the level set by `-v`. -• --dump - Start the application in a data-dump mode, where a 'dump.dat' file is created. - The file contains anonymous raw Twitch API data, studying of which can help troubleshoot - issues with the application. The application automatically closes shortly after launching, - once dumping is finished. -• --version - Show application version information. - -Note: Additional settings are available within the application GUI. - -Exit codes: - -• 0: Application exited successfully -• 1: Exit caused by the CAPTCHA or a Fatal Exception -• 2: Incorrect command line arguments -• 3: Application already running -• 4: Loading of the settings file failed diff --git a/pack.bat b/pack.bat deleted file mode 100644 index 5c46a5d..0000000 --- a/pack.bat +++ /dev/null @@ -1,24 +0,0 @@ -@echo off -IF NOT EXIST 7z.exe GOTO NO7Z -IF NOT EXIST "Twitch Drops Miner" mkdir "Twitch Drops Miner" -rem Prepare files -copy /y /v dist\*.exe "Twitch Drops Miner" -copy /y /v manual.txt "Twitch Drops Miner" -IF EXIST "Twitch Drops Miner.zip" ( - rem Add action - set action=a -) ELSE ( - rem Update action - set action=u -) -rem Pack and test -7z %action% "Twitch Drops Miner.zip" "Twitch Drops Miner/" -r -7z t "Twitch Drops Miner.zip" * -r -rem Cleanup -IF EXIST "Twitch Drops Miner" rmdir /s /q "Twitch Drops Miner" -GOTO EXIT -:NO7Z -echo No 7z.exe detected, skipping packaging! -GOTO EXIT -:EXIT -exit %errorlevel% diff --git a/registry.py b/registry.py deleted file mode 100644 index 8ee9c83..0000000 --- a/registry.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import winreg as reg -from typing import Any -from enum import Enum, Flag -from collections.abc import Generator - - -class RegistryError(Exception): - pass - - -class ValueNotFound(RegistryError): - pass - - -class Access(Flag): - KEY_READ = reg.KEY_READ - KEY_WRITE = reg.KEY_WRITE - KEY_NOTIFY = reg.KEY_NOTIFY - KEY_EXECUTE = reg.KEY_EXECUTE - KEY_SET_VALUE = reg.KEY_SET_VALUE - KEY_ALL_ACCESS = reg.KEY_ALL_ACCESS - KEY_CREATE_LINK = reg.KEY_CREATE_LINK - KEY_QUERY_VALUE = reg.KEY_QUERY_VALUE - KEY_CREATE_SUB_KEY = reg.KEY_CREATE_SUB_KEY - KEY_ENUMERATE_SUB_KEYS = reg.KEY_ENUMERATE_SUB_KEYS - - -class MainKey(Enum): - HKU = reg.HKEY_USERS - HKCR = reg.HKEY_CLASSES_ROOT - HKCU = reg.HKEY_CURRENT_USER - HKLM = reg.HKEY_LOCAL_MACHINE - HKEY_USERS = reg.HKEY_USERS - HKEY_CLASSES_ROOT = reg.HKEY_CLASSES_ROOT - HKEY_CURRENT_USER = reg.HKEY_CURRENT_USER - HKEY_LOCAL_MACHINE = reg.HKEY_LOCAL_MACHINE - HKEY_CURRENT_CONFIG = reg.HKEY_CURRENT_CONFIG - HKEY_PERFORMANCE_DATA = reg.HKEY_PERFORMANCE_DATA - - -class ValueType(Enum): - REG_SZ = reg.REG_SZ - REG_NONE = reg.REG_NONE - REG_LINK = reg.REG_LINK - REG_DWORD = reg.REG_DWORD - REG_QWORD = reg.REG_QWORD - REG_BINARY = reg.REG_BINARY - REG_MULTI_SZ = reg.REG_MULTI_SZ - REG_EXPAND_SZ = reg.REG_EXPAND_SZ - REG_RESOURCE_LIST = reg.REG_RESOURCE_LIST - REG_DWORD_BIG_ENDIAN = reg.REG_DWORD_BIG_ENDIAN - REG_DWORD_LITTLE_ENDIAN = reg.REG_DWORD_LITTLE_ENDIAN - REG_QWORD_LITTLE_ENDIAN = reg.REG_QWORD_LITTLE_ENDIAN - REG_FULL_RESOURCE_DESCRIPTOR = reg.REG_FULL_RESOURCE_DESCRIPTOR - REG_RESOURCE_REQUIREMENTS_LIST = reg.REG_RESOURCE_REQUIREMENTS_LIST - - -class RegistryKey: - def __init__(self, path: str, *, read_only: bool = False): - main_key, _, path = path.replace('/', '\\').partition('\\') - self.main_key = MainKey[main_key] - self.path = path - access_flags = Access.KEY_QUERY_VALUE - if not read_only: - access_flags |= Access.KEY_SET_VALUE - self._handle = reg.OpenKey(self.main_key.value, path, access=access_flags.value) - - def __enter__(self) -> RegistryKey: - return self - - def __exit__(self, exc_type, exc, tb): - self._handle.Close() - - def get(self, name: str) -> tuple[ValueType, Any]: - try: - value, value_type = reg.QueryValueEx(self._handle, name) - except FileNotFoundError: - # TODO: consider returning None for missing values - raise ValueNotFound(name) - return (ValueType(value_type), value) - - def set(self, name: str, value_type: ValueType, value: Any) -> bool: - reg.SetValueEx(self._handle, name, 0, value_type.value, value) - return True # TODO: return False if the set operation fails - - def delete(self, name: str, *, silent: bool = False) -> bool: - try: - reg.DeleteValue(self._handle, name) - except FileNotFoundError: - if not silent: - raise ValueNotFound(name) - return False - return True - - def values(self) -> Generator[tuple[str, ValueType, Any], None, None]: - len_keys, len_values, last_modified = reg.QueryInfoKey(self._handle) - for i in range(len_values): - try: - name, value, value_type = reg.EnumValue(self._handle, i) - yield name, ValueType(value_type), value - except OSError: - return - - -if __name__ == "__main__": - with RegistryKey("HKCU/Software/Microsoft/Windows/CurrentVersion/Run") as key: - # key.get("test") - # key.set("test", ValueType.REG_SZ, "test\\path") - for name, value_type, value in key.values(): - print(name, value_type, value) diff --git a/requirements.txt b/requirements.txt index f0ea621..fc66290 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,11 @@ +# Core dependencies aiohttp>=3.9,<4.0 Pillow -pystray -PyGObject<3.51; sys_platform == "linux" # required for better system tray support on Linux - -# environment-dependent dependencies -pywin32; sys_platform == "win32" truststore +python-dateutil + +# Web GUI dependencies (required) +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +python-socketio>=5.10.0 +jinja2>=3.1.2 diff --git a/run_dev.bat b/run_dev.bat deleted file mode 100644 index 8652562..0000000 --- a/run_dev.bat +++ /dev/null @@ -1,11 +0,0 @@ -@echo off -cls -set dirpath=%~dp0 -if "%dirpath:~-1%" == "\" set dirpath=%dirpath:~0,-1% -set /p "choice=Start with a console? (y/n) " -if "%choice%"=="y" ( - set "exepath=%dirpath%\env\scripts\python" -) else ( - set "exepath=%dirpath%\env\scripts\pythonw" -) -start "TwitchDropsMiner" "%exepath%" "%dirpath%\main.py" diff --git a/setup_env.bat b/setup_env.bat deleted file mode 100755 index 86783e7..0000000 --- a/setup_env.bat +++ /dev/null @@ -1,48 +0,0 @@ -@echo off - -REM Get the directory path of the script -set "dirpath=%~dp0" -if "%dirpath:~-1%" == "\" set "dirpath=%dirpath:~0,-1%" - -REM Check if git is installed -git --version > nul 2>&1 -if %errorlevel% NEQ 0 ( - echo: - echo No git executable found in PATH! - echo: - pause - exit /b 1 -) - -REM Create the virtual environment if it doesn't exist -if not exist "%dirpath%\env" ( - echo: - echo Creating the env folder... - python -m venv "%dirpath%\env" - if %errorlevel% NEQ 0 ( - echo: - echo No python executable found in PATH or failed to create virtual environment! - echo: - pause - exit /b 1 - ) -) - -REM Activate the virtual environment and install requirements -echo: -echo Installing requirements.txt... -"%dirpath%\env\scripts\python" -m pip install -U pip -"%dirpath%\env\scripts\pip" install wheel -"%dirpath%\env\scripts\pip" install -r "%dirpath%\requirements.txt" -if %errorlevel% NEQ 0 ( - echo: - echo Failed to install requirements. - echo: - pause - exit /b 1 -) - -echo: -echo Environment setup completed successfully. -echo: -pause diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..4440e06 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,4 @@ +"""TwitchDropsMiner - Modular source package.""" + +__version__ = "1.0.0" + diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..1e5ffc3 --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +# import an additional thing for proper PyInstaller freeze support +from multiprocessing import freeze_support + + +if __name__ == "__main__": + freeze_support() + import sys + import signal + import asyncio + import logging + import argparse + import warnings + import traceback + from typing import NoReturn + + import truststore + truststore.inject_into_ssl() + + from src.i18n import _ + from src.core.client import Twitch + from src.config.settings import Settings + from src.version import __version__ + from src.exceptions import CaptchaRequired + from src.config.paths import _resource_path as resource_path + from src.config import LOGGING_LEVELS, SELF_PATH, FILE_FORMATTER, LOG_PATH, LOCK_PATH + + + logger = logging.getLogger("TwitchDrops") + # Force INFO level logging by default for better visibility + logger.setLevel(logging.DEBUG) + # Always add console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(FILE_FORMATTER) + logger.addHandler(console_handler) + logger.info("Logger initialized") + + warnings.simplefilter("default", ResourceWarning) + + # import tracemalloc + # tracemalloc.start(3) + + if sys.version_info < (3, 10): + raise RuntimeError("Python 3.10 or higher is required") + + 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( + SELF_PATH.name, + 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("--log", action="store_true") + 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) + except Exception: + logger.exception("Error while loading settings") + print(f"Settings error: {traceback.format_exc()}", file=sys.stderr) + sys.exit(4) + + # client run + logger.debug("Defining main async function") + async def main(): + # set language + try: + _.set_language(settings.language) + except ValueError: + # this language doesn't exist - stick to English + pass + + # Always log to file with timestamped filename in ./logs/ directory + from datetime import datetime + from pathlib import Path + + # Create logs directory if it doesn't exist + logs_dir = Path("logs") + logs_dir.mkdir(exist_ok=True) + + # Generate timestamped log filename: TDM.YYYY-MM-DDTHH-MM-SS.log + timestamp = datetime.now().isoformat(timespec='seconds').replace(':', '-') + log_file = logs_dir / f"TDM.{timestamp}.log" + + # Add file handler for timestamped log + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter(FILE_FORMATTER) + logger.addHandler(file_handler) + logger.info(f"Logging to file: {log_file}") + + # Keep old log.txt for backward compatibility if --log flag is used + if settings.log: + legacy_handler = logging.FileHandler(LOG_PATH) + legacy_handler.setFormatter(FILE_FORMATTER) + logger.addHandler(legacy_handler) + logger.info(f"Legacy log file: {LOG_PATH}") + + 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}") + + 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": + logger.debug("Setting up signal handlers for SIGINT and SIGTERM") + loop.add_signal_handler(signal.SIGINT, lambda *_: client.gui.close()) + loop.add_signal_handler(signal.SIGTERM, lambda *_: client.gui.close()) + + logger.info("Starting main client run loop") + try: + await client.run() + logger.info("Client run completed normally") + except CaptchaRequired: + logger.error("Captcha required - cannot continue") + exit_status = 1 + client.prevent_close() + client.print(_("error", "captcha")) + except Exception: + logger.exception("Fatal error encountered during client run") + exit_status = 1 + client.prevent_close() + client.print("Fatal error encountered:\n") + client.print(traceback.format_exc()) + finally: + logger.info("=== Starting shutdown sequence ===") + if sys.platform == "linux": + logger.debug("Removing signal handlers (Linux)") + loop.remove_signal_handler(signal.SIGINT) + loop.remove_signal_handler(signal.SIGTERM) + logger.info("Notifying client of exit") + client.print(_("gui", "status", "exiting")) + # Shutdown web server + if web_server_task and not web_server_task.done(): + logger.info("Shutting down web server") + # Trigger graceful shutdown and wait for it to finish + await webapp.shutdown_server() + # Wait for server to actually exit (with timeout) + try: + await asyncio.wait_for(web_server_task, timeout=5.0) + logger.info("Web server task completed gracefully") + except asyncio.TimeoutError: + logger.warning("Web server didn't exit in time, forcing cancellation") + web_server_task.cancel() + try: + await web_server_task + except asyncio.CancelledError: + logger.info("Web server task force-cancelled") + except Exception as e: + logger.error(f"Error while shutting down web server: {e}") + else: + logger.debug(f"Web server task status: task={web_server_task is not None}, done={web_server_task.done() if web_server_task else 'N/A'}") + logger.info("Shutting down Twitch client") + await client.shutdown() + logger.info("Twitch client shutdown completed") + logger.info(f"Shutdown complete - close_requested={client.gui.close_requested}, exit_status={exit_status}") + if not client.gui.close_requested: + logger.warning("User didn't request closure - showing error state") + # user didn't request the closure + client.gui.tray.change_icon("error") + client.print(_("status", "terminated")) + client.gui.status.update(_("gui", "status", "terminated")) + # notify the user about the closure + client.gui.grab_attention(sound=True) + # Wait for user to close the GUI window (only needed if close wasn't already requested) + logger.info("Waiting for GUI to close") + await client.gui.wait_until_closed() + logger.info("GUI closed by user") + else: + logger.info("Close already requested - skipping GUI wait") + # save the application state + # NOTE: we have to do it after wait_until_closed, + # because the user can alter some settings between app termination and closing the window + logger.info("Saving application state") + client.save(force=True) + logger.info("Application state saved") + logger.info("Stopping GUI") + client.gui.stop() + logger.info("GUI stopped") + logger.info("Closing GUI window") + client.gui.close_window() + logger.info(f"=== Exiting with status code: {exit_status} ===") + sys.exit(exit_status) + + asyncio.run(main()) + # try: + # # use lock_file to check if we're not already running + # success, file = lock_file(LOCK_PATH) + # if not success: + # # already running - exit + # sys.exit(3) + + # asyncio.run(main()) + # finally: + # file.close() diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..4d1e1aa --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1,16 @@ +""" +API client modules for Twitch API communication. + +This package provides HTTP and GraphQL client implementations for interacting +with Twitch's API endpoints. +""" + +from __future__ import annotations + +from src.api.http_client import HTTPClient +from src.api.gql_client import GQLClient + +__all__ = [ + "HTTPClient", + "GQLClient", +] diff --git a/src/api/gql_client.py b/src/api/gql_client.py new file mode 100644 index 0000000..fce1f46 --- /dev/null +++ b/src/api/gql_client.py @@ -0,0 +1,220 @@ +""" +GraphQL client for Twitch GQL API. + +Handles GraphQL requests with rate limiting, error handling, and retry logic. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import overload, TYPE_CHECKING +from itertools import chain + +from src.utils import RateLimiter, ExponentialBackoff +from src.exceptions import GQLException, MinerException +from src.config import GQL_OPERATIONS + +if TYPE_CHECKING: + from src.config import JsonType, GQLOperation, ClientInfo + from src.api.http_client import HTTPClient + from src.auth import _AuthState + + +logger = logging.getLogger("TwitchDrops") +gql_logger = logging.getLogger("TwitchDrops.gql") + + +class GQLClient: + """ + GraphQL client for Twitch GQL API. + + This client provides: + - Rate-limited GraphQL requests to prevent API bans + - Automatic retry with exponential backoff on errors + - Error handling for various GQL error types + - Support for batched requests + - Data merging utilities for campaign data + """ + + def __init__( + self, + http_client: HTTPClient, + auth_state: _AuthState, + client_type: ClientInfo, + ): + """ + Initialize the GraphQL client. + + Parameters + ---------- + http_client : HTTPClient + The HTTP client for making requests + auth_state : _AuthState + Authentication state manager + client_type : ClientInfo + Client type information (User-Agent, Client-ID, etc.) + """ + self.http_client = http_client + self._auth_state = auth_state + self._client_type = client_type + # NOTE: GQL is volatile and breaks everything if rate limited. + # Do not modify these safe defaults. + self._qgl_limiter = RateLimiter(capacity=5, window=1) + + @overload + async def request(self, ops: GQLOperation) -> JsonType: + ... + + @overload + async def request(self, ops: list[GQLOperation]) -> list[JsonType]: + ... + + async def request( + self, ops: GQLOperation | list[GQLOperation] + ) -> JsonType | list[JsonType]: + """ + Execute one or more GraphQL operations. + + Parameters + ---------- + ops : GQLOperation | list[GQLOperation] + Single operation or list of operations to execute + + Returns + ------- + JsonType | list[JsonType] + Response data for the operation(s) + + Raises + ------ + GQLException + If the GQL API returns an error that can't be handled + RuntimeError + If the retry loop is broken unexpectedly + """ + gql_logger.debug(f"GQL Request: {ops}") + backoff = ExponentialBackoff(maximum=60) + # Flag to retry the request once for specific errors + single_retry: bool = True + + for delay in backoff: + async with self._qgl_limiter: + auth_state = await self._auth_state.validate() + async with self.http_client.request( + "POST", + "https://gql.twitch.tv/gql", + json=ops, + headers=auth_state.headers( + user_agent=self._client_type.USER_AGENT, gql=True + ), + ) as response: + response_json: JsonType | list[JsonType] = await response.json() + + gql_logger.debug(f"GQL Response: {response_json}") + orig_response = response_json + + # Normalize to list for unified error handling + if isinstance(response_json, list): + response_list = response_json + else: + response_list = [response_json] + + force_retry: bool = False + for response_json in response_list: + # GQL error handling + if "errors" in response_json: + for error_dict in response_json["errors"]: + if "message" in error_dict: + if ( + single_retry + and error_dict["message"] + in ( + "service error", + "PersistedQueryNotFound", + ) + ): + logger.error( + f"Retrying a {error_dict['message']} for " + f"{response_json['extensions']['operationName']}" + ) + single_retry = False + if delay < 5: + # Overwrite delay if too short + delay = 5 + force_retry = True + break + elif error_dict["message"] == "server error": + # Nullify the key the error path points to + data_dict: JsonType = response_json["data"] + path: list[str] = error_dict.get("path", []) + for key in path[:-1]: + data_dict = data_dict[key] + data_dict[path[-1]] = None + break + elif error_dict["message"] in ( + "service timeout", + "service unavailable", + "context deadline exceeded", + ): + force_retry = True + break + else: + raise GQLException(response_json["errors"]) + # Other error handling + elif "error" in response_json: + raise GQLException( + f"{response_json['error']}: {response_json['message']}" + ) + + if force_retry: + break + else: + return orig_response + + await asyncio.sleep(delay) + + raise RuntimeError("Retry loop was broken") + + @staticmethod + def merge_data(primary_data: JsonType, secondary_data: JsonType) -> JsonType: + """ + Recursively merge two JSON objects, preferring primary data. + + This is used to merge campaign data from inventory and general campaigns endpoints. + + Parameters + ---------- + primary_data : JsonType + Primary data source (takes precedence) + secondary_data : JsonType + Secondary data source (used when key missing in primary) + + Returns + ------- + JsonType + Merged data dictionary + + Raises + ------ + MinerException + If data types are inconsistent between sources + """ + merged = {} + for key in set(chain(primary_data.keys(), secondary_data.keys())): + in_primary = key in primary_data + if in_primary and key in secondary_data: + vp = primary_data[key] + vs = secondary_data[key] + if not isinstance(vp, type(vs)) or not isinstance(vs, type(vp)): + raise MinerException("Inconsistent merge data") + if isinstance(vp, dict): # Both are dicts + merged[key] = GQLClient.merge_data(vp, vs) + else: + # Use primary value + merged[key] = vp + elif in_primary: + merged[key] = primary_data[key] + else: # In secondary only + merged[key] = secondary_data[key] + return merged diff --git a/src/api/http_client.py b/src/api/http_client.py new file mode 100644 index 0000000..5b3e3e6 --- /dev/null +++ b/src/api/http_client.py @@ -0,0 +1,228 @@ +""" +HTTP client for Twitch API requests. + +Handles HTTP session management, request retries, and connection quality settings. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING +from collections import abc + +import aiohttp +from yarl import URL + +from src.i18n import _ +from src.exceptions import ExitRequest, RequestInvalid +from src.utils import ExponentialBackoff +from src.config import COOKIES_PATH + +if TYPE_CHECKING: + from src.config.settings import Settings + from src.web.gui_manager import WebGUIManager + from src.config import ClientInfo + + +logger = logging.getLogger("TwitchDrops") + + +class HTTPClient: + """ + Manages HTTP session and handles request retries with exponential backoff. + + This client provides: + - Session management with cookie persistence + - Automatic request retries on connection errors + - Connection quality-based timeout configuration + - Proxy support + """ + + def __init__( + self, + settings: Settings, + gui: WebGUIManager, + client_type: ClientInfo, + ): + """ + Initialize the HTTP client. + + Parameters + ---------- + settings : Settings + Application settings for connection quality and proxy configuration + gui : WebGUIManager + GUI manager for user notifications and close detection + client_type : ClientInfo + Client type information (User-Agent, Client-ID, etc.) + """ + self.settings = settings + self.gui = gui + self._client_type = client_type + self._session: aiohttp.ClientSession | None = None + + async def get_session(self) -> aiohttp.ClientSession: + """ + Get or create the HTTP session. + + Returns + ------- + aiohttp.ClientSession + The active HTTP session + + Raises + ------ + RuntimeError + If the session is closed + """ + if (session := self._session) is not None: + if session.closed: + raise RuntimeError("Session is closed") + return session + + # Load cookies + cookie_jar = aiohttp.CookieJar() + try: + if COOKIES_PATH.exists(): + cookie_jar.load(COOKIES_PATH) + except Exception: + # If loading cookies fails, clear the jar and continue + cookie_jar.clear() + + # Create timeouts based on connection quality + connection_quality = self.settings.connection_quality + if connection_quality < 1: + connection_quality = self.settings.connection_quality = 1 + elif connection_quality > 6: + connection_quality = self.settings.connection_quality = 6 + + timeout = aiohttp.ClientTimeout( + sock_connect=5 * connection_quality, + total=10 * connection_quality, + ) + + # Create session with connection pooling + connector = aiohttp.TCPConnector(limit=50) + self._session = aiohttp.ClientSession( + timeout=timeout, + connector=connector, + cookie_jar=cookie_jar, + headers={"User-Agent": self._client_type.USER_AGENT}, + ) + return self._session + + @asynccontextmanager + async def request( + self, + method: str, + url: URL | str, + *, + invalidate_after: datetime | None = None, + **kwargs, + ) -> abc.AsyncIterator[aiohttp.ClientResponse]: + """ + Make an HTTP request with automatic retries. + + Parameters + ---------- + method : str + HTTP method (GET, POST, etc.) + url : URL | str + Request URL + invalidate_after : datetime | None, optional + Datetime after which the request should not be retried + **kwargs + Additional arguments passed to aiohttp.ClientSession.request + + Yields + ------ + aiohttp.ClientResponse + The HTTP response + + Raises + ------ + ExitRequest + If the application is closing + RequestInvalid + If the request expires during retry loop + aiohttp.ClientConnectorCertificateError + If SSL verification fails + """ + session = await self.get_session() + method = method.upper() + + if self.settings.proxy and "proxy" not in kwargs: + kwargs["proxy"] = self.settings.proxy + + logger.debug(f"Request: ({method=}, {url=}, {kwargs=})") + session_timeout = timedelta(seconds=session.timeout.total or 0) + backoff = ExponentialBackoff(maximum=3 * 60) + + for delay in backoff: + if self.gui.close_requested: + raise ExitRequest() + elif ( + invalidate_after is not None + # Account for expiration landing during the request + and datetime.now(timezone.utc) >= (invalidate_after - session_timeout) + ): + raise RequestInvalid() + + try: + response: aiohttp.ClientResponse | None = None + response = await self.gui.coro_unless_closed( + session.request(method, url, **kwargs) + ) + assert response is not None + logger.debug(f"Response: {response.status}: {response}") + + if response.status < 500: + # Pre-read the response to avoid getting errors outside the context manager + raw_response = await response.read() # noqa: F841 + yield response + return + + self.gui.print(_("error", "site_down").format(seconds=round(delay))) + except aiohttp.ClientConnectorCertificateError: + # SSL verification failures should not be retried + raise + except ( + aiohttp.ClientConnectionError, + asyncio.TimeoutError, + aiohttp.ClientPayloadError, + ): + # Connection problems, retry with backoff + if backoff.steps > 1: + # Don't show quick retries to the user + self.gui.print(_("error", "no_connection").format(seconds=round(delay))) + finally: + if response is not None: + response.release() + + # Wait for the backoff delay or until the GUI closes + with asyncio.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self.gui.wait_until_closed(), timeout=delay) + + async def close(self) -> None: + """ + Close the HTTP session and save cookies. + + This should be called during application shutdown. + """ + if self._session is not None: + cookie_jar = self._session.cookie_jar + assert isinstance(cookie_jar, aiohttp.CookieJar) + + # Clear empty cookie entries before saving + # NOTE: Unfortunately, aiohttp provides no easy way of clearing empty cookies, + # so we need to access the private '_cookies' attribute + for cookie_key, cookie in list(cookie_jar._cookies.items()): + if not cookie: + del cookie_jar._cookies[cookie_key] + + cookie_jar.save(COOKIES_PATH) + await self._session.close() + self._session = None diff --git a/src/auth/__init__.py b/src/auth/__init__.py new file mode 100644 index 0000000..e129263 --- /dev/null +++ b/src/auth/__init__.py @@ -0,0 +1,5 @@ +"""Authentication module for Twitch Drops Miner.""" + +from .auth_state import _AuthState + +__all__ = ["_AuthState"] diff --git a/src/auth/auth_state.py b/src/auth/auth_state.py new file mode 100644 index 0000000..ce091c2 --- /dev/null +++ b/src/auth/auth_state.py @@ -0,0 +1,453 @@ +"""Authentication state management for Twitch Drops Miner.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, cast + +import aiohttp +from yarl import URL + +from src.config import COOKIES_PATH +from src.exceptions import CaptchaRequired, LoginException +from src.i18n import _ +from src.utils import CHARS_HEX_LOWER, create_nonce + +if TYPE_CHECKING: + from src.core.client import Twitch + from src.web.gui_manager import LoginForm, LoginFormManager + from src.config import ClientInfo, JsonType + + +logger = logging.getLogger("TwitchDrops") + + +class _AuthState: + """ + Manages authentication state including tokens, session, and login flow. + + This class handles: + - OAuth device code flow for authentication + - Legacy password-based login (deprecated) + - Access token validation and management + - Session and device ID management + - Cookie persistence + """ + + def __init__(self, twitch: Twitch): + self._twitch: Twitch = twitch + self._lock = asyncio.Lock() + self._logged_in = asyncio.Event() + self.user_id: int + self.device_id: str + self.session_id: str + self.access_token: str + self.client_version: str + + def _hasattrs(self, *attrs: str) -> bool: + """Check if all specified attributes exist.""" + return all(hasattr(self, attr) for attr in attrs) + + def _delattrs(self, *attrs: str) -> None: + """Delete all specified attributes if they exist.""" + for attr in attrs: + if hasattr(self, attr): + delattr(self, attr) + + def clear(self) -> None: + """Clear all authentication state.""" + self._delattrs( + "user_id", + "device_id", + "session_id", + "access_token", + "client_version", + ) + self._logged_in.clear() + + async def _oauth_login(self) -> str: + """ + Perform OAuth device code flow authentication. + + This implements the OAuth 2.0 Device Authorization Grant flow: + 1. Request device code and user code from Twitch + 2. Display code to user for entry at twitch.tv/activate + 3. Poll token endpoint until user completes authorization + 4. Return access token + + Returns: + str: The access token + """ + login_form: LoginForm = self._twitch.gui.login + client_info: ClientInfo = self._twitch._client_type + headers = { + "Accept": "application/json", + "Accept-Encoding": "gzip", + "Accept-Language": "en-US", + "Cache-Control": "no-cache", + "Client-Id": client_info.CLIENT_ID, + "Host": "id.twitch.tv", + "Origin": str(client_info.CLIENT_URL), + "Pragma": "no-cache", + "Referer": str(client_info.CLIENT_URL), + "User-Agent": client_info.USER_AGENT, + "X-Device-Id": self.device_id, + } + payload = { + "client_id": client_info.CLIENT_ID, + "scopes": "", # no scopes needed + } + while True: + try: + from datetime import datetime, timedelta, timezone + from src.exceptions import RequestInvalid + + now = datetime.now(timezone.utc) + async with self._twitch.request( + "POST", "https://id.twitch.tv/oauth2/device", headers=headers, data=payload + ) as response: + # { + # "device_code": "40 chars [A-Za-z0-9]", + # "expires_in": 1800, + # "interval": 5, + # "user_code": "8 chars [A-Z]", + # "verification_uri": "https://www.twitch.tv/activate?device-code=ABCDEFGH" + # } + response_json: JsonType = await response.json() + device_code: str = response_json["device_code"] + user_code: str = response_json["user_code"] + interval: int = response_json["interval"] + verification_uri: URL = URL(response_json["verification_uri"]) + expires_at = now + timedelta(seconds=response_json["expires_in"]) + + # Print the code to the user, open them the activate page so they can type it in + await login_form.ask_enter_code(verification_uri, user_code) + + payload = { + "client_id": self._twitch._client_type.CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + while True: + # sleep first, not like the user is gonna enter the code *that* fast + await asyncio.sleep(interval) + async with self._twitch.request( + "POST", + "https://id.twitch.tv/oauth2/token", + headers=headers, + data=payload, + invalidate_after=expires_at, + ) as response: + # 200 means success, 400 means the user haven't entered the code yet + if response.status != 200: + continue + response_json = await response.json() + # { + # "access_token": "40 chars [A-Za-z0-9]", + # "refresh_token": "40 chars [A-Za-z0-9]", + # "scope": [...], + # "token_type": "bearer" + # } + self.access_token = cast(str, response_json["access_token"]) + return self.access_token + except RequestInvalid: + # the device_code has expired, request a new code + continue + + async def _login(self) -> str: + """ + Perform legacy password-based login flow. + + This method implements the old Twitch login API flow using username/password. + It handles: + - Username/password authentication + - Two-factor authentication (TOTP and email codes) + - CAPTCHA detection + - Error handling and user feedback + + NOTE: This flow is deprecated and may trigger CAPTCHA or be blocked by Twitch. + OAuth device code flow (_oauth_login) is the preferred method. + + Returns: + str: The access token + + Raises: + CaptchaRequired: When CAPTCHA is detected + LoginException: On login failure + """ + logger.info("Login flow started") + gui_print = self._twitch.gui.print + login_form: LoginFormManager = self._twitch.gui.login + client_info: ClientInfo = self._twitch._client_type + + token_kind: str = '' + use_chrome: bool = False + payload: JsonType = { + # username and password are added later + # "username": str, + # "password": str, + # client ID to-be associated with the access token + "client_id": client_info.CLIENT_ID, + "undelete_user": False, # purpose unknown + "remember_me": True, # persist the session via the cookie + # "authy_token": str, # 2FA token + # "twitchguard_code": str, # email code + # "captcha": str, # self-fed captcha + # 'force_twitchguard': False, # force email code confirmation + } + + def _safe_loads(s: str): + """JSON loads that skips extra data after the first valid JSON object.""" + import json + + class SkipExtraJsonDecoder(json.JSONDecoder): + def decode(self, s: str, *args): + # skip whitespace check + obj, end = self.raw_decode(s) + return obj + + return json.loads(s, cls=SkipExtraJsonDecoder) + + while True: + login_data = await login_form.ask_login() + payload["username"] = login_data.username + payload["password"] = login_data.password + # reinstate the 2FA token, if present + payload.pop("authy_token", None) + payload.pop("twitchguard_code", None) + if login_data.token: + # if there's no token kind set yet, and the user has entered a token, + # we can immediately assume it's an authenticator token and not an email one + if not token_kind: + token_kind = "authy" + if token_kind == "authy": + payload["authy_token"] = login_data.token + elif token_kind == "email": + payload["twitchguard_code"] = login_data.token + + # use fancy headers to mimic the twitch android app + headers = { + "Accept": "application/vnd.twitchtv.v3+json", + "Accept-Encoding": "gzip", + "Accept-Language": "en-US", + "Client-Id": client_info.CLIENT_ID, + "Content-Type": "application/json; charset=UTF-8", + "Host": "passport.twitch.tv", + "User-Agent": client_info.USER_AGENT, + "X-Device-Id": self.device_id, + # "X-Device-Id": ''.join(random.choices('0123456789abcdef', k=32)), + } + async with self._twitch.request( + "POST", "https://passport.twitch.tv/login", headers=headers, json=payload + ) as response: + login_response: JsonType = await response.json(loads=_safe_loads) + + # Feed this back in to avoid running into CAPTCHA if possible + if "captcha_proof" in login_response: + payload["captcha"] = {"proof": login_response["captcha_proof"]} + + # Error handling + if "error_code" in login_response: + error_code: int = login_response["error_code"] + logger.info(f"Login error code: {error_code}") + if error_code == 1000: + logger.info("1000: CAPTCHA is required") + use_chrome = True + break + elif error_code in (2004, 3001): + logger.info("3001: Login failed due to incorrect username or password") + gui_print(_("login", "incorrect_login_pass")) + if error_code == 2004: + # invalid username + login_form.clear(login=True) + login_form.clear(password=True) + continue + elif error_code in ( + 3012, # Invalid authy token + 3023, # Invalid email code + ): + logger.info("3012/23: Login failed due to incorrect 2FA code") + if error_code == 3023: + token_kind = "email" + gui_print(_("login", "incorrect_email_code")) + else: + token_kind = "authy" + gui_print(_("login", "incorrect_twofa_code")) + login_form.clear(token=True) + continue + elif error_code in ( + 3011, # Authy token needed + 3022, # Email code needed + ): + # 2FA handling + logger.info("3011/22: 2FA token required") + # user didn't provide a token, so ask them for it + if error_code == 3022: + token_kind = "email" + gui_print(_("login", "email_code_required")) + else: + token_kind = "authy" + gui_print(_("login", "twofa_code_required")) + continue + elif error_code >= 5000: + # Special errors, usually from Twitch telling the user to "go away" + # We print the code out to inform the user, and just use chrome flow instead + # { + # "error_code":5023, + # "error":"Please update your app to continue", + # "error_description":"client is not supported for this feature" + # } + # { + # "error_code":5027, + # "error":"Please update your app to continue", + # "error_description":"client blocked from this operation" + # } + gui_print(_("login", "error_code").format(error_code=error_code)) + logger.info(str(login_response)) + use_chrome = True + break + else: + ext_msg = str(login_response) + logger.info(ext_msg) + raise LoginException(ext_msg) + # Success handling + if "access_token" in login_response: + self.access_token = cast(str, login_response["access_token"]) + logger.info("Access token granted") + login_form.clear() + break + + if use_chrome: + # await self._chrome_login() + raise CaptchaRequired() + + if hasattr(self, "access_token"): + return self.access_token + raise LoginException("Login flow finished without setting the access token") + + def headers(self, *, user_agent: str = '', gql: bool = False) -> JsonType: + """ + Build HTTP headers for Twitch API requests. + + Args: + user_agent: Optional custom User-Agent string + gql: If True, include GraphQL-specific headers + + Returns: + Dictionary of HTTP headers + """ + client_info: ClientInfo = self._twitch._client_type + headers = { + "Accept": "*/*", + "Accept-Encoding": "gzip", + "Accept-Language": "en-US", + "Pragma": "no-cache", + "Cache-Control": "no-cache", + "Client-Id": client_info.CLIENT_ID, + } + if user_agent: + headers["User-Agent"] = user_agent + if hasattr(self, "session_id"): + headers["Client-Session-Id"] = self.session_id + # if hasattr(self, "client_version"): + # headers["Client-Version"] = self.client_version + if hasattr(self, "device_id"): + headers["X-Device-Id"] = self.device_id + if gql: + headers["Origin"] = str(client_info.CLIENT_URL) + headers["Referer"] = str(client_info.CLIENT_URL) + headers["Authorization"] = f"OAuth {self.access_token}" + return headers + + async def validate(self): + """Thread-safe wrapper for _validate().""" + async with self._lock: + await self._validate() + return self + + async def _validate(self): + """ + Validate and restore authentication state. + + This method: + 1. Generates session ID if needed + 2. Extracts device ID from Twitch cookies + 3. Validates existing access token or initiates login flow + 4. Ensures token client ID matches expected client + 5. Saves validated cookies to disk + + Raises: + RuntimeError: On repeated validation failures + """ + if not hasattr(self, "session_id"): + self.session_id = create_nonce(CHARS_HEX_LOWER, 16) + if not self._hasattrs("device_id", "access_token", "user_id"): + session = await self._twitch.get_session() + jar = cast(aiohttp.CookieJar, session.cookie_jar) + client_info: ClientInfo = self._twitch._client_type + if not self._hasattrs("device_id"): + async with self._twitch.request( + "GET", client_info.CLIENT_URL, headers=self.headers() + ) as response: + page_html = await response.text("utf8") + assert page_html is not None + # match = re.search(r'twilightBuildID="([-a-z0-9]+)"', page_html) + # if match is None: + # raise MinerException("Unable to extract client_version") + # self.client_version = match.group(1) + # doing the request ends up setting the "unique_id" value in the cookie + cookie = jar.filter_cookies(client_info.CLIENT_URL) + self.device_id = cookie["unique_id"].value + if not self._hasattrs("access_token", "user_id"): + # looks like we're missing something + login_form: LoginForm = self._twitch.gui.login + logger.info("Checking login") + login_form.update(_("gui", "login", "logging_in"), None) + for client_mismatch_attempt in range(2): + for invalid_token_attempt in range(2): + cookie = jar.filter_cookies(client_info.CLIENT_URL) + if "auth-token" not in cookie: + self.access_token = await self._oauth_login() + cookie["auth-token"] = self.access_token + elif not hasattr(self, "access_token"): + logger.info("Restoring session from cookie") + self.access_token = cookie["auth-token"].value + # validate the auth token, by obtaining user_id + async with self._twitch.request( + "GET", + "https://id.twitch.tv/oauth2/validate", + headers={"Authorization": f"OAuth {self.access_token}"} + ) as response: + if response.status == 401: + # the access token we have is invalid - clear the cookie and reauth + logger.info("Restored session is invalid") + assert client_info.CLIENT_URL.host is not None + jar.clear_domain(client_info.CLIENT_URL.host) + continue + elif response.status == 200: + validate_response = await response.json() + break + else: + raise RuntimeError("Login verification failure (step #2)") + # ensure the cookie's client ID matches the currently selected client + if validate_response["client_id"] == client_info.CLIENT_ID: + break + # otherwise, we need to delete the entire cookie file and clear the jar + logger.info("Cookie client ID mismatch") + jar.clear() + COOKIES_PATH.unlink(missing_ok=True) + else: + raise RuntimeError("Login verification failure (step #1)") + self.user_id = int(validate_response["user_id"]) + cookie["persistent"] = str(self.user_id) + logger.info(f"Login successful, user ID: {self.user_id}") + login_form.update(_("gui", "login", "logged_in"), self.user_id) + # update our cookie and save it + jar.update_cookies(cookie, client_info.CLIENT_URL) + jar.save(COOKIES_PATH) + self._logged_in.set() + + def invalidate(self): + """Invalidate the current access token.""" + self._delattrs("access_token") diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..60c0612 --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,113 @@ +"""Configuration package for Twitch Drops Miner.""" + +from __future__ import annotations + +# Re-export all public symbols for convenience +from .constants import ( + CALL, + FILE_FORMATTER, + OUTPUT_FORMATTER, + LOGGING_LEVELS, + State, + WebsocketTopic, + WEBSOCKET_TOPICS, + JsonType, + URLType, + TopicProcess, + GQLOperation, + MAX_INT, + MAX_EXTRA_MINUTES, + BASE_TOPICS, + MAX_WEBSOCKETS, + WS_TOPICS_LIMIT, + TOPICS_PER_CHANNEL, + MAX_TOPICS, + MAX_CHANNELS, + DEFAULT_LANG, + PING_INTERVAL, + PING_TIMEOUT, + ONLINE_DELAY, + WATCH_INTERVAL, + WINDOW_TITLE, +) +from .paths import ( + IS_APPIMAGE, + IS_PACKAGED, + IS_DOCKER, + SYS_SITE_PACKAGES, + SYS_SCRIPTS, + SELF_PATH, + WORKING_DIR, + DATA_DIR, + VENV_PATH, + SITE_PACKAGES_PATH, + SCRIPTS_PATH, + LANG_PATH, + LOG_PATH, + DUMP_PATH, + LOCK_PATH, + CACHE_PATH, + CACHE_DB, + COOKIES_PATH, + SETTINGS_PATH, + _resource_path, + _merge_vars, +) +from .client_info import ClientInfo, ClientType +from .operations import GQL_OPERATIONS + +__all__ = [ + # constants.py + "CALL", + "FILE_FORMATTER", + "OUTPUT_FORMATTER", + "LOGGING_LEVELS", + "State", + "WebsocketTopic", + "WEBSOCKET_TOPICS", + "JsonType", + "URLType", + "TopicProcess", + "GQLOperation", + "MAX_INT", + "MAX_EXTRA_MINUTES", + "BASE_TOPICS", + "MAX_WEBSOCKETS", + "WS_TOPICS_LIMIT", + "TOPICS_PER_CHANNEL", + "MAX_TOPICS", + "MAX_CHANNELS", + "DEFAULT_LANG", + "PING_INTERVAL", + "PING_TIMEOUT", + "ONLINE_DELAY", + "WATCH_INTERVAL", + "WINDOW_TITLE", + # paths.py + "IS_APPIMAGE", + "IS_PACKAGED", + "IS_DOCKER", + "SYS_SITE_PACKAGES", + "SYS_SCRIPTS", + "SELF_PATH", + "WORKING_DIR", + "DATA_DIR", + "VENV_PATH", + "SITE_PACKAGES_PATH", + "SCRIPTS_PATH", + "LANG_PATH", + "LOG_PATH", + "DUMP_PATH", + "LOCK_PATH", + "CACHE_PATH", + "CACHE_DB", + "COOKIES_PATH", + "SETTINGS_PATH", + "_resource_path", + "_merge_vars", + # client_info.py + "ClientInfo", + "ClientType", + # operations.py + "GQL_OPERATIONS", +] diff --git a/src/config/client_info.py b/src/config/client_info.py new file mode 100644 index 0000000..f7cc4b5 --- /dev/null +++ b/src/config/client_info.py @@ -0,0 +1,113 @@ +"""Client configuration for Twitch API interactions.""" + +from __future__ import annotations + +import random +from yarl import URL + + +class ClientInfo: + """Client configuration including URL, ID, and User-Agent.""" + + def __init__(self, client_url: URL, client_id: str, user_agents: str | list[str]) -> None: + self.CLIENT_URL: URL = client_url + self.CLIENT_ID: str = client_id + self.USER_AGENT: str + if isinstance(user_agents, list): + self.USER_AGENT = random.choice(user_agents) + else: + self.USER_AGENT = user_agents + + def __iter__(self): + return iter((self.CLIENT_URL, self.CLIENT_ID, self.USER_AGENT)) + + +class ClientType: + """Predefined client configurations for different Twitch platforms.""" + + WEB = ClientInfo( + URL("https://www.twitch.tv"), + "kimne78kx3ncx6brgo4mv6wki5h1ko", + ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + ), + ) + MOBILE_WEB = ClientInfo( + URL("https://m.twitch.tv"), + "r8s4dac0uhzifbpu9sjdiwzctle17ff", + [ + # Chrome versioning is done fully on android only, + # other platforms only use the major version + ( + "Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" + ), + ( + "Mozilla/5.0 (Linux; Android 16; SM-A205U) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" + ), + ( + "Mozilla/5.0 (Linux; Android 16; SM-A102U) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" + ), + ( + "Mozilla/5.0 (Linux; Android 16; SM-G960U) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" + ), + ( + "Mozilla/5.0 (Linux; Android 16; SM-N960U) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" + ), + ( + "Mozilla/5.0 (Linux; Android 16; LM-Q720) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" + ), + ( + "Mozilla/5.0 (Linux; Android 16; LM-X420) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.7204.158 Mobile Safari/537.36" + ), + ] + ) + ANDROID_APP = ClientInfo( + URL("https://www.twitch.tv"), + "kd1unb4b3q4t58fwlpcbzcbnm76a8fp", + [ + ( + "Dalvik/2.1.0 (Linux; U; Android 16; SM-S911B Build/TP1A.220624.014) " + "tv.twitch.android.app/25.3.0/2503006" + ), + ( + "Dalvik/2.1.0 (Linux; U; Android 16; SM-S938B Build/BP2A.250605.031) " + "tv.twitch.android.app/25.3.0/2503006" + ), + ( + "Dalvik/2.1.0 (Linux; Android 16; SM-X716N Build/UP1A.231005.007) " + "tv.twitch.android.app/25.3.0/2503006" + ), + ( + "Dalvik/2.1.0 (Linux; U; Android 15; SM-G990B Build/AP3A.240905.015.A2) " + "tv.twitch.android.app/25.3.0/2503006" + ), + ( + "Dalvik/2.1.0 (Linux; U; Android 15; SM-G970F Build/AP3A.241105.008) " + "tv.twitch.android.app/25.3.0/2503006" + ), + ( + "Dalvik/2.1.0 (Linux; U; Android 15; SM-A566E Build/AP3A.240905.015.A2) " + "tv.twitch.android.app/25.3.0/2503006" + ), + ( + "Dalvik/2.1.0 (Linux; U; Android 14; SM-X306B Build/UP1A.231005.007) " + "tv.twitch.android.app/25.3.0/2503006" + ), + ] + ) + SMARTBOX = ClientInfo( + URL("https://android.tv.twitch.tv"), + "ue6666qo983tsx6so1t0vnawi233wa", + ( + "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + ), + ) diff --git a/src/config/constants.py b/src/config/constants.py new file mode 100644 index 0000000..762af72 --- /dev/null +++ b/src/config/constants.py @@ -0,0 +1,160 @@ +"""Core constants, enums, and type definitions for Twitch Drops Miner.""" + +from __future__ import annotations + +import sys +import logging +from enum import Enum, auto +from datetime import timedelta +from typing import Any, Dict, Literal, NewType, TYPE_CHECKING +from copy import deepcopy + +from src.version import __version__ + +if TYPE_CHECKING: + from collections import abc # noqa + from typing_extensions import TypeAlias + + +# Logging special levels +CALL: int = logging.INFO - 1 +logging.addLevelName(CALL, "CALL") + +# Logging configuration +LOGGING_LEVELS = { + 0: logging.ERROR, + 1: logging.WARNING, + 2: logging.INFO, + 3: CALL, + 4: logging.DEBUG, +} +FILE_FORMATTER = logging.Formatter( + "{asctime}.{msecs:03.0f}:\t{levelname:>7}:\t{filename}:{lineno}:\t{message}", + style='{', + datefmt="%Y-%m-%d %H:%M:%S", +) +OUTPUT_FORMATTER = logging.Formatter("{levelname}: {message}", style='{', datefmt="%H:%M:%S") + +# Type aliases +JsonType = Dict[str, Any] +URLType = NewType("URLType", str) +TopicProcess: TypeAlias = "abc.Callable[[int, JsonType], Any]" + +# Core constants +MAX_INT = sys.maxsize +MAX_EXTRA_MINUTES = 15 +BASE_TOPICS = 2 +MAX_WEBSOCKETS = 8 +WS_TOPICS_LIMIT = 50 +TOPICS_PER_CHANNEL = 2 +MAX_TOPICS = (MAX_WEBSOCKETS * WS_TOPICS_LIMIT) - BASE_TOPICS +MAX_CHANNELS = MAX_TOPICS // TOPICS_PER_CHANNEL + +# Misc +DEFAULT_LANG = "English" + +# Intervals and Delays +PING_INTERVAL = timedelta(minutes=3) +PING_TIMEOUT = timedelta(seconds=10) +ONLINE_DELAY = timedelta(seconds=120) +WATCH_INTERVAL = timedelta(seconds=59) + +# Strings +WINDOW_TITLE = f"Twitch Drops Miner v{__version__} (by DevilXD)" + + +class State(Enum): + """Application state machine states.""" + IDLE = auto() + INVENTORY_FETCH = auto() + GAMES_UPDATE = auto() + CHANNELS_FETCH = auto() + CHANNELS_CLEANUP = auto() + CHANNEL_SWITCH = auto() + EXIT = auto() + + +class GQLOperation(JsonType): + """GraphQL operation with persisted query hash.""" + + def __init__(self, name: str, sha256: str, *, variables: JsonType | None = None): + super().__init__( + operationName=name, + extensions={ + "persistedQuery": { + "version": 1, + "sha256Hash": sha256, + } + } + ) + if variables is not None: + self.__setitem__("variables", variables) + + def with_variables(self, variables: JsonType) -> GQLOperation: + """Create a copy with merged variables.""" + from .paths import _merge_vars + + modified = deepcopy(self) + if "variables" in self: + existing_variables: JsonType = modified["variables"] + _merge_vars(existing_variables, variables) + else: + modified["variables"] = variables + return modified + + +class WebsocketTopic: + """Represents a websocket topic subscription.""" + + def __init__( + self, + category: Literal["User", "Channel"], + topic_name: str, + target_id: int, + process: TopicProcess, + ): + assert isinstance(target_id, int) + self._id: str = self.as_str(category, topic_name, target_id) + self._target_id = target_id + self._process: TopicProcess = process + + @classmethod + def as_str( + cls, category: Literal["User", "Channel"], topic_name: str, target_id: int + ) -> str: + return f"{WEBSOCKET_TOPICS[category][topic_name]}.{target_id}" + + def __call__(self, message: JsonType): + return self._process(self._target_id, message) + + def __str__(self) -> str: + return self._id + + def __repr__(self) -> str: + return f"Topic({self._id})" + + def __eq__(self, other) -> bool: + if isinstance(other, WebsocketTopic): + return self._id == other._id + elif isinstance(other, str): + return self._id == other + return NotImplemented + + def __hash__(self) -> int: + return hash((self.__class__.__name__, self._id)) + + +WEBSOCKET_TOPICS: dict[str, dict[str, str]] = { + "User": { # Using user_id + "Presence": "presence", # unused + "Drops": "user-drop-events", + "Notifications": "onsite-notifications", + "CommunityPoints": "community-points-user-v1", + }, + "Channel": { # Using channel_id + "Drops": "channel-drop-events", # unused + "StreamState": "video-playback-by-id", + "StreamUpdate": "broadcast-settings-update", + "CommunityPoints": "community-points-channel-v1", # unused + }, +} diff --git a/src/config/operations.py b/src/config/operations.py new file mode 100644 index 0000000..0e2168f --- /dev/null +++ b/src/config/operations.py @@ -0,0 +1,157 @@ +"""GraphQL operations for Twitch API interactions.""" + +from __future__ import annotations + +from .constants import GQLOperation + + +GQL_OPERATIONS: dict[str, GQLOperation] = { + # returns stream information for a particular channel + "GetStreamInfo": GQLOperation( + "VideoPlayerStreamInfoOverlayChannel", + "198492e0857f6aedead9665c81c5a06d67b25b58034649687124083ff288597d", + variables={ + "channel": ..., # channel login + }, + ), + # can be used to claim channel points + "ClaimCommunityPoints": GQLOperation( + "ClaimCommunityPoints", + "46aaeebe02c99afdf4fc97c7c0cba964124bf6b0af229395f1f6d1feed05b3d0", + variables={ + "input": { + "claimID": ..., # points claim_id + "channelID": ..., # channel ID as a str + }, + }, + ), + # can be used to claim a drop + "ClaimDrop": GQLOperation( + "DropsPage_ClaimDropRewards", + "a455deea71bdc9015b78eb49f4acfbce8baa7ccbedd28e549bb025bd0f751930", + variables={ + "input": { + "dropInstanceID": ..., # drop claim_id + }, + }, + ), + # returns current state of points (balance, claim available) for a particular channel + "ChannelPointsContext": GQLOperation( + "ChannelPointsContext", + "374314de591e69925fce3ddc2bcf085796f56ebb8cad67a0daa3165c03adc345", + variables={ + "channelLogin": ..., # channel login + }, + ), + # returns all in-progress campaigns + "Inventory": GQLOperation( + "Inventory", + "d86775d0ef16a63a33ad52e80eaff963b2d5b72fada7c991504a57496e1d8e4b", + variables={ + "fetchRewardCampaigns": False, + } + ), + # returns current state of drops (current drop progress) + "CurrentDrop": GQLOperation( + "DropCurrentSessionContext", + "4d06b702d25d652afb9ef835d2a550031f1cf762b193523a92166f40ea3d142b", + variables={ + "channelID": ..., # watched channel ID as a str + "channelLogin": "", # always empty string + }, + ), + # returns all available campaigns + "Campaigns": GQLOperation( + "ViewerDropsDashboard", + "5a4da2ab3d5b47c9f9ce864e727b2cb346af1e3ea8b897fe8f704a97ff017619", + variables={ + "fetchRewardCampaigns": False, + } + ), + # returns extended information about a particular campaign + "CampaignDetails": GQLOperation( + "DropCampaignDetails", + "039277bf98f3130929262cc7c6efd9c141ca3749cb6dca442fc8ead9a53f77c1", + variables={ + "channelLogin": ..., # user login + "dropID": ..., # campaign ID + }, + ), + # returns drops available for a particular channel + "AvailableDrops": GQLOperation( + "DropsHighlightService_AvailableDrops", + "9a62a09bce5b53e26e64a671e530bc599cb6aab1e5ba3cbd5d85966d3940716f", + variables={ + "channelID": ..., # channel ID as a str + }, + ), + # retuns stream playback access token + "PlaybackAccessToken": GQLOperation( + "PlaybackAccessToken", + "ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9", + variables={ + "isLive": True, + "isVod": False, + "login": ..., # channel login + "platform": "web", + "playerType": "site", + "vodID": "", + }, + ), + # returns live channels for a particular game + "GameDirectory": GQLOperation( + "DirectoryPage_Game", + "98a996c3c3ebb1ba4fd65d6671c6028d7ee8d615cb540b0731b3db2a911d3649", + variables={ + "limit": 30, # limit of channels returned + "slug": ..., # game slug + "imageWidth": 50, + "includeCostreaming": False, + "options": { + "broadcasterLanguages": [], + "freeformTags": None, + "includeRestricted": ["SUB_ONLY_LIVE"], + "recommendationsContext": {"platform": "web"}, + "sort": "RELEVANCE", # also accepted: "VIEWER_COUNT" + "systemFilters": [], + "tags": [], + "requestID": "JIRA-VXP-2397", + }, + "sortTypeIsRecency": False, + }, + ), + "SlugRedirect": GQLOperation( # can be used to turn game name -> game slug + "DirectoryGameRedirect", + "1f0300090caceec51f33c5e20647aceff9017f740f223c3c532ba6fa59f6b6cc", + variables={ + "name": ..., # game name + }, + ), + "NotificationsView": GQLOperation( # unused, triggers notifications "update-summary" + "OnsiteNotifications_View", + "e8e06193f8df73d04a1260df318585d1bd7a7bb447afa058e52095513f2bfa4f", + variables={ + "input": {}, + }, + ), + "NotificationsList": GQLOperation( # unused + "OnsiteNotifications_ListNotifications", + "11cdb54a2706c2c0b2969769907675680f02a6e77d8afe79a749180ad16bfea6", + variables={ + "cursor": "", + "displayType": "VIEWER", + "language": "en", + "limit": 10, + "shouldLoadLastBroadcast": False, + }, + ), + "NotificationsDelete": GQLOperation( + "OnsiteNotifications_DeleteNotification", + "13d463c831f28ffe17dccf55b3148ed8b3edbbd0ebadd56352f1ff0160616816", + variables={ + "input": { + "id": "", # ID of the notification to delete + } + }, + ), +} diff --git a/src/config/paths.py b/src/config/paths.py new file mode 100644 index 0000000..b4d07d2 --- /dev/null +++ b/src/config/paths.py @@ -0,0 +1,121 @@ +"""Path-related configuration and environment detection.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any, Dict + + +# Type alias for path operations +JsonType = Dict[str, Any] + + +# Environment detection +IS_APPIMAGE = "APPIMAGE" in os.environ and os.path.exists(os.environ["APPIMAGE"]) +IS_PACKAGED = hasattr(sys, "_MEIPASS") or IS_APPIMAGE +IS_DOCKER = os.getenv("DOCKER_ENV") == "1" or os.path.exists("/.dockerenv") + +# Site-packages venv path changes depending on the system platform +if sys.platform == "win32": + SYS_SITE_PACKAGES = "Lib/site-packages" +else: + # On Linux, the site-packages path includes a versioned 'pythonX.Y' folder part + # The Lib folder is also spelled in lowercase: 'lib' + version_info = sys.version_info + SYS_SITE_PACKAGES = f"lib/python{version_info.major}.{version_info.minor}/site-packages" + +# Scripts venv path changes depending on the system platform +if sys.platform == "win32": + SYS_SCRIPTS = "Scripts" +else: + SYS_SCRIPTS = "bin" + + +def _resource_path(relative_path: Path | str) -> Path: + """ + Get an absolute path to a bundled resource. + + Works for dev and for PyInstaller. + """ + if IS_APPIMAGE: + base_path = Path(sys.argv[0]).resolve().parent + elif IS_PACKAGED: + # PyInstaller's folder where the one-file app is unpacked + meipass: str = getattr(sys, "_MEIPASS") + base_path = Path(meipass) + else: + base_path = WORKING_DIR + return base_path.joinpath(relative_path) + + +def _merge_vars(base_vars: JsonType, vars: JsonType) -> None: + """ + Merge variables recursively. + + NOTE: This modifies base_vars in place. + """ + for k, v in vars.items(): + if k not in base_vars: + base_vars[k] = v + elif isinstance(v, dict): + if isinstance(base_vars[k], dict): + _merge_vars(base_vars[k], v) + elif base_vars[k] is Ellipsis: + # unspecified base, use the passed in var + base_vars[k] = v + else: + raise RuntimeError(f"Var is a dict, base is not: '{k}'") + elif isinstance(base_vars[k], dict): + raise RuntimeError(f"Base is a dict, var is not: '{k}'") + else: + # simple overwrite + base_vars[k] = v + # ensure none of the vars are ellipsis (unset value) + for k, v in base_vars.items(): + if v is Ellipsis: + raise RuntimeError(f"Unspecified variable: '{k}'") + + +# Base Paths - environment-specific resolution +if IS_DOCKER: + # Docker environment: use fixed paths + SELF_PATH = Path("/app/main.py") + WORKING_DIR = Path("/app") + DATA_DIR = Path("/app/data") +elif IS_APPIMAGE: + SELF_PATH = Path(os.environ["APPIMAGE"]).resolve() + WORKING_DIR = SELF_PATH.parent + DATA_DIR = WORKING_DIR +else: + # NOTE: pyinstaller will set sys.argv[0] to its own executable when building + # NOTE: sys.argv[0] will point to gui.py when running the gui.py directly for GUI debug + # detect these and use __file__ and main.py redirection instead + SELF_PATH = Path(sys.argv[0]).resolve() + if SELF_PATH.stem == "pyinstaller" or SELF_PATH.name == "gui.py": + SELF_PATH = Path(__file__).with_name("main.py").resolve() + WORKING_DIR = SELF_PATH.parent + DATA_DIR = WORKING_DIR + +# Ensure data directory exists in Docker +if IS_DOCKER and not DATA_DIR.exists(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + +# Development paths +VENV_PATH = Path(WORKING_DIR, "env") +SITE_PACKAGES_PATH = Path(VENV_PATH, SYS_SITE_PACKAGES) +SCRIPTS_PATH = Path(VENV_PATH, SYS_SCRIPTS) + +# Translations path +# NOTE: These don't have to be available to the end-user, so the path points to the internal dir +LANG_PATH = _resource_path("lang") + +# Persistent storage paths - use DATA_DIR for Docker compatibility +LOG_PATH = Path(DATA_DIR, "log.txt") +DUMP_PATH = Path(DATA_DIR, "dump.dat") +LOCK_PATH = Path(DATA_DIR, "lock.file") +CACHE_PATH = Path(DATA_DIR, "cache") +CACHE_DB = Path(CACHE_PATH, "mapping.json") +COOKIES_PATH = Path(DATA_DIR, "cookies.jar") +SETTINGS_PATH = Path(DATA_DIR, "settings.json") diff --git a/settings.py b/src/config/settings.py similarity index 86% rename from settings.py rename to src/config/settings.py index 910666d..33705af 100644 --- a/settings.py +++ b/src/config/settings.py @@ -4,8 +4,8 @@ from typing import Any, TypedDict, TYPE_CHECKING from yarl import URL -from utils import json_load, json_save -from constants import SETTINGS_PATH, DEFAULT_LANG, PriorityMode +from src.utils import json_load, json_save +from src.config import SETTINGS_PATH, DEFAULT_LANG if TYPE_CHECKING: from main import ParsedArgs @@ -15,24 +15,20 @@ class SettingsFile(TypedDict): proxy: URL language: str dark_mode: bool - exclude: set[str] - priority: list[str] + games_to_watch: list[str] autostart_tray: bool connection_quality: int tray_notifications: bool - priority_mode: PriorityMode default_settings: SettingsFile = { "proxy": URL(), - "priority": [], - "exclude": set(), + "games_to_watch": [], "dark_mode": False, "autostart_tray": False, "connection_quality": 1, "language": DEFAULT_LANG, "tray_notifications": True, - "priority_mode": PriorityMode.PRIORITY_ONLY, } @@ -49,12 +45,10 @@ class Settings: proxy: URL language: str dark_mode: bool - exclude: set[str] - priority: list[str] + games_to_watch: list[str] autostart_tray: bool connection_quality: int tray_notifications: bool - priority_mode: PriorityMode PASSTHROUGH = ("_settings", "_args", "_altered") diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/twitch.py b/src/core/client.py similarity index 54% rename from twitch.py rename to src/core/client.py index 50bfa3f..1f6b0b2 100644 --- a/twitch.py +++ b/src/core/client.py @@ -5,62 +5,57 @@ import asyncio import logging from time import time from copy import deepcopy -from itertools import chain from functools import partial from collections import abc, deque, OrderedDict from datetime import datetime, timedelta, timezone -from contextlib import suppress, asynccontextmanager -from typing import Any, Literal, Final, NoReturn, overload, cast, TYPE_CHECKING +from contextlib import suppress +from typing import Any, Literal, Final, NoReturn, TYPE_CHECKING +from dateutil.parser import isoparse +from src.web.gui_manager import WebGUIManager import aiohttp -from yarl import URL -from translate import _ -from gui import GUIManager -from channel import Channel -from websocket import WebsocketPool -from inventory import DropsCampaign -from exceptions import ( +from src.i18n import _ +from src.models.channel import Channel +from src.websocket import WebsocketPool +from src.models.campaign import DropsCampaign +from src.auth import _AuthState +from src.api import HTTPClient, GQLClient +from src.services.maintenance import MaintenanceService +from src.services.channel_service import ChannelService +from src.services.message_handlers import MessageHandlerService +from src.services.inventory_service import InventoryService +from src.services.watch_service import WatchService +from src.exceptions import ( ExitRequest, - GQLException, ReloadRequest, - LoginException, - MinerException, - RequestInvalid, - CaptchaRequired, RequestException, + GQLException, + MinerException, ) -from utils import ( - CHARS_HEX_LOWER, +from src.utils import ( chunk, - timestamp, - create_nonce, task_wrapper, - RateLimiter, AwaitableValue, - ExponentialBackoff, ) -from constants import ( +from src.config import ( CALL, MAX_INT, DUMP_PATH, - COOKIES_PATH, MAX_CHANNELS, GQL_OPERATIONS, WATCH_INTERVAL, State, ClientType, - PriorityMode, WebsocketTopic, ) if TYPE_CHECKING: - from utils import Game - from gui import LoginForm - from channel import Stream - from settings import Settings - from inventory import TimedDrop - from constants import ClientInfo, JsonType, GQLOperation + from src.models.game import Game + from src.models.channel import Stream + from src.config.settings import Settings + from src.models.drop import TimedDrop + from src.config import ClientInfo, JsonType, GQLOperation logger = logging.getLogger("TwitchDrops") @@ -68,356 +63,15 @@ gql_logger = logging.getLogger("TwitchDrops.gql") class SkipExtraJsonDecoder(json.JSONDecoder): - def decode(self, s: str, *args): + def decode(self, s: str, _w: Any = None) -> Any: # type: ignore[override] # skip whitespace check obj, end = self.raw_decode(s) return obj -SAFE_LOADS = lambda s: json.loads(s, cls=SkipExtraJsonDecoder) - - -class _AuthState: - def __init__(self, twitch: Twitch): - self._twitch: Twitch = twitch - self._lock = asyncio.Lock() - self._logged_in = asyncio.Event() - self.user_id: int - self.device_id: str - self.session_id: str - self.access_token: str - self.client_version: str - - def _hasattrs(self, *attrs: str) -> bool: - return all(hasattr(self, attr) for attr in attrs) - - def _delattrs(self, *attrs: str) -> None: - for attr in attrs: - if hasattr(self, attr): - delattr(self, attr) - - def clear(self) -> None: - self._delattrs( - "user_id", - "device_id", - "session_id", - "access_token", - "client_version", - ) - self._logged_in.clear() - - async def _oauth_login(self) -> str: - login_form: LoginForm = self._twitch.gui.login - client_info: ClientInfo = self._twitch._client_type - headers = { - "Accept": "application/json", - "Accept-Encoding": "gzip", - "Accept-Language": "en-US", - "Cache-Control": "no-cache", - "Client-Id": client_info.CLIENT_ID, - "Host": "id.twitch.tv", - "Origin": str(client_info.CLIENT_URL), - "Pragma": "no-cache", - "Referer": str(client_info.CLIENT_URL), - "User-Agent": client_info.USER_AGENT, - "X-Device-Id": self.device_id, - } - payload = { - "client_id": client_info.CLIENT_ID, - "scopes": "", # no scopes needed - } - while True: - try: - now = datetime.now(timezone.utc) - async with self._twitch.request( - "POST", "https://id.twitch.tv/oauth2/device", headers=headers, data=payload - ) as response: - # { - # "device_code": "40 chars [A-Za-z0-9]", - # "expires_in": 1800, - # "interval": 5, - # "user_code": "8 chars [A-Z]", - # "verification_uri": "https://www.twitch.tv/activate?device-code=ABCDEFGH" - # } - response_json: JsonType = await response.json() - device_code: str = response_json["device_code"] - user_code: str = response_json["user_code"] - interval: int = response_json["interval"] - verification_uri: URL = URL(response_json["verification_uri"]) - expires_at = now + timedelta(seconds=response_json["expires_in"]) - - # Print the code to the user, open them the activate page so they can type it in - await login_form.ask_enter_code(verification_uri, user_code) - - payload = { - "client_id": self._twitch._client_type.CLIENT_ID, - "device_code": device_code, - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - } - while True: - # sleep first, not like the user is gonna enter the code *that* fast - await asyncio.sleep(interval) - async with self._twitch.request( - "POST", - "https://id.twitch.tv/oauth2/token", - headers=headers, - data=payload, - invalidate_after=expires_at, - ) as response: - # 200 means success, 400 means the user haven't entered the code yet - if response.status != 200: - continue - response_json = await response.json() - # { - # "access_token": "40 chars [A-Za-z0-9]", - # "refresh_token": "40 chars [A-Za-z0-9]", - # "scope": [...], - # "token_type": "bearer" - # } - self.access_token = cast(str, response_json["access_token"]) - return self.access_token - except RequestInvalid: - # the device_code has expired, request a new code - continue - - async def _login(self) -> str: - logger.info("Login flow started") - gui_print = self._twitch.gui.print - login_form: LoginForm = self._twitch.gui.login - client_info: ClientInfo = self._twitch._client_type - - token_kind: str = '' - use_chrome: bool = False - payload: JsonType = { - # username and password are added later - # "username": str, - # "password": str, - # client ID to-be associated with the access token - "client_id": client_info.CLIENT_ID, - "undelete_user": False, # purpose unknown - "remember_me": True, # persist the session via the cookie - # "authy_token": str, # 2FA token - # "twitchguard_code": str, # email code - # "captcha": str, # self-fed captcha - # 'force_twitchguard': False, # force email code confirmation - } - - while True: - login_data = await login_form.ask_login() - payload["username"] = login_data.username - payload["password"] = login_data.password - # reinstate the 2FA token, if present - payload.pop("authy_token", None) - payload.pop("twitchguard_code", None) - if login_data.token: - # if there's no token kind set yet, and the user has entered a token, - # we can immediately assume it's an authenticator token and not an email one - if not token_kind: - token_kind = "authy" - if token_kind == "authy": - payload["authy_token"] = login_data.token - elif token_kind == "email": - payload["twitchguard_code"] = login_data.token - - # use fancy headers to mimic the twitch android app - headers = { - "Accept": "application/vnd.twitchtv.v3+json", - "Accept-Encoding": "gzip", - "Accept-Language": "en-US", - "Client-Id": client_info.CLIENT_ID, - "Content-Type": "application/json; charset=UTF-8", - "Host": "passport.twitch.tv", - "User-Agent": client_info.USER_AGENT, - "X-Device-Id": self.device_id, - # "X-Device-Id": ''.join(random.choices('0123456789abcdef', k=32)), - } - async with self._twitch.request( - "POST", "https://passport.twitch.tv/login", headers=headers, json=payload - ) as response: - login_response: JsonType = await response.json(loads=SAFE_LOADS) - - # Feed this back in to avoid running into CAPTCHA if possible - if "captcha_proof" in login_response: - payload["captcha"] = {"proof": login_response["captcha_proof"]} - - # Error handling - if "error_code" in login_response: - error_code: int = login_response["error_code"] - logger.info(f"Login error code: {error_code}") - if error_code == 1000: - logger.info("1000: CAPTCHA is required") - use_chrome = True - break - elif error_code in (2004, 3001): - logger.info("3001: Login failed due to incorrect username or password") - gui_print(_("login", "incorrect_login_pass")) - if error_code == 2004: - # invalid username - login_form.clear(login=True) - login_form.clear(password=True) - continue - elif error_code in ( - 3012, # Invalid authy token - 3023, # Invalid email code - ): - logger.info("3012/23: Login failed due to incorrect 2FA code") - if error_code == 3023: - token_kind = "email" - gui_print(_("login", "incorrect_email_code")) - else: - token_kind = "authy" - gui_print(_("login", "incorrect_twofa_code")) - login_form.clear(token=True) - continue - elif error_code in ( - 3011, # Authy token needed - 3022, # Email code needed - ): - # 2FA handling - logger.info("3011/22: 2FA token required") - # user didn't provide a token, so ask them for it - if error_code == 3022: - token_kind = "email" - gui_print(_("login", "email_code_required")) - else: - token_kind = "authy" - gui_print(_("login", "twofa_code_required")) - continue - elif error_code >= 5000: - # Special errors, usually from Twitch telling the user to "go away" - # We print the code out to inform the user, and just use chrome flow instead - # { - # "error_code":5023, - # "error":"Please update your app to continue", - # "error_description":"client is not supported for this feature" - # } - # { - # "error_code":5027, - # "error":"Please update your app to continue", - # "error_description":"client blocked from this operation" - # } - gui_print(_("login", "error_code").format(error_code=error_code)) - logger.info(str(login_response)) - use_chrome = True - break - else: - ext_msg = str(login_response) - logger.info(ext_msg) - raise LoginException(ext_msg) - # Success handling - if "access_token" in login_response: - self.access_token = cast(str, login_response["access_token"]) - logger.info("Access token granted") - login_form.clear() - break - - if use_chrome: - # await self._chrome_login() - raise CaptchaRequired() - - if hasattr(self, "access_token"): - return self.access_token - raise LoginException("Login flow finished without setting the access token") - - def headers(self, *, user_agent: str = '', gql: bool = False) -> JsonType: - client_info: ClientInfo = self._twitch._client_type - headers = { - "Accept": "*/*", - "Accept-Encoding": "gzip", - "Accept-Language": "en-US", - "Pragma": "no-cache", - "Cache-Control": "no-cache", - "Client-Id": client_info.CLIENT_ID, - } - if user_agent: - headers["User-Agent"] = user_agent - if hasattr(self, "session_id"): - headers["Client-Session-Id"] = self.session_id - # if hasattr(self, "client_version"): - # headers["Client-Version"] = self.client_version - if hasattr(self, "device_id"): - headers["X-Device-Id"] = self.device_id - if gql: - headers["Origin"] = str(client_info.CLIENT_URL) - headers["Referer"] = str(client_info.CLIENT_URL) - headers["Authorization"] = f"OAuth {self.access_token}" - return headers - - async def validate(self): - async with self._lock: - await self._validate() - - async def _validate(self): - if not hasattr(self, "session_id"): - self.session_id = create_nonce(CHARS_HEX_LOWER, 16) - if not self._hasattrs("device_id", "access_token", "user_id"): - session = await self._twitch.get_session() - jar = cast(aiohttp.CookieJar, session.cookie_jar) - client_info: ClientInfo = self._twitch._client_type - if not self._hasattrs("device_id"): - async with self._twitch.request( - "GET", client_info.CLIENT_URL, headers=self.headers() - ) as response: - page_html = await response.text("utf8") - assert page_html is not None - # match = re.search(r'twilightBuildID="([-a-z0-9]+)"', page_html) - # if match is None: - # raise MinerException("Unable to extract client_version") - # self.client_version = match.group(1) - # doing the request ends up setting the "unique_id" value in the cookie - cookie = jar.filter_cookies(client_info.CLIENT_URL) - self.device_id = cookie["unique_id"].value - if not self._hasattrs("access_token", "user_id"): - # looks like we're missing something - login_form: LoginForm = self._twitch.gui.login - logger.info("Checking login") - login_form.update(_("gui", "login", "logging_in"), None) - for client_mismatch_attempt in range(2): - for invalid_token_attempt in range(2): - cookie = jar.filter_cookies(client_info.CLIENT_URL) - if "auth-token" not in cookie: - self.access_token = await self._oauth_login() - cookie["auth-token"] = self.access_token - elif not hasattr(self, "access_token"): - logger.info("Restoring session from cookie") - self.access_token = cookie["auth-token"].value - # validate the auth token, by obtaining user_id - async with self._twitch.request( - "GET", - "https://id.twitch.tv/oauth2/validate", - headers={"Authorization": f"OAuth {self.access_token}"} - ) as response: - if response.status == 401: - # the access token we have is invalid - clear the cookie and reauth - logger.info("Restored session is invalid") - assert client_info.CLIENT_URL.host is not None - jar.clear_domain(client_info.CLIENT_URL.host) - continue - elif response.status == 200: - validate_response = await response.json() - break - else: - raise RuntimeError("Login verification failure (step #2)") - # ensure the cookie's client ID matches the currently selected client - if validate_response["client_id"] == client_info.CLIENT_ID: - break - # otherwise, we need to delete the entire cookie file and clear the jar - logger.info("Cookie client ID mismatch") - jar.clear() - COOKIES_PATH.unlink(missing_ok=True) - else: - raise RuntimeError("Login verification failure (step #1)") - self.user_id = int(validate_response["user_id"]) - cookie["persistent"] = str(self.user_id) - logger.info(f"Login successful, user ID: {self.user_id}") - login_form.update(_("gui", "login", "logged_in"), self.user_id) - # update our cookie and save it - jar.update_cookies(cookie, client_info.CLIENT_URL) - jar.save(COOKIES_PATH) - self._logged_in.set() - - def invalidate(self): - self._delattrs("access_token") +def _safe_loads(s: str) -> Any: + """JSON loads that skips extra data after the first valid JSON object.""" + return json.loads(s, cls=SkipExtraJsonDecoder) class Twitch: @@ -431,15 +85,14 @@ class Twitch: self._drops: dict[str, TimedDrop] = {} self._campaigns: dict[str, DropsCampaign] = {} self._mnt_triggers: deque[datetime] = deque() - # NOTE: GQL is pretty volatile and breaks everything if one runs into their rate limit. - # Do not modify the default, safe values. - self._qgl_limiter = RateLimiter(capacity=5, window=1) - # Client type, session and auth + # Client type and auth self._client_type: ClientInfo = ClientType.ANDROID_APP - self._session: aiohttp.ClientSession | None = None self._auth_state: _AuthState = _AuthState(self) - # GUI - self.gui = GUIManager(self) + # GUI (will be set by main.py) + self.gui: WebGUIManager = None # type: ignore[assignment] + # API clients (will be initialized after GUI is set) + self._http_client: HTTPClient | None = None + self._gql_client: GQLClient | None = None # Storing and watching channels self.channels: OrderedDict[int, Channel] = OrderedDict() self.watching_channel: AwaitableValue[Channel] = AwaitableValue() @@ -449,41 +102,39 @@ class Twitch: self.websocket = WebsocketPool(self) # Maintenance task self._mnt_task: asyncio.Task[None] | None = None + # Services + self._maintenance_service: MaintenanceService = MaintenanceService(self) + self._channel_service: ChannelService = ChannelService(self) + self._message_handler_service: MessageHandlerService = MessageHandlerService(self) + self._inventory_service: InventoryService = InventoryService(self) + self._watch_service: WatchService = WatchService(self) - async def get_session(self) -> aiohttp.ClientSession: - if (session := self._session) is not None: - if session.closed: - raise RuntimeError("Session is closed") - return session - # load in cookies - cookie_jar = aiohttp.CookieJar() - try: - if COOKIES_PATH.exists(): - cookie_jar.load(COOKIES_PATH) - except Exception: - # if loading in the cookies file ends up in an error, just ignore it - # clear the jar, just in case - cookie_jar.clear() - # create timeouts - # connection quality mulitiplier determines the magnitude of timeouts - connection_quality = self.settings.connection_quality - if connection_quality < 1: - connection_quality = self.settings.connection_quality = 1 - elif connection_quality > 6: - connection_quality = self.settings.connection_quality = 6 - timeout = aiohttp.ClientTimeout( - sock_connect=5*connection_quality, - total=10*connection_quality, - ) - # create session, limited to 50 connections at maximum - connector = aiohttp.TCPConnector(limit=50) - self._session = aiohttp.ClientSession( - timeout=timeout, - connector=connector, - cookie_jar=cookie_jar, - headers={"User-Agent": self._client_type.USER_AGENT}, - ) - return self._session + def _ensure_api_clients(self) -> None: + """Ensure API clients are initialized (called after GUI is set).""" + if self._http_client is None: + self._http_client = HTTPClient(self.settings, self.gui, self._client_type) + if self._gql_client is None: + self._gql_client = GQLClient(self._http_client, self._auth_state, self._client_type) + + async def get_session(self): + """ + Get the HTTP session (for backward compatibility). + + Delegates to HTTPClient. + """ + self._ensure_api_clients() + assert self._http_client is not None + return await self._http_client.get_session() + + def request(self, method: str, url: str | Any, **kwargs): + """ + Make an HTTP request (for backward compatibility). + + Delegates to HTTPClient. + """ + self._ensure_api_clients() + assert self._http_client is not None + return self._http_client.request(method, url, **kwargs) async def shutdown(self) -> None: start_time = time() @@ -494,19 +145,10 @@ class Twitch: if self._mnt_task is not None: self._mnt_task.cancel() self._mnt_task = None - # stop websocket, close session and save cookies + # stop websocket and close HTTP session await self.websocket.stop(clear_topics=True) - if self._session is not None: - cookie_jar = cast(aiohttp.CookieJar, self._session.cookie_jar) - # clear empty cookie entries off the cookies file before saving - # NOTE: Unfortunately, aiohttp provides no easy way of clearing empty cookies, - # so we need to access the private '_cookies' attribute for this. - for cookie_key, cookie in list(cookie_jar._cookies.items()): - if not cookie: - del cookie_jar._cookies[cookie_key] - cookie_jar.save(COOKIES_PATH) - await self._session.close() - self._session = None + if self._http_client is not None: + await self._http_client.close() self._drops.clear() self.channels.clear() self.inventory.clear() @@ -518,51 +160,48 @@ class Twitch: await asyncio.sleep(start_time + 0.5 - time()) def wait_until_login(self) -> abc.Coroutine[Any, Any, Literal[True]]: + """Wait until the user is logged in.""" return self._auth_state._logged_in.wait() def change_state(self, state: State) -> None: + """Change the current state of the miner.""" if self._state is not State.EXIT: # prevent state changing once we switch to exit state self._state = state self._state_change.set() def state_change(self, state: State) -> abc.Callable[[], None]: - # this is identical to change_state, but defers the call - # perfect for GUI usage + """Return a callable that changes state when invoked (deferred call for GUI usage).""" return partial(self.change_state, state) - def close(self): + def close(self) -> None: """ Called when the application is requested to close by the user, usually by the console or application window being closed. """ self.change_state(State.EXIT) - def prevent_close(self): + def prevent_close(self) -> None: """ Called when the application window has to be prevented from closing, even after the user closes it with X. Usually used solely to display tracebacks from the closing sequence. """ self.gui.prevent_close() - def print(self, message: str): - """ - Can be used to print messages within the GUI. - """ + def print(self, message: str) -> None: + """Print a message in the GUI.""" self.gui.print(message) def save(self, *, force: bool = False) -> None: - """ - Saves the application state. - """ + """Save the application state (settings and GUI state).""" self.gui.save(force=force) self.settings.save(force=force) def get_priority(self, channel: Channel) -> int: """ - Return a priority number for a given channel. + Return a priority number for a given channel based on games_to_watch order. - 0 has the highest priority. + 0 has the highest priority (first in games_to_watch list). Higher numbers -> lower priority. MAX_INT (a really big number) signifies the lowest possible priority. """ @@ -575,11 +214,26 @@ class Twitch: @staticmethod def _viewers_key(channel: Channel) -> int: + """Sort key for channels by viewer count (descending).""" if (viewers := channel.viewers) is not None: return viewers return -1 - async def run(self): + def _remove_channel_topics(self, channels: abc.Iterable[Channel]) -> None: + """Remove websocket topics for a list of channels.""" + topics_to_remove: list[str] = [] + for channel in channels: + topics_to_remove.append( + WebsocketTopic.as_str("Channel", "StreamState", channel.id) + ) + topics_to_remove.append( + WebsocketTopic.as_str("Channel", "StreamUpdate", channel.id) + ) + if topics_to_remove: + self.websocket.remove_topics(topics_to_remove) + + async def run(self) -> None: + """Main entry point for the miner - handles reload and exit requests.""" if self.settings.dump: with open(DUMP_PATH, 'w', encoding="utf8"): # replace the existing file with an empty one @@ -595,7 +249,7 @@ class Twitch: except aiohttp.ContentTypeError as exc: raise RequestException(_("login", "unexpected_content")) from exc - async def _run(self): + async def _run(self) -> None: """ Main method that runs the whole client. @@ -605,6 +259,8 @@ class Twitch: • Changing the stream that's being watched if necessary """ self.gui.start() + # Initialize API clients now that GUI is available + self._ensure_api_clients() auth_state = await self.get_auth() await self.websocket.start() # NOTE: watch task is explicitly restarted on each new run @@ -642,42 +298,76 @@ class Twitch: self.change_state(State.GAMES_UPDATE) elif self._state is State.GAMES_UPDATE: # claim drops from expired and active campaigns + logger.info("Checking for claimable drops") + logger.debug("Campaigns in inventory: %s", self.inventory) for campaign in self.inventory: if not campaign.upcoming: for drop in campaign.drops: if drop.can_claim: await drop.claim() - # figure out which games we want + # figure out which games we want based on games_to_watch whitelist self.wanted_games.clear() - exclude = self.settings.exclude - priority = self.settings.priority - priority_mode = self.settings.priority_mode - priority_only = priority_mode is PriorityMode.PRIORITY_ONLY - next_hour = datetime.now(timezone.utc) + timedelta(hours=1) - # sorted_campaigns: list[DropsCampaign] = list(self.inventory) - sorted_campaigns: list[DropsCampaign] = self.inventory - if not priority_only: - if priority_mode is PriorityMode.ENDING_SOONEST: - sorted_campaigns.sort(key=lambda c: c.ends_at) - elif priority_mode is PriorityMode.LOW_AVBL_FIRST: - sorted_campaigns.sort(key=lambda c: c.availability) - sorted_campaigns.sort( - key=lambda c: ( - priority.index(c.game.name) if c.game.name in priority else MAX_INT + 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("inventory has %d eligible campaigns", sum(1 for c in self.inventory if c.eligible)) + logger.info("inventories: %s", self.inventory) + + # Log detailed game -> campaigns -> channels mapping + 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 = [""] + game_campaign_map[campaign.game.name].append((campaign, channel_names)) + + for game_name in sorted(game_campaign_map.keys()): + logger.info(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] != "" else "directory" + logger.info(f" └─ Campaign: {campaign.name} [{status_info}] (ends: {ends_info})") + logger.info(f" Channels: {channel_info}") + if channel_list[0] != "" and len(channel_list) <= 10: + logger.info(f" └─ {', '.join(channel_list)}") + elif channel_list[0] != "": + logger.info(f" └─ {', '.join(channel_list[:10])} ... (+{len(channel_list)-10} more)") + logger.info("=== End Campaigns Mapping ===") + + # Build wanted_games list preserving the order from games_to_watch + for game_name in games_to_watch: + # Find campaigns for this game (case-insensitive matching) + game_name_lower: str = game_name.lower() + for campaign in self.inventory: + game: Game = campaign.game + if ( + game.name.lower() == game_name_lower + and game not in self.wanted_games # isn't already there + and campaign.can_earn_within(next_hour) # can be progressed within the next hour + ): + self.wanted_games.append(game) + break # Only add each game once + + if self.wanted_games: + logger.info( + "Wanted games: %s", + ", ".join(game.name for game in self.wanted_games) ) - ) - for campaign in sorted_campaigns: - game: Game = campaign.game - if ( - game not in self.wanted_games # isn't already there - # and isn't excluded by list or priority mode - and game.name not in exclude - and (not priority_only or game.name in priority) - # and can be progressed within the next hour - and campaign.can_earn_within(next_hour) - ): - # non-excluded games with no priority are placed last, below priority ones - self.wanted_games.append(game) + else: + logger.warning( + "No wanted games found! games_to_watch=%s, eligible_campaigns=%d", + games_to_watch, + sum(1 for c in self.inventory if c.eligible and c.can_earn_within(next_hour)) + ) + full_cleanup = True self.restart_watching() self.change_state(State.CHANNELS_CLEANUP) @@ -702,19 +392,11 @@ class Twitch: ] full_cleanup = False if to_remove_channels: - to_remove_topics: list[str] = [] - for channel in to_remove_channels: - to_remove_topics.append( - WebsocketTopic.as_str("Channel", "StreamState", channel.id) - ) - to_remove_topics.append( - WebsocketTopic.as_str("Channel", "StreamUpdate", channel.id) - ) - self.websocket.remove_topics(to_remove_topics) + self._remove_channel_topics(to_remove_channels) for channel in to_remove_channels: del channels[channel.id] channel.remove() - del to_remove_channels, to_remove_topics + del to_remove_channels if self.wanted_games: self.change_state(State.CHANNELS_FETCH) else: @@ -768,16 +450,8 @@ class Twitch: if to_remove_channels: # tracked channels and gui were cleared earlier, so no need to do it here # just make sure to unsubscribe from their topics - to_remove_topics = [] - for channel in to_remove_channels: - to_remove_topics.append( - WebsocketTopic.as_str("Channel", "StreamState", channel.id) - ) - to_remove_topics.append( - WebsocketTopic.as_str("Channel", "StreamUpdate", channel.id) - ) - self.websocket.remove_topics(to_remove_topics) - del to_remove_channels, to_remove_topics + self._remove_channel_topics(to_remove_channels) + del to_remove_channels # set our new channel list for channel in ordered_channels: channels[channel.id] = channel @@ -832,40 +506,37 @@ class Twitch: self.gui.close() continue self.gui.status.update(_("gui", "status", "switching")) - # Change into the selected channel, stay in the watching channel, - # or select a new channel that meets the required conditions - new_watching = None - selected_channel = self.gui.channels.get_selection() + + # Determine the best channel to watch + new_watching: Channel | None = None + selected_channel: Channel | None = self.gui.channels.get_selection() + if selected_channel is not None and self.can_watch(selected_channel): - # selected channel is checked first, and set as long as we can watch it + # User-selected channel takes priority new_watching = selected_channel else: - # other channels additionally need to have a good reason - # for a switch (including the watching one) - # NOTE: we need to sort the channels every time because one channel - # can end up streaming any game - channels aren't game-tied + # 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 = self.watching_channel.get_with_default(None) + + watching_channel: Channel | None = self.watching_channel.get_with_default(None) + if new_watching is not None: - # if we have a better switch target - do so + # Switch to new channel self.watch(new_watching) - # break the state change chain by clearing the flag self._state_change.clear() elif watching_channel is not None and self.can_watch(watching_channel): - # otherwise, continue watching what we had before + # Continue watching current channel self.gui.status.update( _("status", "watching").format(channel=watching_channel.name) ) - # break the state change chain by clearing the flag self._state_change.clear() else: - # not watching anything and there isn't anything to watch either + # No channels available to watch self.print(_("status", "no_channel")) self.change_state(State.IDLE) - del new_watching, selected_channel, watching_channel elif self._state is State.EXIT: self.gui.tray.change_icon("pickaxe") self.gui.status.update(_("gui", "status", "exiting")) @@ -910,8 +581,9 @@ class Twitch: {"channelID": str(channel.id)} ) ) + assert isinstance(context, dict) drop_data: JsonType | None = ( - context["data"]["currentUser"]["dropCurrentSession"] + context["data"]["currentUser"]["dropCurrentSession"] # type: ignore[index] ) except GQLException: drop_data = None @@ -948,7 +620,7 @@ class Twitch: @task_wrapper(critical=True) async def _maintenance_task(self) -> None: now = datetime.now(timezone.utc) - next_period = now + timedelta(hours=1) + next_period = now + timedelta(minutes=1) while True: # exit if there's no need to repeat the loop now = datetime.now(timezone.utc) @@ -1011,28 +683,32 @@ class Twitch: and channel.acl_based > watching_channel.acl_based ) - def watch(self, channel: Channel, *, update_status: bool = True): + def watch(self, channel: Channel, *, update_status: bool = True) -> None: + """Start watching a specific channel.""" self.gui.tray.change_icon("active") self.gui.channels.set_watching(channel) self.watching_channel.set(channel) if update_status: - status_text = _("status", "watching").format(channel=channel.name) + status_text: str = _("status", "watching").format(channel=channel.name) self.print(status_text) self.gui.status.update(status_text) - def stop_watching(self): + def stop_watching(self) -> None: + """Stop watching the current channel.""" self.gui.clear_drop() self.watching_channel.clear() self.gui.channels.clear_watching() - def restart_watching(self): + def restart_watching(self) -> None: + """Restart the watch loop (forces immediate re-send of watch payload).""" self.gui.progress.stop_timer() self._watching_restart.set() @task_wrapper - async def process_stream_state(self, channel_id: int, message: JsonType): - msg_type = message["type"] - channel = self.channels.get(channel_id) + async def process_stream_state(self, channel_id: int, message: JsonType) -> None: + """Process websocket stream state updates (viewcount, stream-up, stream-down).""" + msg_type: str = message["type"] + channel: Channel | None = self.channels.get(channel_id) if channel is None: logger.error(f"Stream state change for a non-existing channel: {channel_id}") return @@ -1056,7 +732,8 @@ class Twitch: logger.warning(f"Unknown stream state: {msg_type}") @task_wrapper - async def process_stream_update(self, channel_id: int, message: JsonType): + async def process_stream_update(self, channel_id: int, message: JsonType) -> None: + """Process websocket broadcast settings updates (game/title changes).""" # message = { # "channel_id": "12345678", # "type": "broadcast_settings_update", @@ -1068,7 +745,7 @@ class Twitch: # "old_game_id": 123456, # "game_id": 123456 # } - channel = self.channels.get(channel_id) + channel: Channel | None = self.channels.get(channel_id) if channel is None: logger.error(f"Broadcast settings update for a non-existing channel: {channel_id}") return @@ -1085,62 +762,57 @@ class Twitch: def on_channel_update( self, channel: Channel, stream_before: Stream | None, stream_after: Stream | None - ): + ) -> None: """ - Called by a Channel when it's status is updated (ONLINE, OFFLINE, title/tags change). + Called by a Channel when its status is updated (ONLINE, OFFLINE, title/tags change). - NOTE: 'stream_before' gets dealocated once this function finishes. + NOTE: 'stream_before' gets deallocated once this function finishes. """ - if stream_before is None: - if stream_after is not None: - # Channel going ONLINE - if self.can_watch(channel) and self.should_switch(channel): - # we can watch the channel, and we should - self.print(_("status", "goes_online").format(channel=channel.name)) - self.watch(channel) - else: - logger.info(f"{channel.name} goes ONLINE") + watching_channel: Channel | None = self.watching_channel.get_with_default(None) + is_watching_this: bool = watching_channel is not None and watching_channel == channel + + # Channel going from OFFLINE to ONLINE + if stream_before is None and stream_after is not None: + if self.can_watch(channel) and self.should_switch(channel): + self.print(_("status", "goes_online").format(channel=channel.name)) + self.watch(channel) + else: + logger.info(f"{channel.name} goes ONLINE") + + # Channel going from ONLINE to OFFLINE + elif stream_before is not None and stream_after is None: + if is_watching_this: + self.print(_("status", "goes_offline").format(channel=channel.name)) + self.change_state(State.CHANNEL_SWITCH) else: - # Channel was OFFLINE and stays that way - logger.log(CALL, f"{channel.name} stays OFFLINE") - else: - watching_channel = self.watching_channel.get_with_default(None) - # check if the watching channel was the one updated - if watching_channel is not None and watching_channel == channel: - # NOTE: In these cases, channel was the watching channel - if not self.can_watch(channel): - # we can't watch it anymore - if stream_after is None: - # Channel going OFFLINE - self.print(_("status", "goes_offline").format(channel=channel.name)) - else: - # Channel stays ONLINE, but we can't watch it anymore - logger.info( - f"{channel.name} status has been updated, switching... " - f"(🎁: {stream_before.drops_enabled and '✔' or '❌'} -> " - f"{stream_after.drops_enabled and '✔' or '❌'})" - ) - self.change_state(State.CHANNEL_SWITCH) - else: - # Channel stays ONLINE, and we can still watch it - no change - pass - # NOTE: In these cases, it wasn't the watching channel - elif stream_after is None: logger.info(f"{channel.name} goes OFFLINE") - else: - # Channel stays ONLINE, but has been updated - logger.info( - f"{channel.name} status has been updated " - f"(🎁: {stream_before.drops_enabled and '✔' or '❌'} -> " - f"{stream_after.drops_enabled and '✔' or '❌'})" - ) + + # Channel staying ONLINE but with updates + elif stream_before is not None and stream_after is not None: + drops_status: str = ( + f"(🎁: {stream_before.drops_enabled and '✔' or '❌'} -> " + f"{stream_after.drops_enabled and '✔' or '❌'})" + ) + + if is_watching_this and not self.can_watch(channel): + # Watching this channel but can't watch it anymore + logger.info(f"{channel.name} status updated, switching... {drops_status}") + self.change_state(State.CHANNEL_SWITCH) + elif not is_watching_this: + # Not watching this channel + logger.info(f"{channel.name} status updated {drops_status}") if self.can_watch(channel) and self.should_switch(channel): - # ... and we can and should watch it self.watch(channel) + + # Channel was OFFLINE and stays OFFLINE + else: + logger.log(CALL, f"{channel.name} stays OFFLINE") + channel.display() @task_wrapper - async def process_drops(self, user_id: int, message: JsonType): + async def process_drops(self, user_id: int, message: JsonType) -> None: + """Process websocket drop progress and claim updates.""" # Message examples: # {"type": "drop-progress", data: {"current_progress_min": 3, "required_progress_min": 10}} # {"type": "drop-claim", data: {"drop_instance_id": ...}} @@ -1172,8 +844,9 @@ class Twitch: {"channelID": str(watching_channel.id)} ) ) + assert isinstance(context, dict) drop_data: JsonType | None = ( - context["data"]["currentUser"]["dropCurrentSession"] + context["data"]["currentUser"]["dropCurrentSession"] # type: ignore[index] ) if drop_data is None or drop_data["dropID"] != drop.id: break @@ -1198,7 +871,8 @@ class Twitch: drop.update_minutes(message["data"]["current_progress_min"]) @task_wrapper - async def process_notifications(self, user_id: int, message: JsonType): + async def process_notifications(self, user_id: int, message: JsonType) -> None: + """Process websocket notification updates.""" if message["type"] == "create-notification": data: JsonType = message["data"]["notification"] if data["type"] == "user_drop_reward_reminder_notification": @@ -1210,169 +884,28 @@ class Twitch: ) async def get_auth(self) -> _AuthState: + """Get authentication state (validates token if needed).""" await self._auth_state.validate() return self._auth_state - @asynccontextmanager - async def request( - self, method: str, url: URL | str, *, invalidate_after: datetime | None = None, **kwargs - ) -> abc.AsyncIterator[aiohttp.ClientResponse]: - session = await self.get_session() - method = method.upper() - if self.settings.proxy and "proxy" not in kwargs: - kwargs["proxy"] = self.settings.proxy - logger.debug(f"Request: ({method=}, {url=}, {kwargs=})") - session_timeout = timedelta(seconds=session.timeout.total or 0) - backoff = ExponentialBackoff(maximum=3*60) - for delay in backoff: - if self.gui.close_requested: - raise ExitRequest() - elif ( - invalidate_after is not None - # account for the expiration landing during the request - and datetime.now(timezone.utc) >= (invalidate_after - session_timeout) - ): - raise RequestInvalid() - try: - response: aiohttp.ClientResponse | None = None - response = await self.gui.coro_unless_closed( - session.request(method, url, **kwargs) - ) - assert response is not None - logger.debug(f"Response: {response.status}: {response}") - if response.status < 500: - # pre-read the response to avoid getting errors outside of the context manager - raw_response = await response.read() # noqa - yield response - return - self.print(_("error", "site_down").format(seconds=round(delay))) - except aiohttp.ClientConnectorCertificateError: - # for a case where SSL verification fails - raise - except ( - aiohttp.ClientConnectionError, asyncio.TimeoutError, aiohttp.ClientPayloadError - ): - # connection problems, retry - if backoff.steps > 1: - # just so that quick retries that sometimes happen, aren't shown - self.print(_("error", "no_connection").format(seconds=round(delay))) - finally: - if response is not None: - response.release() - with suppress(asyncio.TimeoutError): - await asyncio.wait_for(self.gui.wait_until_closed(), timeout=delay) - - @overload - async def gql_request(self, ops: GQLOperation) -> JsonType: - ... - - @overload - async def gql_request(self, ops: list[GQLOperation]) -> list[JsonType]: - ... - async def gql_request( self, ops: GQLOperation | list[GQLOperation] ) -> JsonType | list[JsonType]: - gql_logger.debug(f"GQL Request: {ops}") - backoff = ExponentialBackoff(maximum=60) - # Use a flag to retry the request a single time, if a specific set of errors is encountered - single_retry: bool = True - for delay in backoff: - async with self._qgl_limiter: - auth_state = await self.get_auth() - async with self.request( - "POST", - "https://gql.twitch.tv/gql", - json=ops, - headers=auth_state.headers(user_agent=self._client_type.USER_AGENT, gql=True), - ) as response: - response_json: JsonType | list[JsonType] = await response.json() - gql_logger.debug(f"GQL Response: {response_json}") - orig_response = response_json - if isinstance(response_json, list): - response_list = response_json - else: - response_list = [response_json] - force_retry: bool = False - for response_json in response_list: - # GQL error handling - if "errors" in response_json: - for error_dict in response_json["errors"]: - if "message" in error_dict: - if ( - single_retry - and error_dict["message"] in ( - "service error" - "PersistedQueryNotFound" - ) - ): - logger.error( - f"Retrying a {error_dict['message']} for " - f"{response_json['extensions']['operationName']}" - ) - single_retry = False - if delay < 5: - # overwrite the delay if too short - delay = 5 - force_retry = True - break - elif error_dict["message"] == "server error": - # nullify the key the error path points to - data_dict: JsonType = response_json["data"] - path: list[str] = error_dict.get("path", []) - for key in path[:-1]: - data_dict = data_dict[key] - data_dict[path[-1]] = None - break - elif ( - error_dict["message"] in ( - "service timeout", - "service unavailable", - "context deadline exceeded", - ) - ): - force_retry = True - break - else: - raise GQLException(response_json['errors']) - # Other error handling - elif "error" in response_json: - raise GQLException( - f"{response_json['error']}: {response_json['message']}" - ) - if force_retry: - break - else: - return orig_response - await asyncio.sleep(delay) - raise RuntimeError("Retry loop was broken") + """ + Execute GraphQL request(s). - def _merge_data(self, primary_data: JsonType, secondary_data: JsonType) -> JsonType: - merged = {} - for key in set(chain(primary_data.keys(), secondary_data.keys())): - in_primary = key in primary_data - if in_primary and key in secondary_data: - vp = primary_data[key] - vs = secondary_data[key] - if not isinstance(vp, type(vs)) or not isinstance(vs, type(vp)): - raise MinerException("Inconsistent merge data") - if isinstance(vp, dict): # both are dicts - merged[key] = self._merge_data(vp, vs) - else: - # use primary value - merged[key] = vp - elif in_primary: - merged[key] = primary_data[key] - else: # in campaigns only - merged[key] = secondary_data[key] - return merged + Delegates to GQLClient for execution. + """ + self._ensure_api_clients() + assert self._gql_client is not None + return await self._gql_client.request(ops) async def fetch_campaigns( self, campaigns_chunk: list[tuple[str, JsonType]] ) -> dict[str, JsonType]: campaign_ids: dict[str, JsonType] = dict(campaigns_chunk) auth_state = await self.get_auth() - response_list: list[JsonType] = await self.gql_request( + response_list_raw = await self.gql_request( [ GQL_OPERATIONS["CampaignDetails"].with_variables( {"channelLogin": str(auth_state.user_id), "dropID": cid} @@ -1380,27 +913,33 @@ class Twitch: for cid in campaign_ids ] ) + # Ensure we have a list + response_list: list[JsonType] = ( + response_list_raw if isinstance(response_list_raw, list) else [response_list_raw] + ) fetched_data: dict[str, JsonType] = { - (campaign_data := response_json["data"]["user"]["dropCampaign"])["id"]: campaign_data + (campaign_data := response_json["data"]["user"]["dropCampaign"])["id"]: campaign_data # type: ignore[index] for response_json in response_list } - return self._merge_data(campaign_ids, fetched_data) + return GQLClient.merge_data(campaign_ids, fetched_data) async def fetch_inventory(self) -> None: status_update = self.gui.status.update status_update(_("gui", "status", "fetching_inventory")) # fetch in-progress campaigns (inventory) response = await self.gql_request(GQL_OPERATIONS["Inventory"]) - inventory: JsonType = response["data"]["currentUser"]["inventory"] + assert isinstance(response, dict) + inventory: JsonType = response["data"]["currentUser"]["inventory"] # type: ignore[index] ongoing_campaigns: list[JsonType] = inventory["dropCampaignsInProgress"] or [] # this contains claimed benefit edge IDs, not drop IDs claimed_benefits: dict[str, datetime] = { - b["id"]: timestamp(b["lastAwardedAt"]) for b in inventory["gameEventDrops"] + b["id"]: isoparse(b["lastAwardedAt"]) for b in inventory["gameEventDrops"] } inventory_data: dict[str, JsonType] = {c["id"]: c for c in ongoing_campaigns} # fetch general available campaigns data (campaigns) response = await self.gql_request(GQL_OPERATIONS["Campaigns"]) - available_list: list[JsonType] = response["data"]["currentUser"]["dropCampaigns"] or [] + assert isinstance(response, dict) + available_list: list[JsonType] = response["data"]["currentUser"]["dropCampaigns"] or [] # type: ignore[index] applicable_statuses = ("ACTIVE", "UPCOMING") available_campaigns: dict[str, JsonType] = { c["id"]: c @@ -1417,7 +956,7 @@ class Twitch: for coro in asyncio.as_completed(fetch_campaigns_tasks): chunk_campaigns_data = await coro # merge the inventory and campaigns datas together - inventory_data = self._merge_data(inventory_data, chunk_campaigns_data) + inventory_data = GQLClient.merge_data(inventory_data, chunk_campaigns_data) except Exception: # asyncio.as_completed doesn't cancel tasks on errors for task in fetch_campaigns_tasks: @@ -1544,12 +1083,13 @@ class Twitch: ) except GQLException as exc: raise MinerException(f"Game: {game.slug}") from exc - if "game" in response["data"]: + assert isinstance(response, dict) + if "game" in response["data"]: # type: ignore[operator] return [ Channel.from_directory( self, stream_channel_data["node"], drops_enabled=drops_enabled ) - for stream_channel_data in response["data"]["game"]["streams"]["edges"] + for stream_channel_data in response["data"]["game"]["streams"]["edges"] # type: ignore[index] if stream_channel_data["node"]["broadcaster"] is not None ] return [] @@ -1565,15 +1105,16 @@ class Twitch: # shortcut for nothing to process # NOTE: Have to do this here, becase "channels" can be any iterable return - stream_gql_tasks: list[asyncio.Task[list[JsonType]]] = [ + stream_gql_tasks: list[asyncio.Task[JsonType | list[JsonType]]] = [ asyncio.create_task(self.gql_request(stream_gql_chunk)) for stream_gql_chunk in chunk(stream_gql_ops, 20) ] try: for coro in asyncio.as_completed(stream_gql_tasks): - response_list: list[JsonType] = await coro + response = await coro + response_list: list[JsonType] = response if isinstance(response, list) else [response] for response_json in response_list: - channel_data: JsonType = response_json["data"]["user"] + channel_data: JsonType = response_json["data"]["user"] # type: ignore[index] if channel_data is not None: acl_streams_map[int(channel_data["id"])] = channel_data except Exception: diff --git a/exceptions.py b/src/exceptions.py similarity index 100% rename from exceptions.py rename to src/exceptions.py diff --git a/src/i18n/__init__.py b/src/i18n/__init__.py new file mode 100644 index 0000000..220f360 --- /dev/null +++ b/src/i18n/__init__.py @@ -0,0 +1,59 @@ +"""Internationalization (i18n) package for Twitch Drops Miner.""" + +from __future__ import annotations + +from .translator import ( + StatusMessages, + ChromeMessages, + LoginMessages, + ErrorMessages, + GUIStatus, + GUITabs, + GUITray, + GUILoginForm, + GUIWebsocket, + GUIProgress, + GUIChannelHeadings, + GUIChannels, + GUIInvFilter, + GUIInvStatus, + GUIInventory, + GUISettingsGeneral, + GUIPriorityModes, + GUISettings, + GUIHelpLinks, + GUIHelp, + GUIMessages, + Translation, + default_translation, + Translator, + _, +) + +__all__ = [ + "StatusMessages", + "ChromeMessages", + "LoginMessages", + "ErrorMessages", + "GUIStatus", + "GUITabs", + "GUITray", + "GUILoginForm", + "GUIWebsocket", + "GUIProgress", + "GUIChannelHeadings", + "GUIChannels", + "GUIInvFilter", + "GUIInvStatus", + "GUIInventory", + "GUISettingsGeneral", + "GUIPriorityModes", + "GUISettings", + "GUIHelpLinks", + "GUIHelp", + "GUIMessages", + "Translation", + "default_translation", + "Translator", + "_", +] diff --git a/translate.py b/src/i18n/translator.py similarity index 98% rename from translate.py rename to src/i18n/translator.py index 33a1b7d..5559105 100644 --- a/translate.py +++ b/src/i18n/translator.py @@ -3,9 +3,9 @@ from __future__ import annotations from collections import abc from typing import Any, TypedDict, TYPE_CHECKING -from exceptions import MinerException -from utils import json_load, json_save -from constants import IS_PACKAGED, LANG_PATH, DEFAULT_LANG +from src.exceptions import MinerException +from src.utils.json_utils import json_load, json_save +from src.config import IS_PACKAGED, LANG_PATH, DEFAULT_LANG if TYPE_CHECKING: from typing_extensions import NotRequired @@ -48,6 +48,7 @@ class ErrorMessages(TypedDict): class GUIStatus(TypedDict): name: str idle: str + ready: str exiting: str terminated: str cleanup: str @@ -262,6 +263,7 @@ default_translation: Translation = { "status": { "name": "Status", "idle": "Idle", + "ready": "Ready", "exiting": "Exiting...", "terminated": "Terminated", "cleanup": "Cleaning up channels...", diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..bcdd97b --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,19 @@ +"""Domain models for Twitch drops mining.""" + +from src.models.game import Game +from src.models.benefit import Benefit, BenefitType +from src.models.drop import BaseDrop, TimedDrop, remove_dimensions +from src.models.campaign import DropsCampaign +from src.models.channel import Channel, Stream + +__all__ = [ + "Game", + "Benefit", + "BenefitType", + "BaseDrop", + "TimedDrop", + "remove_dimensions", + "DropsCampaign", + "Channel", + "Stream", +] diff --git a/src/models/benefit.py b/src/models/benefit.py new file mode 100644 index 0000000..7b67e01 --- /dev/null +++ b/src/models/benefit.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.config.constants import JsonType, URLType + + +class BenefitType(Enum): + """Type of drop benefit (reward).""" + UNKNOWN = "UNKNOWN" + BADGE = "BADGE" + EMOTE = "EMOTE" + DIRECT_ENTITLEMENT = "DIRECT_ENTITLEMENT" + + def is_badge_or_emote(self) -> bool: + return self in (BenefitType.BADGE, BenefitType.EMOTE) + + +class Benefit: + """Represents a reward/benefit from a completed drop.""" + __slots__ = ("id", "name", "type", "image_url") + + def __init__(self, data: JsonType): + benefit_data: JsonType = data["benefit"] + self.id: str = benefit_data["id"] + self.name: str = benefit_data["name"] + self.type: BenefitType = ( + BenefitType(benefit_data["distributionType"]) + if benefit_data["distributionType"] in BenefitType.__members__.keys() + else BenefitType.UNKNOWN + ) + self.image_url: URLType = benefit_data["imageAssetURL"] diff --git a/src/models/campaign.py b/src/models/campaign.py new file mode 100644 index 0000000..b9c15fc --- /dev/null +++ b/src/models/campaign.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import logging +from itertools import chain +from typing import TYPE_CHECKING +from functools import cached_property +from datetime import datetime, timezone +from dateutil.parser import isoparse + +from src.models.channel import Channel +from src.models.game import Game +from src.models.drop import TimedDrop, remove_dimensions +from src.config.constants import URLType, State + +if TYPE_CHECKING: + from collections import abc + + from src.core.client import Twitch + from src.config.constants import JsonType + + +logger = logging.getLogger("TwitchDrops") + + +class DropsCampaign: + def __init__(self, twitch: Twitch, data: JsonType, claimed_benefits: dict[str, datetime]): + self._twitch: Twitch = twitch + self.id: str = data["id"] + self.name: str = data["name"] + self.game: Game = Game(data["game"]) + self.linked: bool = data["self"]["isAccountConnected"] + self.link_url: str = data["accountLinkURL"] + # campaign's image actually comes from the game object + # we use regex to get rid of the dimensions part (ex. ".../game_id-285x380.jpg") + self.image_url: URLType = remove_dimensions(data["game"]["boxArtURL"]) + self.starts_at: datetime = isoparse(data["startAt"]) + self.ends_at: datetime = isoparse(data["endAt"]) + self._valid: bool = data["status"] != "EXPIRED" + allowed: JsonType = data["allow"] + self.allowed_channels: list[Channel] = ( + [Channel.from_acl(twitch, channel_data) for channel_data in allowed["channels"]] + if allowed["channels"] and allowed.get("isEnabled", True) else [] + ) + self.timed_drops: dict[str, TimedDrop] = { + drop_data["id"]: TimedDrop(self, drop_data, claimed_benefits) + for drop_data in data["timeBasedDrops"] + } + + def __repr__(self) -> str: + return f"Campaign({self.game!s}, {self.name}, {self.claimed_drops}/{self.total_drops})" + + @property + def drops(self) -> abc.Iterable[TimedDrop]: + return self.timed_drops.values() + + @property + def time_triggers(self) -> set[datetime]: + return set( + chain( + (self.starts_at, self.ends_at), + *((d.starts_at, d.ends_at) for d in self.timed_drops.values()), + ) + ) + + @property + def active(self) -> bool: + return self._valid and self.starts_at <= datetime.now(timezone.utc) < self.ends_at + + @property + def upcoming(self) -> bool: + return self._valid and datetime.now(timezone.utc) < self.starts_at + + @property + def expired(self) -> bool: + return not self._valid or self.ends_at <= datetime.now(timezone.utc) + + @property + def total_drops(self) -> int: + return len(self.timed_drops) + + @property + def eligible(self) -> bool: + return self.linked or self.has_badge_or_emote + + @cached_property + def has_badge_or_emote(self) -> bool: + return any( + benefit.type.is_badge_or_emote() for drop in self.drops for benefit in drop.benefits + ) + + @property + def finished(self) -> bool: + return all(d.is_claimed or d.required_minutes <= 0 for d in self.drops) + + @property + def claimed_drops(self) -> int: + return sum(d.is_claimed for d in self.drops) + + @property + def remaining_drops(self) -> int: + return sum(not d.is_claimed for d in self.drops) + + @property + def required_minutes(self) -> int: + return max(d.total_required_minutes for d in self.drops) + + @property + def remaining_minutes(self) -> int: + return max(d.total_remaining_minutes for d in self.drops) + + @property + def progress(self) -> float: + return sum(d.progress for d in self.drops) / self.total_drops + + @property + def availability(self) -> float: + return min(d.availability for d in self.drops) + + @property + def first_drop(self) -> TimedDrop | None: + drops: list[TimedDrop] = sorted( + (drop for drop in self.drops if drop.can_earn()), + key=lambda d: d.remaining_minutes, + ) + return drops[0] if drops else None + + def _update_real_minutes(self, delta: int) -> None: + for drop in self.drops: + drop._update_real_minutes(delta) + if (first_drop := self.first_drop) is not None: + first_drop.display() + + def _base_can_earn( + self, channel: Channel | None = None, ignore_channel_status: bool = False + ) -> bool: + return ( + self.eligible # account is eligible + and self.active # campaign is active (and valid) + and ( + channel is None or ( # channel isn't specified, + # or there's no ACL, or the channel is in the ACL + (not self.allowed_channels or channel in self.allowed_channels) + # and the channel is live and playing the campaign's game + and ( + ignore_channel_status + or channel.game is not None and channel.game == self.game + ) + ) + ) + ) + + def get_drop(self, drop_id: str) -> TimedDrop | None: + """Get a specific drop by ID from this campaign.""" + return self.timed_drops.get(drop_id) + + def preconditions_chain(self) -> set[str]: + """Return all drop IDs that are preconditions for unclaimed drops.""" + return set( + chain.from_iterable( + drop.precondition_drops for drop in self.drops if not drop.is_claimed + ) + ) + + def can_earn( + self, channel: Channel | None = None, ignore_channel_status: bool = False + ) -> bool: + # True if any of the containing drops can be earned + return ( + self._base_can_earn(channel, ignore_channel_status) + and any(drop._base_can_earn() for drop in self.drops) + ) + + def can_earn_within(self, stamp: datetime) -> bool: + # Same as can_earn, but doesn't check the channel + # and uses a future timestamp to see if we can earn this campaign later + return ( + self.eligible + and self._valid + and self.ends_at > datetime.now(timezone.utc) + and self.starts_at < stamp + and any(drop._can_earn_within(stamp) for drop in self.drops) + ) + + def bump_minutes(self, channel: Channel) -> None: + """ + Bump the minute counter for all earnable drops in this campaign. + Used when websocket updates aren't available. + """ + # NOTE: Use a temporary list to ensure all drops are bumped before checking + if any([drop._bump_minutes(channel) for drop in self.drops]): + # Executes if any drop's extra_current_minutes reach MAX_ESTIMATED_MINUTES + # TODO: Figure out a better way to handle this case + logger.warning( + f"At least one of the drops in campaign \"{self.name}({self.game.name})\" " + "has reached the maximum extra minutes limit!" + ) + self._twitch.change_state(State.CHANNEL_SWITCH) + if (first_drop := self.first_drop) is not None: + first_drop.display() diff --git a/channel.py b/src/models/channel.py similarity index 96% rename from channel.py rename to src/models/channel.py index 789cc8c..05c7af0 100644 --- a/channel.py +++ b/src/models/channel.py @@ -11,14 +11,15 @@ from typing import Any, SupportsInt, cast, TYPE_CHECKING import aiohttp from yarl import URL -from utils import Game, json_minify -from exceptions import MinerException, RequestException -from constants import CALL, GQL_OPERATIONS, ONLINE_DELAY, URLType +from src.models.game import Game +from src.config.constants import JsonType, GQLOperation, URLType, CALL, ONLINE_DELAY +from src.config.operations import GQL_OPERATIONS +from src.utils.json_utils import json_minify +from src.exceptions import MinerException, RequestException if TYPE_CHECKING: - from twitch import Twitch - from gui import ChannelList - from constants import JsonType, GQLOperation + from src.core.client import Twitch + from src.web.gui_manager import ChannelList logger = logging.getLogger("TwitchDrops") @@ -269,10 +270,12 @@ class Channel: return self._stream.drops_enabled return False - def display(self, *, add: bool = False): + def display(self, *, add: bool = False) -> None: + """Display or update this channel in the GUI channel list.""" self._gui_channels.display(self, add=add) - def remove(self): + def remove(self) -> None: + """Remove this channel from the GUI and cancel pending tasks.""" if self._pending_stream_up is not None: self._pending_stream_up.cancel() self._pending_stream_up = None @@ -315,7 +318,7 @@ class Channel: for campaign_data in available_drops ) - def external_update(self, channel_data: JsonType, available_drops: list[JsonType]): + def external_update(self, channel_data: JsonType, available_drops: list[JsonType]) -> None: """ Update stream information based on data provided externally. @@ -375,7 +378,7 @@ class Channel: self._pending_stream_up = None # for 'display' to work properly await self.update_stream() - def check_online(self): + def check_online(self) -> None: """ Sets up a task that will wait ONLINE_DELAY duration, and then check for the stream being ONLINE OR OFFLINE. @@ -392,7 +395,7 @@ class Channel: self._pending_stream_up = asyncio.create_task(self._online_delay()) self.display() - def set_offline(self): + def set_offline(self) -> None: """ Sets the channel status to OFFLINE. Cancels PENDING_ONLINE if applicable. diff --git a/inventory.py b/src/models/drop.py similarity index 58% rename from inventory.py rename to src/models/drop.py index 4f25b06..d1953ca 100644 --- a/inventory.py +++ b/src/models/drop.py @@ -1,58 +1,31 @@ from __future__ import annotations import re -import math import logging -from enum import Enum -from itertools import chain from typing import TYPE_CHECKING -from functools import cached_property from datetime import datetime, timedelta, timezone +from dateutil.parser import isoparse -from translate import _ -from channel import Channel -from utils import timestamp, Game -from exceptions import GQLException -from constants import GQL_OPERATIONS, MAX_EXTRA_MINUTES, URLType, State +from src.i18n import _ +from src.models.benefit import Benefit +from src.exceptions import GQLException +from src.config.constants import MAX_EXTRA_MINUTES, State +from src.config.operations import GQL_OPERATIONS if TYPE_CHECKING: - from collections import abc - - from twitch import Twitch - from constants import JsonType + from src.core.client import Twitch + from src.models.channel import Channel + from src.models.campaign import DropsCampaign + from src.config.constants import JsonType logger = logging.getLogger("TwitchDrops") DIMS_PATTERN = re.compile(r'-\d+x\d+(?=\.(?:jpg|png|gif)$)', re.I) -def remove_dimensions(url: URLType) -> URLType: - return URLType(DIMS_PATTERN.sub('', url)) - - -class BenefitType(Enum): - UNKNOWN = "UNKNOWN" - BADGE = "BADGE" - EMOTE = "EMOTE" - DIRECT_ENTITLEMENT = "DIRECT_ENTITLEMENT" - - def is_badge_or_emote(self) -> bool: - return self in (BenefitType.BADGE, BenefitType.EMOTE) - - -class Benefit: - __slots__ = ("id", "name", "type", "image_url") - - def __init__(self, data: JsonType): - benefit_data: JsonType = data["benefit"] - self.id: str = benefit_data["id"] - self.name: str = benefit_data["name"] - self.type: BenefitType = ( - BenefitType(benefit_data["distributionType"]) - if benefit_data["distributionType"] in BenefitType.__members__.keys() - else BenefitType.UNKNOWN - ) - self.image_url: URLType = benefit_data["imageAssetURL"] +def remove_dimensions(url: str) -> str: + """Remove dimension suffix from Twitch image URLs (e.g., -285x380.jpg).""" + return DIMS_PATTERN.sub('', url) class BaseDrop: @@ -64,8 +37,8 @@ class BaseDrop: self.name: str = data["name"] self.campaign: DropsCampaign = campaign self.benefits: list[Benefit] = [Benefit(b) for b in (data["benefitEdges"] or [])] - self.starts_at: datetime = timestamp(data["startAt"]) - self.ends_at: datetime = timestamp(data["endAt"]) + self.starts_at: datetime = isoparse(data["startAt"]) + self.ends_at: datetime = isoparse(data["endAt"]) self.claim_id: str | None = None self.is_claimed: bool = False if "self" in data: @@ -150,7 +123,8 @@ class BaseDrop: and datetime.now(timezone.utc) < self.campaign.ends_at + timedelta(hours=24) ) - def update_claim(self, claim_id: str): + def update_claim(self, claim_id: str) -> None: + """Update the claim ID for this drop.""" self.claim_id = claim_id async def generate_claim(self) -> None: @@ -280,6 +254,7 @@ class TimedDrop(BaseDrop): @property def availability(self) -> float: + import math now = datetime.now(timezone.utc) if self.required_minutes > 0 and self.total_remaining_minutes > 0 and now < self.ends_at: return ((self.ends_at - now).total_seconds() / 60) / self.total_remaining_minutes @@ -323,10 +298,12 @@ class TimedDrop(BaseDrop): self._on_state_changed() return result - def display(self, *, countdown: bool = True, subone: bool = False): + def display(self, *, countdown: bool = True, subone: bool = False) -> None: + """Display this drop in the GUI with progress information.""" self._twitch.gui.display_drop(self, countdown=countdown, subone=subone) - def update_minutes(self, new_minutes: int): + def update_minutes(self, new_minutes: int) -> None: + """Update the current watched minutes for this drop.""" delta: int = new_minutes - self.real_current_minutes if delta == 0: return @@ -335,174 +312,3 @@ class TimedDrop(BaseDrop): elif self.real_current_minutes + delta > self.required_minutes: delta = self.required_minutes - self.real_current_minutes self.campaign._update_real_minutes(delta) - - -class DropsCampaign: - def __init__(self, twitch: Twitch, data: JsonType, claimed_benefits: dict[str, datetime]): - self._twitch: Twitch = twitch - self.id: str = data["id"] - self.name: str = data["name"] - self.game: Game = Game(data["game"]) - self.linked: bool = data["self"]["isAccountConnected"] - self.link_url: str = data["accountLinkURL"] - # campaign's image actually comes from the game object - # we use regex to get rid of the dimensions part (ex. ".../game_id-285x380.jpg") - self.image_url: URLType = remove_dimensions(data["game"]["boxArtURL"]) - self.starts_at: datetime = timestamp(data["startAt"]) - self.ends_at: datetime = timestamp(data["endAt"]) - self._valid: bool = data["status"] != "EXPIRED" - allowed: JsonType = data["allow"] - self.allowed_channels: list[Channel] = ( - [Channel.from_acl(twitch, channel_data) for channel_data in allowed["channels"]] - if allowed["channels"] and allowed.get("isEnabled", True) else [] - ) - self.timed_drops: dict[str, TimedDrop] = { - drop_data["id"]: TimedDrop(self, drop_data, claimed_benefits) - for drop_data in data["timeBasedDrops"] - } - - def __repr__(self) -> str: - return f"Campaign({self.game!s}, {self.name}, {self.claimed_drops}/{self.total_drops})" - - @property - def drops(self) -> abc.Iterable[TimedDrop]: - return self.timed_drops.values() - - @property - def time_triggers(self) -> set[datetime]: - return set( - chain( - (self.starts_at, self.ends_at), - *((d.starts_at, d.ends_at) for d in self.timed_drops.values()), - ) - ) - - @property - def active(self) -> bool: - return self._valid and self.starts_at <= datetime.now(timezone.utc) < self.ends_at - - @property - def upcoming(self) -> bool: - return self._valid and datetime.now(timezone.utc) < self.starts_at - - @property - def expired(self) -> bool: - return not self._valid or self.ends_at <= datetime.now(timezone.utc) - - @property - def total_drops(self) -> int: - return len(self.timed_drops) - - @property - def eligible(self) -> bool: - return self.linked or self.has_badge_or_emote - - @cached_property - def has_badge_or_emote(self) -> bool: - return any( - benefit.type.is_badge_or_emote() for drop in self.drops for benefit in drop.benefits - ) - - @property - def finished(self) -> bool: - return all(d.is_claimed or d.required_minutes <= 0 for d in self.drops) - - @property - def claimed_drops(self) -> int: - return sum(d.is_claimed for d in self.drops) - - @property - def remaining_drops(self) -> int: - return sum(not d.is_claimed for d in self.drops) - - @property - def required_minutes(self) -> int: - return max(d.total_required_minutes for d in self.drops) - - @property - def remaining_minutes(self) -> int: - return max(d.total_remaining_minutes for d in self.drops) - - @property - def progress(self) -> float: - return sum(d.progress for d in self.drops) / self.total_drops - - @property - def availability(self) -> float: - return min(d.availability for d in self.drops) - - @property - def first_drop(self) -> TimedDrop | None: - drops: list[TimedDrop] = sorted( - (drop for drop in self.drops if drop.can_earn()), - key=lambda d: d.remaining_minutes, - ) - return drops[0] if drops else None - - def _update_real_minutes(self, delta: int) -> None: - for drop in self.drops: - drop._update_real_minutes(delta) - if (first_drop := self.first_drop) is not None: - first_drop.display() - - def _base_can_earn( - self, channel: Channel | None = None, ignore_channel_status: bool = False - ) -> bool: - return ( - self.eligible # account is eligible - and self.active # campaign is active (and valid) - and ( - channel is None or ( # channel isn't specified, - # or there's no ACL, or the channel is in the ACL - (not self.allowed_channels or channel in self.allowed_channels) - # and the channel is live and playing the campaign's game - and ( - ignore_channel_status - or channel.game is not None and channel.game == self.game - ) - ) - ) - ) - - def get_drop(self, drop_id: str) -> TimedDrop | None: - return self.timed_drops.get(drop_id) - - def preconditions_chain(self) -> set[str]: - return set( - chain.from_iterable( - drop.precondition_drops for drop in self.drops if not drop.is_claimed - ) - ) - - def can_earn( - self, channel: Channel | None = None, ignore_channel_status: bool = False - ) -> bool: - # True if any of the containing drops can be earned - return ( - self._base_can_earn(channel, ignore_channel_status) - and any(drop._base_can_earn() for drop in self.drops) - ) - - def can_earn_within(self, stamp: datetime) -> bool: - # Same as can_earn, but doesn't check the channel - # and uses a future timestamp to see if we can earn this campaign later - return ( - self.eligible - and self._valid - and self.ends_at > datetime.now(timezone.utc) - and self.starts_at < stamp - and any(drop._can_earn_within(stamp) for drop in self.drops) - ) - - def bump_minutes(self, channel: Channel) -> None: - # NOTE: Use a temporary list to ensure all drops are bumped before checking - if any([drop._bump_minutes(channel) for drop in self.drops]): - # Executes if any drop's extra_current_minutes reach MAX_ESTIMATED_MINUTES - # TODO: Figure out a better way to handle this case - logger.warning( - f"At least one of the drops in campaign \"{self.name}({self.game.name})\" " - "has reached the maximum extra minutes limit!" - ) - self._twitch.change_state(State.CHANNEL_SWITCH) - if (first_drop := self.first_drop) is not None: - first_drop.display() diff --git a/src/models/game.py b/src/models/game.py new file mode 100644 index 0000000..b304d21 --- /dev/null +++ b/src/models/game.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import re +from functools import cached_property +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.config.constants import JsonType + + +class Game: + """Represents a Twitch game/category.""" + + def __init__(self, data: JsonType): + self.id: int = int(data["id"]) + self.name: str = data.get("displayName") or data["name"] + if "slug" in data: + self.slug = data["slug"] + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f"Game({self.id}, {self.name})" + + def __eq__(self, other: object) -> bool: + if isinstance(other, self.__class__): + return self.id == other.id + return NotImplemented + + def __hash__(self) -> int: + return self.id + + @cached_property + def slug(self) -> str: + """ + Converts the game name into a slug, useable for the GQL API. + """ + # remove specific characters + slug_text = re.sub(r'\'', '', self.name.lower()) + # remove non alpha-numeric characters + slug_text = re.sub(r'\W+', '-', slug_text) + # strip and collapse dashes + slug_text = re.sub(r'-{2,}', '-', slug_text.strip('-')) + return slug_text diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/channel_service.py b/src/services/channel_service.py new file mode 100644 index 0000000..3271f80 --- /dev/null +++ b/src/services/channel_service.py @@ -0,0 +1,177 @@ +""" +Channel service for managing channel discovery, online status checks, and priority sorting. + +This service handles all channel-related operations including fetching live streams +from directories, bulk online status verification, and channel priority determination. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING +from collections import abc + +from src.utils import chunk +from src.config import MAX_INT, GQL_OPERATIONS +from src.models.channel import Channel +from src.exceptions import GQLException, MinerException + +if TYPE_CHECKING: + from src.core.client import Twitch + from src.models.game import Game + from src.config import JsonType, GQLOperation + + +logger = logging.getLogger("TwitchDrops") + + +class ChannelService: + """ + Service responsible for channel management operations. + + Handles: + - Channel priority calculation + - Live stream discovery from game directories + - Bulk online status checks for ACL channels + - Channel sorting by viewers and priority + """ + + def __init__(self, twitch: Twitch) -> None: + """ + Initialize the channel service. + + Args: + twitch: The Twitch client instance + """ + self._twitch = twitch + + def get_priority(self, channel: Channel) -> int: + """ + Return a priority number for a given channel based on games_to_watch order. + + Priority is determined by the position of the channel's game in the + wanted_games list. Lower numbers indicate higher priority. + + Args: + channel: The channel to evaluate + + Returns: + Priority number where: + - 0 has the highest priority (first in games_to_watch list) + - Higher numbers indicate lower priority + - MAX_INT signifies the lowest possible priority (unwanted game or offline) + """ + if ( + (game := channel.game) is None # None when OFFLINE or no game set + or game not in self._twitch.wanted_games # we don't care about the played game + ): + return MAX_INT + return self._twitch.wanted_games.index(game) + + @staticmethod + def get_viewers_key(channel: Channel) -> int: + """ + Sort key for channels by viewer count (descending). + + Args: + channel: The channel to evaluate + + Returns: + Viewer count, or -1 if not available (offline channels) + """ + if (viewers := channel.viewers) is not None: + return viewers + return -1 + + async def get_live_streams( + self, game: Game, *, limit: int = 20, drops_enabled: bool = True + ) -> list[Channel]: + """ + Fetch live streams for a specific game from Twitch directory. + + Args: + game: The game to fetch streams for + limit: Maximum number of streams to return (default: 20) + drops_enabled: Only return channels with drops enabled (default: True) + + Returns: + List of Channel objects representing live streams + + Raises: + MinerException: If the GQL request fails + """ + filters: list[str] = [] + if drops_enabled: + filters.append("DROPS_ENABLED") + + try: + response = await self._twitch.gql_request( + GQL_OPERATIONS["GameDirectory"].with_variables({ + "limit": limit, + "slug": game.slug, + "options": { + "includeRestricted": ["SUB_ONLY_LIVE"], + "systemFilters": filters, + }, + }) + ) + except GQLException as exc: + raise MinerException(f"Game: {game.slug}") from exc + + if "game" in response["data"]: + return [ + Channel.from_directory( + self._twitch, stream_channel_data["node"], drops_enabled=drops_enabled + ) + for stream_channel_data in response["data"]["game"]["streams"]["edges"] + if stream_channel_data["node"]["broadcaster"] is not None + ] + return [] + + async def bulk_check_online(self, channels: abc.Iterable[Channel]) -> None: + """ + Utilize batch GQL requests to check ONLINE status for multiple channels at once. + + This method efficiently checks the online status and drops_enabled flag + for a large number of channels by batching GraphQL requests. + + Args: + channels: Iterable of Channel objects to check + """ + acl_streams_map: dict[int, JsonType] = {} + stream_gql_ops: list[GQLOperation] = [channel.stream_gql for channel in channels] + + if not stream_gql_ops: + # shortcut for nothing to process + # NOTE: Have to do this here, because "channels" can be any iterable + return + + stream_gql_tasks: list[asyncio.Task[list[JsonType]]] = [ + asyncio.create_task(self._twitch.gql_request(stream_gql_chunk)) + for stream_gql_chunk in chunk(stream_gql_ops, 20) + ] + + try: + for coro in asyncio.as_completed(stream_gql_tasks): + response_list: list[JsonType] = await coro + for response_json in response_list: + channel_data: JsonType = response_json["data"]["user"] + if channel_data is not None: + acl_streams_map[int(channel_data["id"])] = channel_data + except Exception: + # asyncio.as_completed doesn't cancel tasks on errors + for task in stream_gql_tasks: + task.cancel() + raise + + # Update all channels with their stream data + for channel in channels: + channel_id = channel.id + if channel_id not in acl_streams_map: + continue + channel_data = acl_streams_map[channel_id] + if channel_data["stream"] is None: + continue + # Update channel with stream data (no available drops check) + channel.external_update(channel_data, []) diff --git a/src/services/inventory_service.py b/src/services/inventory_service.py new file mode 100644 index 0000000..9d32fae --- /dev/null +++ b/src/services/inventory_service.py @@ -0,0 +1,271 @@ +""" +Inventory service for managing campaigns, drops, and inventory fetching. + +This service handles fetching campaign data from Twitch's GraphQL API, +managing the inventory state, and determining active campaigns. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any +from dateutil.parser import isoparse + +from src.utils import chunk +from src.i18n import _ +from src.config import DUMP_PATH, GQL_OPERATIONS +from src.models import DropsCampaign +from src.exceptions import ExitRequest +from src.api import GQLClient + +if TYPE_CHECKING: + from src.core.client import Twitch + from src.models.channel import Channel + from src.config import JsonType + + +logger = logging.getLogger("TwitchDrops") + + +class InventoryService: + """ + Service responsible for inventory and campaign management. + + Handles: + - Fetching campaign details from GraphQL + - Fetching inventory (in-progress campaigns) + - Determining active campaign for a channel + - Managing campaign data and claimed benefits + """ + + def __init__(self, twitch: Twitch) -> None: + """ + Initialize the inventory service. + + Args: + twitch: The Twitch client instance + """ + self._twitch = twitch + + async def fetch_campaigns( + self, campaigns_chunk: list[tuple[str, JsonType]] + ) -> dict[str, JsonType]: + """ + Fetch detailed campaign data for a chunk of campaign IDs. + + Args: + campaigns_chunk: List of (campaign_id, campaign_data) tuples + + Returns: + Dictionary mapping campaign IDs to their detailed data + """ + campaign_ids: dict[str, JsonType] = dict(campaigns_chunk) + auth_state = await self._twitch.get_auth() + + response_list_raw = await self._twitch.gql_request( + [ + GQL_OPERATIONS["CampaignDetails"].with_variables( + {"channelLogin": str(auth_state.user_id), "dropID": cid} + ) + for cid in campaign_ids + ] + ) + + # Ensure we have a list + response_list: list[JsonType] = ( + response_list_raw if isinstance(response_list_raw, list) else [response_list_raw] + ) + + fetched_data: dict[str, JsonType] = { + (campaign_data := response_json["data"]["user"]["dropCampaign"])["id"]: campaign_data + for response_json in response_list + } + + return GQLClient.merge_data(campaign_ids, fetched_data) + + async def fetch_inventory(self) -> None: + """ + Fetch the complete inventory including campaigns and drops. + + This method: + 1. Fetches in-progress campaigns (inventory) + 2. Fetches available campaigns + 3. Fetches detailed data for each campaign + 4. Creates DropsCampaign objects + 5. Updates GUI with campaign information + 6. Sets up maintenance triggers for campaign timing changes + """ + status_update = self._twitch.gui.status.update + status_update(_("gui", "status", "fetching_inventory")) + + # fetch in-progress campaigns (inventory) + response = await self._twitch.gql_request(GQL_OPERATIONS["Inventory"]) + inventory: JsonType = response["data"]["currentUser"]["inventory"] + ongoing_campaigns: list[JsonType] = inventory["dropCampaignsInProgress"] or [] + + # this contains claimed benefit edge IDs, not drop IDs + claimed_benefits: dict[str, datetime] = { + b["id"]: isoparse(b["lastAwardedAt"]) + for b in inventory["gameEventDrops"] + } + + inventory_data: dict[str, JsonType] = {c["id"]: c for c in ongoing_campaigns} + + # fetch general available campaigns data (campaigns) + response = await self._twitch.gql_request(GQL_OPERATIONS["Campaigns"]) + available_list: list[JsonType] = response["data"]["currentUser"]["dropCampaigns"] or [] + applicable_statuses = ("ACTIVE", "UPCOMING") + available_campaigns: dict[str, JsonType] = { + c["id"]: c + for c in available_list + if c["status"] in applicable_statuses # that are currently not expired + } + + # fetch detailed data for each campaign, in chunks + status_update(_("gui", "status", "fetching_campaigns")) + fetch_campaigns_tasks: list[asyncio.Task[Any]] = [ + asyncio.create_task(self.fetch_campaigns(campaigns_chunk)) + for campaigns_chunk in chunk(available_campaigns.items(), 20) + ] + + try: + for coro in asyncio.as_completed(fetch_campaigns_tasks): + chunk_campaigns_data = await coro + # merge the inventory and campaigns datas together + inventory_data = GQLClient.merge_data(inventory_data, chunk_campaigns_data) + except Exception: + # asyncio.as_completed doesn't cancel tasks on errors + for task in fetch_campaigns_tasks: + task.cancel() + raise + + # filter out invalid campaigns + for campaign_id in list(inventory_data.keys()): + if inventory_data[campaign_id]["game"] is None: + del inventory_data[campaign_id] + + if self._twitch.settings.dump: + # dump the campaigns data to the dump file + with open(DUMP_PATH, 'a', encoding="utf8") as file: + # we need to pre-process the inventory dump a little + dump_data: JsonType = deepcopy(inventory_data) + for campaign_data in dump_data.values(): + # replace ACL lists with a simple text description + if ( + campaign_data["allow"] + and campaign_data["allow"].get("isEnabled", True) + and campaign_data["allow"]["channels"] + ): + # simply count the channels included in the ACL + campaign_data["allow"]["channels"] = ( + f"{len(campaign_data['allow']['channels'])} channels" + ) + # replace drop instance IDs, so they don't include user IDs + for drop_data in campaign_data["timeBasedDrops"]: + if "self" in drop_data and drop_data["self"]["dropInstanceID"]: + drop_data["self"]["dropInstanceID"] = "..." + json.dump(dump_data, file, indent=4, sort_keys=True) + file.write("\n\n") # add 2x new line spacer + json.dump(claimed_benefits, file, indent=4, sort_keys=True, default=str) + + # use the merged data to create campaign objects + campaigns: list[DropsCampaign] = [ + DropsCampaign(self._twitch, campaign_data, claimed_benefits) + for campaign_data in inventory_data.values() + ] + campaigns.sort(key=lambda c: c.active, reverse=True) + campaigns.sort(key=lambda c: c.upcoming and c.starts_at or c.ends_at) + campaigns.sort(key=lambda c: c.eligible, reverse=True) + + self._twitch._drops.clear() + self._twitch.gui.inv.clear() + self._twitch.inventory.clear() + self._twitch._mnt_triggers.clear() + switch_triggers: set[datetime] = set() + next_hour = datetime.now(timezone.utc) + timedelta(hours=1) + + # add the campaigns to the internal inventory + for campaign in campaigns: + self._twitch._drops.update({drop.id: drop for drop in campaign.drops}) + if campaign.can_earn_within(next_hour): + switch_triggers.update(campaign.time_triggers) + self._twitch.inventory.append(campaign) + self._twitch._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 + status_update( + _("gui", "status", "adding_campaigns").format(counter=f"(0/{len(campaigns)})") + ) + add_campaign_tasks: list[asyncio.Task[None]] = [ + asyncio.create_task(self._twitch.gui.inv.add_campaign(campaign)) + for campaign in campaigns + ] + + try: + for i, coro in enumerate(asyncio.as_completed(add_campaign_tasks), start=1): + await coro + status_update( + _("gui", "status", "adding_campaigns").format( + counter=f"({i}/{len(campaigns)})" + ) + ) + # this is needed here explicitly, because cache reads from disk don't raise this + if self._twitch.gui.close_requested: + raise ExitRequest() + except Exception: + # asyncio.as_completed doesn't cancel tasks on errors + for task in add_campaign_tasks: + task.cancel() + raise + + self._twitch._mnt_triggers.extend(sorted(switch_triggers)) + + # trim out all triggers that we're already past + now = datetime.now(timezone.utc) + while self._twitch._mnt_triggers and self._twitch._mnt_triggers[0] <= now: + self._twitch._mnt_triggers.popleft() + + # NOTE: maintenance task is restarted at the end of each inventory fetch + if self._twitch._mnt_task is not None and not self._twitch._mnt_task.done(): + self._twitch._mnt_task.cancel() + self._twitch._mnt_task = asyncio.create_task( + self._twitch._maintenance_service.run_maintenance_task() + ) + + def get_active_campaign(self, channel: Channel | None = None) -> DropsCampaign | None: + """ + Determine the active campaign for a given channel (or watching channel). + + Returns the campaign with the least remaining minutes that can be earned + on the specified channel. This is used to determine which drop is actively + being progressed. + + Args: + channel: The channel to check (defaults to watching channel) + + Returns: + The active DropsCampaign, or None if no campaign can be earned + """ + if not self._twitch.wanted_games: + return None + + watching_channel = self._twitch.watching_channel.get_with_default(channel) + if watching_channel is None: + # if we aren't watching anything, we can't earn any drops + return None + + campaigns: list[DropsCampaign] = [] + for campaign in self._twitch.inventory: + if campaign.can_earn(watching_channel): + campaigns.append(campaign) + + if campaigns: + campaigns.sort(key=lambda c: c.remaining_minutes) + return campaigns[0] + + return None diff --git a/src/services/maintenance.py b/src/services/maintenance.py new file mode 100644 index 0000000..d442d4f --- /dev/null +++ b/src/services/maintenance.py @@ -0,0 +1,93 @@ +""" +Maintenance service for periodic inventory reloads and cleanup triggers. + +This service manages scheduled tasks that trigger inventory fetches and channel cleanups +based on campaign timing (starts/ends) and hourly reload cycles. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING + +from src.utils import task_wrapper +from src.config import CALL, State + +if TYPE_CHECKING: + from src.core.client import Twitch + + +logger = logging.getLogger("TwitchDrops") + + +class MaintenanceService: + """ + Service responsible for periodic maintenance tasks. + + Handles: + - Hourly inventory reloads + - Campaign-triggered channel cleanups (when drops start/end) + - Task scheduling based on time triggers + """ + + def __init__(self, twitch: Twitch) -> None: + """ + Initialize the maintenance service. + + Args: + twitch: The Twitch client instance + """ + self._twitch = twitch + + @task_wrapper(critical=True) + async def run_maintenance_task(self) -> None: + """ + Execute the maintenance task loop. + + This task monitors time triggers for channel cleanup and performs + periodic inventory reloads approximately every 60 minutes. The task + exits after each reload cycle and is restarted by fetch_inventory. + + The maintenance logic: + 1. Wait until the next trigger (either a campaign time trigger or next hour) + 2. If the trigger is a campaign timing change, request channel cleanup + 3. After reaching the next hour boundary, request inventory reload + """ + now = datetime.now(timezone.utc) + next_period = now + timedelta(minutes=1) + + while True: + # exit if there's no need to repeat the loop + now = datetime.now(timezone.utc) + if now >= next_period: + break + + next_trigger = next_period + while self._twitch._mnt_triggers and self._twitch._mnt_triggers[0] <= next_trigger: + next_trigger = self._twitch._mnt_triggers.popleft() + + trigger_type: str = "Reload" if next_trigger == next_period else "Cleanup" + logger.log( + CALL, + ( + "Maintenance task waiting until: " + f"{next_trigger.astimezone().strftime('%X')} ({trigger_type})" + ) + ) + + await asyncio.sleep((next_trigger - now).total_seconds()) + + # exit after waiting, before the actions + now = datetime.now(timezone.utc) + if now >= next_period: + break + + if next_trigger != next_period: + logger.log(CALL, "Maintenance task requests channels cleanup") + self._twitch.change_state(State.CHANNELS_CLEANUP) + + # this triggers a restart of this task every (up to) 60 minutes + logger.log(CALL, "Maintenance task requests a reload") + self._twitch.change_state(State.INVENTORY_FETCH) diff --git a/src/services/message_handlers.py b/src/services/message_handlers.py new file mode 100644 index 0000000..db4437f --- /dev/null +++ b/src/services/message_handlers.py @@ -0,0 +1,273 @@ +""" +Message handler service for processing websocket updates. + +This service handles all websocket message types including drop progress, +drop claims, notifications, stream state changes, and broadcast updates. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from src.utils import task_wrapper +from src.config import CALL, State, GQL_OPERATIONS +from src.i18n import _ + +if TYPE_CHECKING: + from src.core.client import Twitch + from src.models.channel import Channel, Stream + from src.models import TimedDrop + from src.config import JsonType + + +logger = logging.getLogger("TwitchDrops") + + +class MessageHandlerService: + """ + Service responsible for processing websocket messages. + + Handles: + - Drop progress updates (websocket) + - Drop claim notifications (websocket) + - User notifications (websocket) + - Stream state changes (viewcount, stream-up, stream-down) + - Broadcast settings updates (game/title changes) + - Channel update callbacks + """ + + def __init__(self, twitch: Twitch) -> None: + """ + Initialize the message handler service. + + Args: + twitch: The Twitch client instance + """ + self._twitch = twitch + + @task_wrapper + async def process_stream_state(self, channel_id: int, message: JsonType) -> None: + """ + Process websocket stream state updates (viewcount, stream-up, stream-down). + + Args: + channel_id: The channel ID that sent the update + message: The websocket message payload + """ + msg_type: str = message["type"] + channel: Channel | None = self._twitch.channels.get(channel_id) + + if channel is None: + logger.error(f"Stream state change for a non-existing channel: {channel_id}") + return + + if msg_type == "viewcount": + if not channel.online: + # if it's not online for some reason, set it so + channel.check_online() + else: + viewers = message["viewers"] + channel.viewers = viewers + channel.display() + # logger.debug(f"{channel.name} viewers: {viewers}") + elif msg_type == "stream-down": + channel.set_offline() + elif msg_type == "stream-up": + channel.check_online() + elif msg_type == "commercial": + # skip these + pass + else: + logger.warning(f"Unknown stream state: {msg_type}") + + @task_wrapper + async def process_stream_update(self, channel_id: int, message: JsonType) -> None: + """ + Process websocket broadcast settings updates (game/title changes). + + Args: + channel_id: The channel ID that sent the update + message: The websocket message payload containing: + - channel_id: Channel ID string + - type: "broadcast_settings_update" + - channel: Channel login name + - old_status: Previous stream title + - status: New stream title + - old_game: Previous game name + - game: New game name + - old_game_id: Previous game ID + - game_id: New game ID + """ + channel: Channel | None = self._twitch.channels.get(channel_id) + + if channel is None: + logger.error(f"Broadcast settings update for a non-existing channel: {channel_id}") + return + + if message["old_game"] != message["game"]: + game_change = f", game changed: {message['old_game']} -> {message['game']}" + else: + game_change = '' + + logger.log(CALL, f"Channel update from websocket: {channel.name}{game_change}") + + # There's no information about channel tags here, but this event is triggered + # when the tags change. We can use this to just update the stream data after the change. + # Use 'check_online' to introduce a delay, allowing for multiple title and tags + # changes before we update. This eventually calls 'on_channel_update' below. + channel.check_online() + + def on_channel_update( + self, channel: Channel, stream_before: Stream | None, stream_after: Stream | None + ) -> None: + """ + Called by a Channel when its status is updated (ONLINE, OFFLINE, title/tags change). + + This method determines whether a channel switch is needed based on the + status change and channel watching eligibility. + + Args: + channel: The channel that was updated + stream_before: The previous stream state (None if was offline) + stream_after: The new stream state (None if now offline) + + Note: + 'stream_before' gets deallocated once this function finishes. + """ + watching_channel: Channel | None = self._twitch.watching_channel.get_with_default(None) + is_watching_this: bool = watching_channel is not None and watching_channel == channel + + # Channel going from OFFLINE to ONLINE + if stream_before is None and stream_after is not None: + if self._twitch.can_watch(channel) and self._twitch.should_switch(channel): + self._twitch.print(_("status", "goes_online").format(channel=channel.name)) + self._twitch.watch(channel) + else: + logger.info(f"{channel.name} goes ONLINE") + + # Channel going from ONLINE to OFFLINE + elif stream_before is not None and stream_after is None: + if is_watching_this: + self._twitch.print(_("status", "goes_offline").format(channel=channel.name)) + self._twitch.change_state(State.CHANNEL_SWITCH) + else: + logger.info(f"{channel.name} goes OFFLINE") + + # Channel staying ONLINE but with updates + elif stream_before is not None and stream_after is not None: + drops_status: str = ( + f"(🎁: {stream_before.drops_enabled and '✔' or '❌'} -> " + f"{stream_after.drops_enabled and '✔' or '❌'})" + ) + + if is_watching_this and not self._twitch.can_watch(channel): + # Watching this channel but can't watch it anymore + logger.info(f"{channel.name} status updated, switching... {drops_status}") + self._twitch.change_state(State.CHANNEL_SWITCH) + elif not is_watching_this: + # Not watching this channel + logger.info(f"{channel.name} status updated {drops_status}") + if self._twitch.can_watch(channel) and self._twitch.should_switch(channel): + self._twitch.watch(channel) + + # Channel was OFFLINE and stays OFFLINE + else: + logger.log(CALL, f"{channel.name} stays OFFLINE") + + channel.display() + + @task_wrapper + async def process_drops(self, user_id: int, message: JsonType) -> None: + """ + Process websocket drop progress and claim updates. + + Args: + user_id: The user ID that sent the message + message: The websocket message payload, examples: + - {"type": "drop-progress", data: {"current_progress_min": 3, "required_progress_min": 10}} + - {"type": "drop-claim", data: {"drop_instance_id": ...}} + """ + msg_type: str = message["type"] + if msg_type not in ("drop-progress", "drop-claim"): + return + + drop_id: str = message["data"]["drop_id"] + drop: TimedDrop | None = self._twitch._drops.get(drop_id) + watching_channel: Channel | None = self._twitch.watching_channel.get_with_default(None) + + if msg_type == "drop-claim": + if drop is None: + logger.error( + f"Received a drop claim ID for a non-existing drop: {drop_id}\n" + f"Drop claim ID: {message['data']['drop_instance_id']}" + ) + return + + drop.update_claim(message["data"]["drop_instance_id"]) + campaign = drop.campaign + await drop.claim() + drop.display() + + # About 4-20s after claiming the drop, next drop can be started + # by re-sending the watch payload. We can test for it by fetching the current drop + # via GQL, and then comparing drop IDs. + await asyncio.sleep(4) + + if watching_channel is not None: + for attempt in range(8): + context = await self._twitch.gql_request( + GQL_OPERATIONS["CurrentDrop"].with_variables( + {"channelID": str(watching_channel.id)} + ) + ) + drop_data: JsonType | None = ( + context["data"]["currentUser"]["dropCurrentSession"] + ) + if drop_data is None or drop_data["dropID"] != drop.id: + break + await asyncio.sleep(2) + + if campaign.can_earn(watching_channel): + self._twitch.restart_watching() + else: + self._twitch.change_state(State.INVENTORY_FETCH) + return + + assert msg_type == "drop-progress" + if drop is not None: + drop_text = ( + f"{drop.name} ({drop.campaign.game}, " + f"{message['data']['current_progress_min']}/" + f"{message['data']['required_progress_min']})" + ) + else: + drop_text = "" + + logger.log(CALL, f"Drop update from websocket: {drop_text}") + + if drop is not None and drop.can_earn(self._twitch.watching_channel.get_with_default(None)): + # the received payload is for the drop we expected + drop.update_minutes(message["data"]["current_progress_min"]) + + @task_wrapper + async def process_notifications(self, user_id: int, message: JsonType) -> None: + """ + Process websocket notification updates. + + Handles notification for drop rewards that are ready to claim. + + Args: + user_id: The user ID that sent the notification + message: The websocket message payload + """ + if message["type"] == "create-notification": + data: JsonType = message["data"]["notification"] + if data["type"] == "user_drop_reward_reminder_notification": + self._twitch.change_state(State.INVENTORY_FETCH) + await self._twitch.gql_request( + GQL_OPERATIONS["NotificationsDelete"].with_variables( + {"input": {"id": data["id"]}} + ) + ) diff --git a/src/services/watch_service.py b/src/services/watch_service.py new file mode 100644 index 0000000..663ac1b --- /dev/null +++ b/src/services/watch_service.py @@ -0,0 +1,256 @@ +""" +Watch service for managing channel watching and drop progress monitoring. + +This service handles the core watching loop that sends watch payloads to Twitch, +monitors drop progress, and determines when to switch channels. +""" + +from __future__ import annotations + +import asyncio +import logging +from time import time +from typing import TYPE_CHECKING, NoReturn +from contextlib import suppress + +from src.utils import task_wrapper, AwaitableValue +from src.i18n import _ +from src.config import CALL, WATCH_INTERVAL, GQL_OPERATIONS +from src.exceptions import GQLException + +if TYPE_CHECKING: + from src.core.client import Twitch + from src.models.channel import Channel + from src.models import TimedDrop + from src.config import JsonType + + +logger = logging.getLogger("TwitchDrops") + + +class WatchService: + """ + Service responsible for watching channels and monitoring drop progress. + + Handles: + - Starting/stopping channel watching + - Watch loop that sends periodic watch payloads + - Drop progress monitoring via GQL and websocket + - Channel switch eligibility checks + - Watch loop sleep with restart capability + """ + + def __init__(self, twitch: Twitch) -> None: + """ + Initialize the watch service. + + Args: + twitch: The Twitch client instance + """ + self._twitch = twitch + + def can_watch(self, channel: Channel) -> bool: + """ + Determines if the given channel qualifies as a watching candidate. + + A channel can be watched if: + - There are wanted games configured + - The channel is online + - Drops are enabled on the channel + - The channel is streaming a wanted game + - At least one campaign can be progressed on this channel + + Args: + channel: The channel to evaluate + + Returns: + True if the channel can be watched, False otherwise + """ + if not self._twitch.wanted_games: + return False + + # exit early if stream is offline or drops aren't enabled + if not channel.online or not channel.drops_enabled: + return False + + # check if we can progress any campaign for the played game + if channel.game is None or channel.game not in self._twitch.wanted_games: + return False + + for campaign in self._twitch.inventory: + if campaign.can_earn(channel): + return True + + return False + + def should_switch(self, channel: Channel) -> bool: + """ + Determines if the given channel qualifies as a switch candidate. + + A channel should be switched to if: + - We're not currently watching anything + - The channel's game has higher priority than the watching channel's game + - The channel has the same game priority but is ACL-based and watching isn't + + Args: + channel: The channel to evaluate as a switch candidate + + Returns: + True if we should switch to this channel, False otherwise + """ + watching_channel = self._twitch.watching_channel.get_with_default(None) + if watching_channel is None: + return True + + channel_order = self._twitch._channel_service.get_priority(channel) + watching_order = self._twitch._channel_service.get_priority(watching_channel) + + return ( + # this channel's game is higher order than the watching one's + channel_order < watching_order + or channel_order == watching_order # or the order is the same + # and this channel is ACL-based and the watching channel isn't + and channel.acl_based > watching_channel.acl_based + ) + + def watch(self, channel: Channel, *, update_status: bool = True) -> None: + """ + Start watching a specific channel. + + Updates GUI elements and sets the watching channel. Optionally prints + a status message and updates the status bar. + + Args: + channel: The channel to start watching + update_status: Whether to print status message and update status bar + """ + self._twitch.gui.tray.change_icon("active") + self._twitch.gui.channels.set_watching(channel) + self._twitch.watching_channel.set(channel) + + if update_status: + status_text: str = _("status", "watching").format(channel=channel.name) + self._twitch.print(status_text) + self._twitch.gui.status.update(status_text) + + def stop_watching(self) -> None: + """ + Stop watching the current channel. + + Clears the watching channel and updates GUI elements. + """ + self._twitch.gui.clear_drop() + self._twitch.watching_channel.clear() + self._twitch.gui.channels.clear_watching() + + def restart_watching(self) -> None: + """ + Restart the watch loop (forces immediate re-send of watch payload). + + Stops the progress timer and signals the watch loop to restart. + """ + self._twitch.gui.progress.stop_timer() + self._twitch._watching_restart.set() + + async def watch_sleep(self, delay: float) -> None: + """ + Sleep for a delay that can be interrupted by restart_watching(). + + Uses wait_for with a timeout to allow an asyncio.sleep-like behavior + that can be ended prematurely via the watching restart event. + + Args: + delay: Time in seconds to sleep + """ + self._twitch._watching_restart.clear() + with suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._twitch._watching_restart.wait(), timeout=delay) + + @task_wrapper(critical=True) + async def watch_loop(self) -> NoReturn: + """ + Main watch loop that sends watch payloads and monitors drop progress. + + This loop: + 1. Waits for a channel to watch + 2. Sends watch payload to the channel + 3. Waits ~20 seconds for websocket progress update + 4. If no update received, queries drop progress via GQL or estimates it + 5. Sleeps until next watch interval (~20 seconds) + 6. Repeats + + The loop handles cases where Twitch temporarily stops reporting progress + by falling back to GQL queries or minute bumping. + """ + interval: float = WATCH_INTERVAL.total_seconds() + + while True: + channel: Channel = await self._twitch.watching_channel.get() + + if not channel.online: + # if the channel isn't online anymore, we stop watching it + self.stop_watching() + continue + + # logger.log(CALL, f"Sending watch payload to: {channel.name}") + succeeded: bool = await channel.send_watch() + last_sent: float = time() + + if not succeeded: + logger.log(CALL, f"Watch requested failed for channel: {channel.name}") + + # wait ~20 seconds for a progress update + await asyncio.sleep(20) + + if self._twitch.gui.progress.minute_almost_done(): + # If the previous update was more than ~60s ago, and the progress tracker + # isn't counting down anymore, that means Twitch has temporarily + # stopped reporting drop's progress. To ensure the timer keeps at least somewhat + # accurate time, we can use GQL to query for the current drop, + # or even "pretend" mining as a last resort option. + handled: bool = False + + # Solution 1: use GQL to query for the currently mined drop status + try: + context = await self._twitch.gql_request( + GQL_OPERATIONS["CurrentDrop"].with_variables( + {"channelID": str(channel.id)} + ) + ) + drop_data: JsonType | None = ( + context["data"]["currentUser"]["dropCurrentSession"] + ) + except GQLException: + drop_data = None + + if drop_data is not None: + gql_drop: TimedDrop | None = self._twitch._drops.get(drop_data["dropID"]) + if gql_drop is not None and gql_drop.can_earn(channel): + gql_drop.update_minutes(drop_data["currentMinutesWatched"]) + drop_text: str = ( + f"{gql_drop.name} ({gql_drop.campaign.game}, " + f"{gql_drop.current_minutes}/{gql_drop.required_minutes})" + ) + logger.log(CALL, f"Drop progress from GQL: {drop_text}") + handled = True + + # Solution 2: If GQL fails, figure out which campaign we're most likely mining + # right now, and then bump up the minutes on it's drops + if not handled: + active_campaign = self._twitch._inventory_service.get_active_campaign(channel) + if active_campaign is not None: + active_campaign.bump_minutes(channel) + # NOTE: This usually gets overwritten below + drop_text = f"Unknown drop ({active_campaign.game})" + if (active_drop := active_campaign.first_drop) is not None: + active_drop.display() + drop_text = ( + f"{active_drop.name} ({active_drop.campaign.game}, " + f"{active_drop.current_minutes}/{active_drop.required_minutes})" + ) + logger.log(CALL, f"Drop progress from active search: {drop_text}") + handled = True + else: + logger.log(CALL, "No active drop could be determined") + + await self.watch_sleep(interval - min(time() - last_sent, interval)) diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..905aca8 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,64 @@ +"""Utility modules for TwitchDropsMiner.""" + +from __future__ import annotations + +# String utilities +from .string_utils import ( + CHARS_ASCII, + CHARS_HEX_LOWER, + CHARS_HEX_UPPER, + create_nonce, + chunk, + deduplicate, +) + +# JSON utilities +from .json_utils import ( + json_minify, + json_load, + json_save, + merge_json, + SERIALIZE_ENV, +) + +# Async helpers +from .async_helpers import ( + first_to_complete, + format_traceback, + task_wrapper, + invalidate_cache, + AwaitableValue, +) + +# Rate limiting +from .rate_limiter import RateLimiter + +# Backoff +from .backoff import ExponentialBackoff + + +__all__ = [ + # String utilities + "CHARS_ASCII", + "CHARS_HEX_LOWER", + "CHARS_HEX_UPPER", + "create_nonce", + "chunk", + "deduplicate", + # JSON utilities + "json_minify", + "json_load", + "json_save", + "merge_json", + "SERIALIZE_ENV", + # Async helpers + "first_to_complete", + "format_traceback", + "task_wrapper", + "invalidate_cache", + "AwaitableValue", + # Rate limiting + "RateLimiter", + # Backoff + "ExponentialBackoff", +] diff --git a/src/utils/async_helpers.py b/src/utils/async_helpers.py new file mode 100644 index 0000000..2a66c63 --- /dev/null +++ b/src/utils/async_helpers.py @@ -0,0 +1,137 @@ +"""Async programming utilities and helpers.""" + +from __future__ import annotations + +import io +import sys +import asyncio +import logging +import traceback +from pathlib import Path +from functools import wraps +from contextlib import suppress +from typing import Any, Literal, Generic, TypeVar, ParamSpec +from collections import abc + +from src.exceptions import ExitRequest, ReloadRequest + + +_T = TypeVar("_T") # type +_D = TypeVar("_D") # default +_P = ParamSpec("_P") # params + +logger = logging.getLogger("TwitchDrops") + + +async def first_to_complete(coros: abc.Iterable[abc.Coroutine[Any, Any, _T]]) -> _T: + """Wait for the first coroutine to complete, canceling the rest.""" + # In Python 3.11, we need to explicitly wrap awaitables + tasks: list[asyncio.Task[_T]] = [asyncio.ensure_future(coro) for coro in coros] + done: set[asyncio.Task[Any]] + pending: set[asyncio.Task[Any]] + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + return await next(iter(done)) + + +def format_traceback(exc: BaseException, **kwargs: Any) -> str: + """ + Like `traceback.print_exc` but returns a string. Uses the passed-in exception. + Any additional `**kwargs` are passed to the underlaying `traceback.format_exception`. + """ + return ''.join(traceback.format_exception(type(exc), exc, exc.__traceback__, **kwargs)) + + +def task_wrapper( + afunc: abc.Callable[_P, abc.Coroutine[Any, Any, _T]] | None = None, *, critical: bool = False +): + """ + Decorator for async tasks that handles exceptions gracefully. + + Args: + afunc: The async function to wrap + critical: If True, a critical task failure will trigger application termination + + Handles ExitRequest and ReloadRequest silently, logs other exceptions. + Critical tasks will attempt to find and close the Twitch instance on failure. + """ + def decorator( + afunc: abc.Callable[_P, abc.Coroutine[Any, Any, _T]] + ) -> abc.Callable[_P, abc.Coroutine[Any, Any, _T]]: + @wraps(afunc) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs): + try: + await afunc(*args, **kwargs) + except (ExitRequest, ReloadRequest): + pass + except Exception: + logger.exception(f"Exception in {afunc.__name__} task") + if critical: + # critical task's death should trigger a termination. + # there isn't an easy and sure way to obtain the Twitch instance here, + # but we can improvise finding it + from src.core.client import Twitch # cyclic import + probe = args and args[0] or None # extract from 'self' arg + if isinstance(probe, Twitch): + probe.close() + elif probe is not None: + probe = getattr(probe, "_twitch", None) # extract from '_twitch' attr + if isinstance(probe, Twitch): + probe.close() + raise # raise up to the wrapping task + return wrapper + if afunc is None: + return decorator + return decorator(afunc) + + +def invalidate_cache(instance: object, *attrnames: str) -> None: + """ + Invalidate cached_property attributes on an instance. + Used to clear functools.cached_property values. + """ + for name in attrnames: + with suppress(AttributeError): + delattr(instance, name) + + +class AwaitableValue(Generic[_T]): + """ + A value that can be set once and awaited by multiple consumers. + + Provides async/await interface for waiting on a value to become available. + Useful for coordination between async tasks. + """ + + def __init__(self): + self._value: _T + self._event = asyncio.Event() + + def has_value(self) -> bool: + """Check if the value has been set.""" + return self._event.is_set() + + def wait(self) -> abc.Coroutine[Any, Any, Literal[True]]: + """Return a coroutine that waits for the value to be set.""" + return self._event.wait() + + def get_with_default(self, default: _D) -> _T | _D: + """Get the value if set, otherwise return the default.""" + if self._event.is_set(): + return self._value + return default + + async def get(self) -> _T: + """Wait for and return the value.""" + await self._event.wait() + return self._value + + def set(self, value: _T) -> None: + """Set the value and notify all waiters.""" + self._value = value + self._event.set() + + def clear(self) -> None: + """Clear the value, allowing it to be set again.""" + self._event.clear() diff --git a/src/utils/backoff.py b/src/utils/backoff.py new file mode 100644 index 0000000..aae211c --- /dev/null +++ b/src/utils/backoff.py @@ -0,0 +1,86 @@ +"""Exponential backoff implementation for retry logic.""" + +from __future__ import annotations + +import random +from collections import abc + + +class ExponentialBackoff: + """ + Iterator that generates exponentially increasing delays with variance. + + Useful for implementing retry logic with exponential backoff. + Includes configurable variance to prevent thundering herd problems. + + Usage: + backoff = ExponentialBackoff(base=2, maximum=300) + for delay in backoff: + await asyncio.sleep(delay) + if try_operation(): + backoff.reset() + break + """ + + def __init__( + self, + *, + base: float = 2, + variance: float | tuple[float, float] = 0.1, + shift: float = 0, + maximum: float = 300, + ): + """ + Initialize exponential backoff. + + Args: + base: Exponential base (must be > 1) + variance: Random variance to apply. Can be: + - Single float: applies symmetric variance (1 ± variance) + - Tuple: (min_multiplier, max_multiplier) for asymmetric variance + shift: Constant value added to each delay + maximum: Maximum delay value to return + + Raises: + ValueError: If base <= 1 + """ + if base <= 1: + raise ValueError("Base has to be greater than 1") + self.steps: int = 0 + self.base: float = float(base) + self.shift: float = float(shift) + self.maximum: float = float(maximum) + self.variance_min: float + self.variance_max: float + if isinstance(variance, tuple): + self.variance_min, self.variance_max = variance + else: + self.variance_min = 1 - variance + self.variance_max = 1 + variance + + @property + def exp(self) -> int: + """Current exponent value (steps - 1, minimum 0).""" + return max(0, self.steps - 1) + + def __iter__(self) -> abc.Iterator[float]: + return self + + def __next__(self) -> float: + """Generate the next delay value.""" + value: float = ( + pow(self.base, self.steps) + * random.uniform(self.variance_min, self.variance_max) + + self.shift + ) + if value > self.maximum: + return self.maximum + # NOTE: variance can cause the returned value to be lower than the previous one already, + # so this should be safe to move past the first return, + # to prevent the exponent from getting very big after reaching max and many iterations + self.steps += 1 + return value + + def reset(self) -> None: + """Reset the backoff to initial state.""" + self.steps = 0 diff --git a/cache.py b/src/utils/cache.py similarity index 97% rename from cache.py rename to src/utils/cache.py index 683ded0..a061217 100644 --- a/cache.py +++ b/src/utils/cache.py @@ -7,15 +7,15 @@ import io import json from typing import Dict, TypedDict, NewType, TYPE_CHECKING -from utils import json_load, json_save -from constants import URLType, CACHE_PATH, CACHE_DB +from src.utils import json_load, json_save +from src.config import URLType, CACHE_PATH, CACHE_DB from PIL import Image as Image_module from PIL.ImageTk import PhotoImage if TYPE_CHECKING: - from gui import GUIManager + from src.web.gui_manager import GUIManager from PIL.Image import Image from typing_extensions import TypeAlias diff --git a/src/utils/json_utils.py b/src/utils/json_utils.py new file mode 100644 index 0000000..6f2a2cc --- /dev/null +++ b/src/utils/json_utils.py @@ -0,0 +1,158 @@ +"""JSON serialization and deserialization utilities.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Mapping, TypeVar, cast + +from yarl import URL + +from src.config import JsonType + + +_JSON_T = TypeVar("_JSON_T", bound=Mapping[Any, Any]) +_MISSING = object() + + +# Serialization environment - maps type names to deserialization functions +SERIALIZE_ENV: dict[str, Callable[[Any], object]] = { + "set": set, + "URL": URL, + "datetime": lambda d: datetime.fromtimestamp(d, timezone.utc), +} + + +def json_minify(data: JsonType | list[JsonType]) -> str: + """Return minified JSON string (no whitespace) for payload usage.""" + return json.dumps(data, separators=(',', ':')) + + +def _serialize(obj: Any) -> Any: + """ + Custom JSON encoder for special types. + + Converts datetime, set, Enum, and URL objects to serializable format. + Stores both the type name and the converted data for proper deserialization. + """ + # convert data + d: int | str | float | list[Any] | JsonType + if isinstance(obj, datetime): + if obj.tzinfo is None: + # assume naive objects are UTC + obj = obj.replace(tzinfo=timezone.utc) + d = obj.timestamp() + elif isinstance(obj, set): + d = list(obj) + elif isinstance(obj, Enum): + # NOTE: IntEnum cannot be used, as it will get serialized as a plain integer, + # then loaded back as an integer as well. + d = obj.value + elif isinstance(obj, URL): + d = str(obj) + else: + raise TypeError(obj) + # store with type + return { + "__type": type(obj).__name__, + "data": d, + } + + +def _remove_missing(obj: JsonType) -> JsonType: + """ + Remove _MISSING sentinel values from a dictionary recursively. + + This modifies obj in place, but returns it for convenience. + Used during deserialization to clean up unrecognized types. + """ + for key, value in obj.copy().items(): + if value is _MISSING: + del obj[key] + elif isinstance(value, dict): + _remove_missing(value) + if not value: + # the dict is empty now, so remove it's key entirely + del obj[key] + return obj + + +def _deserialize(obj: JsonType) -> Any: + """ + Custom JSON decoder hook for special types. + + Reconstructs objects from serialized format using SERIALIZE_ENV. + Returns _MISSING sentinel for unrecognized types (to be cleaned up later). + """ + if "__type" in obj: + obj_type = obj["__type"] + if obj_type in SERIALIZE_ENV: + return SERIALIZE_ENV[obj_type](obj["data"]) + else: + return _MISSING + return obj + + +def merge_json(obj: JsonType, template: Mapping[Any, Any]) -> None: + """ + Merge a JSON object with a template, ensuring all expected keys exist. + + NOTE: This modifies object in place. + + - Removes keys not present in template + - Overwrites values with wrong type from template + - Recursively merges nested dictionaries + - Adds missing keys from template + """ + for k, v in list(obj.items()): + if k not in template: + # unknown key: overwrite from template + del obj[k] + elif type(v) is not type(template[k]): + # types don't match: overwrite from template + obj[k] = template[k] + elif isinstance(v, dict): + assert isinstance(template[k], dict) + merge_json(v, template[k]) + # ensure the object is not missing any keys + for k in template.keys(): + if k not in obj: + obj[k] = template[k] + + +def json_load(path: Path, defaults: _JSON_T, *, merge: bool = True) -> _JSON_T: + """ + Load JSON from a file with defaults and optional merging. + + Args: + path: Path to JSON file + defaults: Default values to use if file doesn't exist or merge is enabled + merge: If True, merge loaded data with defaults template + + Returns: + Loaded and optionally merged JSON data + """ + defaults_dict: JsonType = dict(defaults) + if path.exists(): + with open(path, 'r', encoding="utf8") as file: + combined: JsonType = _remove_missing(json.load(file, object_hook=_deserialize)) + if merge: + merge_json(combined, defaults_dict) + else: + combined = defaults_dict + return cast(_JSON_T, combined) + + +def json_save(path: Path, contents: Mapping[Any, Any], *, sort: bool = False) -> None: + """ + Save data to a JSON file with custom serialization. + + Args: + path: Path to save JSON file + contents: Data to serialize + sort: If True, sort keys alphabetically + """ + with open(path, 'w', encoding="utf8") as file: + json.dump(contents, file, default=_serialize, sort_keys=sort, indent=4) diff --git a/src/utils/rate_limiter.py b/src/utils/rate_limiter.py new file mode 100644 index 0000000..8e193fb --- /dev/null +++ b/src/utils/rate_limiter.py @@ -0,0 +1,75 @@ +"""Rate limiting utilities for API request management.""" + +from __future__ import annotations + +import asyncio + + +class RateLimiter: + """ + Async context manager for rate limiting operations. + + Enforces a maximum number of operations within a sliding time window. + Tracks both total operations in the window and concurrent operations. + + Usage: + limiter = RateLimiter(capacity=10, window=60) + async with limiter: + # perform rate-limited operation + pass + """ + + def __init__(self, *, capacity: int, window: int): + """ + Initialize rate limiter. + + Args: + capacity: Maximum number of operations allowed + window: Time window in seconds + """ + self.total: int = 0 + self.concurrent: int = 0 + self.window: int = window + self.capacity: int = capacity + self._reset_task: asyncio.Task[None] | None = None + self._cond: asyncio.Condition = asyncio.Condition() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.concurrent}/{self.total}/{self.capacity})" + + def __del__(self) -> None: + if self._reset_task is not None: + self._reset_task.cancel() + + def _can_proceed(self) -> bool: + """Check if an operation can proceed based on current limits.""" + return max(self.total, self.concurrent) < self.capacity + + async def __aenter__(self): + """Enter context - wait if rate limit is reached.""" + async with self._cond: + await self._cond.wait_for(self._can_proceed) + self.total += 1 + self.concurrent += 1 + if self._reset_task is None: + self._reset_task = asyncio.create_task(self._rtask()) + + async def __aexit__(self, exc_type, exc, tb): + """Exit context - decrement concurrent counter and notify waiters.""" + self.concurrent -= 1 + async with self._cond: + self._cond.notify(self.capacity - self.concurrent) + + async def _reset(self) -> None: + """Reset the total counter after the window expires.""" + if self._reset_task is not None: + self._reset_task = None + async with self._cond: + self.total = 0 + if self.concurrent < self.capacity: + self._cond.notify(self.capacity - self.concurrent) + + async def _rtask(self) -> None: + """Background task that resets counters after the time window.""" + await asyncio.sleep(self.window) + await self._reset() diff --git a/src/utils/string_utils.py b/src/utils/string_utils.py new file mode 100644 index 0000000..adcb686 --- /dev/null +++ b/src/utils/string_utils.py @@ -0,0 +1,34 @@ +"""String manipulation utility functions.""" + +from __future__ import annotations + +import random +import string +from collections import OrderedDict +from collections import abc +from typing import TypeVar + + +# Character sets for nonce generation +CHARS_ASCII = string.ascii_letters + string.digits +CHARS_HEX_LOWER = string.digits + "abcdef" +CHARS_HEX_UPPER = string.digits + "ABCDEF" + +_T = TypeVar("_T") + + +def create_nonce(chars: str, length: int) -> str: + """Generate a random nonce string of specified length from given characters.""" + return ''.join(random.choices(chars, k=length)) + + +def chunk(to_chunk: abc.Iterable[_T], chunk_length: int) -> abc.Generator[list[_T], None, None]: + """Split an iterable into chunks of a specified length.""" + list_to_chunk: list[_T] = list(to_chunk) + for i in range(0, len(list_to_chunk), chunk_length): + yield list_to_chunk[i:i + chunk_length] + + +def deduplicate(iterable: abc.Iterable[_T]) -> list[_T]: + """Remove duplicates from an iterable while preserving order.""" + return list(OrderedDict.fromkeys(iterable).keys()) diff --git a/version.py b/src/version.py similarity index 100% rename from version.py rename to src/version.py diff --git a/src/web/__init__.py b/src/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/web/api/__init__.py b/src/web/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/web/app.py b/src/web/app.py new file mode 100644 index 0000000..15dfb8e --- /dev/null +++ b/src/web/app.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from fastapi import FastAPI, HTTPException +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse, FileResponse +from fastapi.middleware.cors import CORSMiddleware +import socketio +from pydantic import BaseModel + +if TYPE_CHECKING: + from src.web.gui_manager import WebGUIManager + from src.core.client import Twitch + + +logger = logging.getLogger("TwitchDrops") + +# Create FastAPI app +app = FastAPI(title="Twitch Drops Miner Web", version="1.0.0") + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify exact origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Create Socket.IO server +sio = socketio.AsyncServer( + async_mode='asgi', + cors_allowed_origins='*', + logger=False, + engineio_logger=False +) + +# Wrap with ASGI app +socket_app = socketio.ASGIApp(sio, app) + +# Global references (set by main.py) +gui_manager: WebGUIManager | None = None +twitch_client: Twitch | None = None +_server_instance: 'uvicorn.Server | None' = None + + +def set_managers(gui: WebGUIManager, twitch: Twitch): + """Called by main.py to set up references""" + global gui_manager, twitch_client + gui_manager = gui + twitch_client = twitch + gui.set_socketio(sio) + + +# Pydantic models for API +class LoginRequest(BaseModel): + username: str + password: str + token: str = '' + + +class ChannelSelectRequest(BaseModel): + channel_id: int + + +class SettingsUpdate(BaseModel): + games_to_watch: list[str] | None = None + dark_mode: bool | None = None + proxy: str | None = None + tray_notifications: bool | None = None + connection_quality: int | None = None + + +# ==================== REST API Endpoints ==================== + +@app.get("/", response_class=HTMLResponse) +async def serve_index(): + """Serve the main web interface""" + # Web files are in project_root/web/, we're in project_root/src/web/ + web_dir = Path(__file__).parent.parent.parent / "web" + index_file = web_dir / "index.html" + logger.debug(f"Looking for web files: __file__={__file__}, web_dir={web_dir}, index_file={index_file}, exists={index_file.exists()}") + if index_file.exists(): + return FileResponse(index_file) + return HTMLResponse( + content=f"

Twitch Drops Miner

Web interface files not found. Please check installation.

Debug: Looking for {index_file}

", + status_code=500 + ) + + +@app.get("/api/status") +async def get_status(): + """Get current application status""" + if not gui_manager: + 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 + } + + +@app.get("/api/channels") +async def get_channels(): + """Get list of tracked channels""" + if not gui_manager: + raise HTTPException(status_code=503, detail="GUI not initialized") + + return { + "channels": gui_manager.channels.get_channels() + } + + +@app.post("/api/channels/select") +async def select_channel(request: ChannelSelectRequest): + """Select a channel to watch""" + if not gui_manager: + raise HTTPException(status_code=503, detail="GUI not initialized") + + gui_manager.select_channel(request.channel_id) + return {"success": True} + + +@app.get("/api/campaigns") +async def get_campaigns(): + """Get campaign inventory""" + if not gui_manager: + raise HTTPException(status_code=503, detail="GUI not initialized") + + return { + "campaigns": gui_manager.inv.get_campaigns() + } + + +@app.get("/api/console") +async def get_console_history(): + """Get console output history""" + if not gui_manager: + raise HTTPException(status_code=503, detail="GUI not initialized") + + return { + "lines": gui_manager.output.get_history() + } + + +@app.get("/api/settings") +async def get_settings(): + """Get current settings""" + if not gui_manager: + raise HTTPException(status_code=503, detail="GUI not initialized") + + return gui_manager.settings.get_settings() + + +@app.post("/api/settings") +async def update_settings(settings: SettingsUpdate): + """Update application settings""" + if not gui_manager: + raise HTTPException(status_code=503, detail="GUI not initialized") + + settings_dict = settings.dict(exclude_unset=True) + gui_manager.settings.update_settings(settings_dict) + return {"success": True, "settings": gui_manager.settings.get_settings()} + + +@app.post("/api/login") +async def submit_login(login_data: LoginRequest): + """Submit login credentials""" + if not gui_manager: + raise HTTPException(status_code=503, detail="GUI not initialized") + + gui_manager.login.submit_login( + login_data.username, + login_data.password, + login_data.token + ) + return {"success": True} + + +@app.post("/api/oauth/confirm") +async def confirm_oauth(): + """Confirm OAuth code has been entered by user""" + if not gui_manager: + raise HTTPException(status_code=503, detail="GUI not initialized") + + # Just set the event to signal the user has acknowledged the code + gui_manager.login._login_event.set() + return {"success": True} + + +@app.post("/api/reload") +async def trigger_reload(): + """Trigger application reload""" + if not twitch_client: + raise HTTPException(status_code=503, detail="Twitch client not initialized") + + from src.config import State + twitch_client.change_state(State.INVENTORY_FETCH) + return {"success": True} + + +@app.post("/api/close") +async def trigger_close(): + """Trigger application shutdown""" + if not gui_manager: + raise HTTPException(status_code=503, detail="GUI not initialized") + + gui_manager.close() + return {"success": True} + + +# ==================== Socket.IO Events ==================== + +@sio.event +async def connect(sid, environ): + """Client connected""" + logger.info(f"Web client connected: {sid}") + + # Send initial state to new client + if gui_manager: + 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() + }, room=sid) + + +@sio.event +async def disconnect(sid): + """Client disconnected""" + logger.info(f"Web client disconnected: {sid}") + + +@sio.event +async def request_login(sid): + """Client requested login form submission""" + logger.info(f"Login request from client: {sid}") + # The actual login data comes via REST API + + +@sio.event +async def request_reload(sid): + """Client requested application reload""" + if twitch_client: + from src.config import State + twitch_client.change_state(State.INVENTORY_FETCH) + + +# Mount static files (CSS, JS, images) +# Web files are in project_root/web/, we're in project_root/src/web/ +web_dir = Path(__file__).parent.parent.parent / "web" +if web_dir.exists(): + static_dir = web_dir / "static" + if static_dir.exists(): + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + +# Development server runner +async def run_server(host: str = "0.0.0.0", port: int = 8080): + """Run the web server (used for development/testing)""" + global _server_instance + import uvicorn + config = uvicorn.Config( + socket_app, + host=host, + port=port, + log_level="info", + access_log=False + ) + server = uvicorn.Server(config) + _server_instance = server + try: + await server.serve() + finally: + _server_instance = None + + +async def shutdown_server(): + """Gracefully shutdown the web server""" + global _server_instance + if _server_instance: + logger.info("Setting server.should_exit = True") + _server_instance.should_exit = True + # Give the server a moment to process the shutdown signal + # The uvicorn server checks should_exit periodically + await asyncio.sleep(0.1) + + +if __name__ == "__main__": + # For standalone testing + asyncio.run(run_server()) diff --git a/src/web/gui_manager.py b/src/web/gui_manager.py new file mode 100644 index 0000000..1364b8b --- /dev/null +++ b/src/web/gui_manager.py @@ -0,0 +1,233 @@ +"""Main web GUI manager coordinating all UI components.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from src.models.game import Game +from src.i18n import _ +from src.web.managers.broadcaster import WebSocketBroadcaster +from src.web.managers.status import StatusManager, WebsocketStatusManager +from src.web.managers.console import ConsoleOutputManager +from src.web.managers.campaigns import CampaignProgressManager +from src.web.managers.channels import ChannelListManager +from src.web.managers.inventory import InventoryManager +from src.web.managers.login import LoginFormManager +from src.web.managers.tray import TrayIconStub +from src.web.managers.settings import SettingsManager +from src.web.managers.cache import ImageCache + +if TYPE_CHECKING: + from socketio import AsyncServer + from src.core.client import Twitch + from src.models import TimedDrop + + +logger = logging.getLogger("TwitchDrops") + + +class WebGUIManager: + """Web-based GUI manager coordinating all UI components. + + This class serves as the main coordinator for the web-based interface, + managing all component managers and providing the same interface as the + desktop GUIManager for compatibility with the core application logic. + + The WebGUIManager uses Socket.IO for real-time bidirectional communication + with browser clients, enabling live updates of drop progress, channel lists, + and other dynamic content. + """ + + def __init__(self, twitch: Twitch): + self._twitch: Twitch = twitch + self._broadcaster = WebSocketBroadcaster() + self._close_requested = asyncio.Event() + + # Create component managers + self.status = StatusManager(self._broadcaster) + self.websockets = WebsocketStatusManager(self._broadcaster) + self.output = ConsoleOutputManager(self._broadcaster) + self.progress = CampaignProgressManager(self._broadcaster) + self.channels = ChannelListManager(self._broadcaster) + self.inv = InventoryManager(self._broadcaster, ImageCache(self)) + self.login = LoginFormManager(self._broadcaster, self) + self.tray = TrayIconStub(self._broadcaster) + self.settings = SettingsManager(self._broadcaster, twitch.settings) + + # Selected channel tracking (set by web client) + self._selected_channel_id: int | None = None + + # Start message + logger.info("Web GUI Manager initialized") + + def set_socketio(self, sio: AsyncServer): + """Set the Socket.IO instance for real-time communication. + + Called by webapp during initialization to connect the broadcaster + to the Socket.IO server. + + Args: + sio: The Socket.IO AsyncServer instance + """ + self._broadcaster.set_socketio(sio) + + @property + def close_requested(self) -> bool: + """Check if application closure has been requested. + + Returns: + True if close was requested via GUI + """ + return self._close_requested.is_set() + + def start(self): + """Start the GUI (logs ready message in web mode).""" + logger.info("Web GUI started - access via browser") + self.status.update(_("gui", "status", "ready")) + + def close(self, *args) -> int: + """Request application closure. + + Returns: + Exit code (0 for normal shutdown) + """ + self._close_requested.set() + # notify client we're supposed to close + self._twitch.close() + logger.info("Close requested via web GUI") + return 0 + + def prevent_close(self): + """Prevent window from closing (no-op in web mode). + + In web mode, users can still navigate away from the page. + """ + pass + + def stop(self): + """Stop the GUI.""" + logger.info("Web GUI stopped") + + def close_window(self): + """Close the GUI window (no-op in web mode).""" + pass + + async def wait_until_closed(self): + """Wait until the GUI is closed by the user.""" + await self._close_requested.wait() + + async def coro_unless_closed(self, coro): + """Run a coroutine unless the GUI is closed. + + Races the provided coroutine against the close event, canceling + the coroutine if close is requested and raising ExitRequest. + + Args: + coro: Coroutine to run + + Returns: + Result of the coroutine if it completes first + + Raises: + ExitRequest: If close is requested during execution + """ + # Race the coroutine against the close event + tasks = [asyncio.ensure_future(coro), asyncio.ensure_future(self._close_requested.wait())] + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + # Cancel any pending tasks + for task in pending: + task.cancel() + # If close was requested, raise ExitRequest + if self._close_requested.is_set(): + from src.exceptions import ExitRequest + raise ExitRequest() + # Otherwise return the result + return await next(iter(done)) + + 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. + + Args: + message: Message to display in console + """ + self.output.print(message) + + def set_games(self, games: set[Game]): + """Set available games for settings panel. + + Args: + games: Set of Game objects from discovered campaigns + """ + self.settings.set_games(games) + + def display_drop(self, drop: TimedDrop, *, countdown: bool = True, subone: bool = False): + """Display drop mining progress with countdown. + + Args: + drop: The drop currently being mined + countdown: Whether to show countdown timer + subone: Subtract one minute from remaining time + """ + remaining = drop.remaining_minutes * 60 # Convert to seconds + if subone: + remaining -= 60 + self.progress.update(drop, remaining) + + def clear_drop(self): + """Clear the drop progress display.""" + self.progress.stop_timer() + + def grab_attention(self, *, sound: bool = True): + """Get user's attention via notification. + + Args: + sound: Whether to play notification sound + """ + asyncio.create_task( + self._broadcaster.emit("attention_required", {"sound": sound}) + ) + + def select_channel(self, channel_id: int): + """Select a channel (called by webapp when user clicks channel). + + Args: + channel_id: Twitch channel ID to select + """ + self._selected_channel_id = channel_id + + def get_selected_channel_id(self) -> int | None: + """Get the currently selected channel ID and clear the selection. + + Returns: + Channel ID if one was selected, None otherwise + """ + result = self._selected_channel_id + self._selected_channel_id = None # Clear after reading + return result + + def apply_theme(self, dark_mode: bool): + """Apply UI theme (handled client-side in web mode). + + Args: + dark_mode: Whether to use dark theme + """ + asyncio.create_task( + self._broadcaster.emit("theme_change", {"dark_mode": dark_mode}) + ) + + +# Type aliases for backwards compatibility with code that imports from gui +LoginForm = LoginFormManager +ChannelList = ChannelListManager +WebsocketStatus = WebsocketStatusManager +GUIManager = WebGUIManager diff --git a/src/web/managers/__init__.py b/src/web/managers/__init__.py new file mode 100644 index 0000000..0aa89d4 --- /dev/null +++ b/src/web/managers/__init__.py @@ -0,0 +1,41 @@ +"""Web GUI manager modules for the Twitch Drops Miner web interface. + +This package contains all component managers for the web-based GUI: +- WebSocketBroadcaster: Real-time message broadcasting to clients +- StatusManager: Main application status display +- WebsocketStatusManager: WebSocket connection pool status +- ConsoleOutputManager: Console log output buffering and display +- CampaignProgressManager: Active drop mining progress and countdown +- ChannelListManager: Available channels tracking and display +- InventoryManager: Drop campaigns and inventory management +- LoginFormManager: Authentication and OAuth flow handling +- SettingsManager: Application settings configuration +- TrayIconStub: System tray stub (browser notifications in web mode) +- ImageCache: Minimal image caching for campaign artwork +""" + +from src.web.managers.broadcaster import WebSocketBroadcaster +from src.web.managers.status import StatusManager, WebsocketStatusManager +from src.web.managers.console import ConsoleOutputManager +from src.web.managers.campaigns import CampaignProgressManager +from src.web.managers.channels import ChannelListManager +from src.web.managers.inventory import InventoryManager +from src.web.managers.login import LoginFormManager, LoginData +from src.web.managers.settings import SettingsManager +from src.web.managers.tray import TrayIconStub +from src.web.managers.cache import ImageCache + +__all__ = [ + "WebSocketBroadcaster", + "StatusManager", + "WebsocketStatusManager", + "ConsoleOutputManager", + "CampaignProgressManager", + "ChannelListManager", + "InventoryManager", + "LoginFormManager", + "LoginData", + "SettingsManager", + "TrayIconStub", + "ImageCache", +] diff --git a/src/web/managers/broadcaster.py b/src/web/managers/broadcaster.py new file mode 100644 index 0000000..58d477f --- /dev/null +++ b/src/web/managers/broadcaster.py @@ -0,0 +1,33 @@ +"""WebSocket broadcaster for real-time updates to web clients.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from socketio import AsyncServer + + +class WebSocketBroadcaster: + """Manages broadcasting messages to all connected web clients via Socket.IO. + + This class acts as a central hub for sending real-time updates from the application + to all connected browser clients through Socket.IO events. + """ + + def __init__(self): + self._sio: AsyncServer | None = None # Will be set by webapp + + def set_socketio(self, sio: AsyncServer): + """Set the Socket.IO server instance for broadcasting.""" + self._sio = sio + + async def emit(self, event: str, data: Any): + """Emit an event to all connected clients. + + Args: + event: The event name to emit + data: The data payload to send with the event + """ + if self._sio: + await self._sio.emit(event, data) diff --git a/src/web/managers/cache.py b/src/web/managers/cache.py new file mode 100644 index 0000000..f338518 --- /dev/null +++ b/src/web/managers/cache.py @@ -0,0 +1,32 @@ +"""Image cache for web interface (minimal implementation).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.web.gui_manager import WebGUIManager + + +class ImageCache: + """Minimal image cache for web mode. + + In the web interface, campaign images are referenced by their URLs directly + rather than being cached locally. This class maintains API compatibility + with desktop GUI implementations while simply passing through URLs. + """ + + def __init__(self, manager: WebGUIManager): + self._manager = manager + + async def get(self, url: str) -> str: + """Get image URL (returns the URL directly in web mode). + + Args: + url: The image URL to retrieve + + Returns: + The same URL (no caching needed for web display) + """ + # In web mode, we just return the URL + return url diff --git a/src/web/managers/campaigns.py b/src/web/managers/campaigns.py new file mode 100644 index 0000000..21902d6 --- /dev/null +++ b/src/web/managers/campaigns.py @@ -0,0 +1,61 @@ +"""Campaign progress manager for tracking active drop mining progress.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.web.managers.broadcaster import WebSocketBroadcaster + from src.models import TimedDrop + + +class CampaignProgressManager: + """Manages active drop mining progress display and countdown timer. + + Tracks the currently mined drop and broadcasts real-time progress updates + including remaining time and completion percentage to the web interface. + """ + + def __init__(self, broadcaster: WebSocketBroadcaster): + self._broadcaster = broadcaster + self._current_drop: TimedDrop | None = None + self._remaining_seconds: int = 0 + + def update(self, drop: TimedDrop | None, remaining_seconds: int): + """Update the current drop progress and remaining time. + + Args: + drop: The drop currently being mined, or None if no active drop + remaining_seconds: Seconds remaining until the next progress minute + """ + self._current_drop = drop + self._remaining_seconds = remaining_seconds + if drop: + asyncio.create_task( + self._broadcaster.emit("drop_progress", { + "drop_id": drop.id, + "drop_name": drop.name, + "campaign_name": drop.campaign.name, + "game_name": drop.campaign.game.name, + "current_minutes": drop.current_minutes, + "required_minutes": drop.required_minutes, + "progress": drop.progress, + "remaining_seconds": remaining_seconds + }) + ) + + def stop_timer(self): + """Stop the progress timer and clear the current drop.""" + self._current_drop = None + asyncio.create_task( + self._broadcaster.emit("drop_progress_stop", {}) + ) + + def minute_almost_done(self) -> bool: + """Check if the current progress minute is almost complete. + + Returns: + True if remaining seconds is at or below zero + """ + return self._remaining_seconds <= 0 diff --git a/src/web/managers/channels.py b/src/web/managers/channels.py new file mode 100644 index 0000000..fc4a1ae --- /dev/null +++ b/src/web/managers/channels.py @@ -0,0 +1,100 @@ +"""Channel list manager for tracking and displaying available channels.""" + +from __future__ import annotations + +import asyncio +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from src.web.managers.broadcaster import WebSocketBroadcaster + from src.models.channel import Channel + + +class ChannelListManager: + """Manages the list of available channels in the web interface. + + Tracks all discovered channels with their online status, game, viewers, + and drop eligibility. Broadcasts real-time updates when channels change + or when the watched channel switches. + """ + + def __init__(self, broadcaster: WebSocketBroadcaster): + self._broadcaster = broadcaster + self._channels: dict[int, dict[str, Any]] = {} + self._watching_id: int | None = None + self._selected_id: int | None = None + + def display(self, channel: Channel, *, add: bool = False): + """Add or update a channel in the display list. + + Args: + channel: The channel to display + add: If True, emit channel_add event; otherwise emit channel_update + """ + channel_data = { + "id": channel.id, + "name": channel.name, + "game": channel.game.name 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 + } + self._channels[channel.id] = channel_data + asyncio.create_task( + self._broadcaster.emit("channel_update" if not add else "channel_add", channel_data) + ) + + def remove(self, channel: Channel): + """Remove a channel from the display list. + + Args: + channel: The channel to remove + """ + if channel.id in self._channels: + del self._channels[channel.id] + asyncio.create_task( + self._broadcaster.emit("channel_remove", {"id": channel.id}) + ) + + def clear(self): + """Clear all channels from the display list.""" + self._channels.clear() + asyncio.create_task( + self._broadcaster.emit("channels_clear", {}) + ) + + def set_watching(self, channel: Channel): + """Mark a channel as currently being watched. + + Args: + channel: The channel now being watched + """ + self._watching_id = channel.id + asyncio.create_task( + self._broadcaster.emit("channel_watching", {"id": channel.id}) + ) + + def clear_watching(self): + """Clear the currently watched channel indicator.""" + self._watching_id = None + asyncio.create_task( + self._broadcaster.emit("channel_watching_clear", {}) + ) + + def get_selection(self) -> Channel | None: + """Get user's channel selection (handled via webapp API). + + Returns: + None (selection is handled through the web API, not here) + """ + return None # Handled via webapp API + + def get_channels(self) -> list[dict[str, Any]]: + """Get all currently tracked channels. + + Returns: + List of channel data dictionaries + """ + return list(self._channels.values()) diff --git a/src/web/managers/console.py b/src/web/managers/console.py new file mode 100644 index 0000000..b2174d2 --- /dev/null +++ b/src/web/managers/console.py @@ -0,0 +1,44 @@ +"""Console output manager for logging to web interface.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from collections import deque +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.web.managers.broadcaster import WebSocketBroadcaster + + +class ConsoleOutputManager: + """Manages console output display in the web interface. + + Buffers log messages and broadcasts them to connected clients in real-time, + maintaining a rolling history of recent messages. + """ + + def __init__(self, broadcaster: WebSocketBroadcaster, max_lines: int = 1000): + self._broadcaster = broadcaster + self._buffer: deque[str] = deque(maxlen=max_lines) + + def print(self, message: str): + """Print a message to the console output with timestamp. + + Args: + message: The message to display + """ + timestamp = datetime.now().strftime("%H:%M:%S") + line = f"[{timestamp}] {message}" + self._buffer.append(line) + asyncio.create_task( + self._broadcaster.emit("console_output", {"message": line}) + ) + + def get_history(self) -> list[str]: + """Get the current console history buffer. + + Returns: + List of timestamped console messages + """ + return list(self._buffer) diff --git a/src/web/managers/inventory.py b/src/web/managers/inventory.py new file mode 100644 index 0000000..7b36c85 --- /dev/null +++ b/src/web/managers/inventory.py @@ -0,0 +1,108 @@ +"""Inventory manager for tracking drop campaigns and claiming progress.""" + +from __future__ import annotations + +import asyncio +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from src.web.managers.broadcaster import WebSocketBroadcaster + from src.web.managers.cache import ImageCache + from src.models import DropsCampaign, TimedDrop + + +class InventoryManager: + """Manages drop campaign inventory display in the web interface. + + Tracks all active, upcoming, and expired campaigns with their drops, + broadcasting real-time updates as drops are mined and claimed. + """ + + def __init__(self, broadcaster: WebSocketBroadcaster, cache: ImageCache): + self._broadcaster = broadcaster + self._cache = cache + self._campaigns: dict[str, dict[str, Any]] = {} + + def clear(self): + """Clear all campaigns from inventory.""" + self._campaigns.clear() + asyncio.create_task( + self._broadcaster.emit("inventory_clear", {}) + ) + + async def add_campaign(self, campaign: DropsCampaign): + """Add a campaign to the inventory display. + + Args: + campaign: The drop campaign to add + """ + # Get campaign image from cache + image_url = str(campaign.image_url) + + drops_data = [] + for drop in campaign.drops: + drops_data.append({ + "id": drop.id, + "name": drop.name, + "current_minutes": drop.current_minutes, + "required_minutes": drop.required_minutes, + "progress": drop.progress, + "is_claimed": drop.is_claimed, + "can_claim": drop.can_claim, + "rewards": drop.rewards_text(), + "starts_at": drop.starts_at.isoformat(), + "ends_at": drop.ends_at.isoformat() + }) + + campaign_data = { + "id": campaign.id, + "name": campaign.name, + "game_name": campaign.game.name, + "image_url": image_url, + "starts_at": campaign.starts_at.isoformat(), + "ends_at": campaign.ends_at.isoformat(), + "linked": campaign.linked, + "active": campaign.active, + "upcoming": campaign.upcoming, + "expired": campaign.expired, + "claimed_drops": campaign.claimed_drops, + "total_drops": campaign.total_drops, + "drops": drops_data + } + + self._campaigns[campaign.id] = campaign_data + await self._broadcaster.emit("campaign_add", campaign_data) + + def update_drop(self, drop: TimedDrop): + """Update a specific drop's progress within its campaign. + + Args: + drop: The drop to update + """ + campaign_id = drop.campaign.id + if campaign_id in self._campaigns: + # Find and update the drop in the campaign + for drop_data in self._campaigns[campaign_id]["drops"]: + if drop_data["id"] == drop.id: + drop_data.update({ + "current_minutes": drop.current_minutes, + "required_minutes": drop.required_minutes, + "progress": drop.progress, + "is_claimed": drop.is_claimed, + "can_claim": drop.can_claim + }) + asyncio.create_task( + self._broadcaster.emit("drop_update", { + "campaign_id": campaign_id, + "drop": drop_data + }) + ) + break + + def get_campaigns(self) -> list[dict[str, Any]]: + """Get all campaigns in inventory. + + Returns: + List of campaign data dictionaries + """ + return list(self._campaigns.values()) diff --git a/src/web/managers/login.py b/src/web/managers/login.py new file mode 100644 index 0000000..75f1f09 --- /dev/null +++ b/src/web/managers/login.py @@ -0,0 +1,132 @@ +"""Login form manager for handling Twitch authentication.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any, TYPE_CHECKING + +from src.i18n import _ + +if TYPE_CHECKING: + from src.web.managers.broadcaster import WebSocketBroadcaster + from src.web.gui_manager import WebGUIManager + + +@dataclass +class LoginData: + """Container for login credentials submitted by the user.""" + username: str + password: str + token: str + + +class LoginFormManager: + """Manages login form and OAuth authentication flow in the web interface. + + Handles both traditional username/password login and OAuth device code flow, + coordinating between the web client and the Twitch authentication system. + """ + + def __init__(self, broadcaster: WebSocketBroadcaster, manager: WebGUIManager): + self._broadcaster = broadcaster + self._manager = manager + self._login_event = asyncio.Event() + self._login_data: LoginData | None = None + self._status = "Logged out" + self._user_id: int | None = None + self._oauth_pending: dict[str, str] | None = None # Store OAuth code for late-connecting clients + + def clear(self, login: bool = False, password: bool = False, token: bool = False): + """Clear login form fields on the client side. + + Args: + login: Clear the login/username field + password: Clear the password field + token: Clear the 2FA token field + """ + asyncio.create_task( + self._broadcaster.emit("login_clear", { + "login": login, + "password": password, + "token": token + }) + ) + + def update(self, status: str, user_id: int | None): + """Update login status display. + + Args: + status: Status message to display (e.g., "Logged in as...", "Login required") + user_id: Twitch user ID if logged in, None otherwise + """ + self._status = status + self._user_id = user_id + asyncio.create_task( + self._broadcaster.emit("login_status", { + "status": status, + "user_id": user_id + }) + ) + + async def ask_login(self) -> LoginData: + """Request login credentials from the user. + + Returns: + LoginData containing submitted credentials + """ + self.update(_("gui", "login", "required"), None) + self._login_event.clear() + await self._broadcaster.emit("login_required", {}) + # Use coro_unless_closed to handle shutdown during login + await self._manager.coro_unless_closed(self._login_event.wait()) + return self._login_data + + async def ask_enter_code(self, page_url, user_code: str): + """Request OAuth device code entry from the user. + + Displays the activation URL and code to the user, waiting for them + to complete the OAuth flow on Twitch's website. + + Args: + page_url: URL where user should enter the code (e.g., twitch.tv/activate) + user_code: The device code to enter + """ + self.update(_("gui", "login", "required"), None) + self._login_event.clear() + # Store OAuth code for late-connecting clients + self._oauth_pending = { + "url": str(page_url), + "code": user_code + } + await self._broadcaster.emit("oauth_code_required", self._oauth_pending) + # Use coro_unless_closed to handle shutdown during login + await self._manager.coro_unless_closed(self._login_event.wait()) + # Clear OAuth state after confirmation + self._oauth_pending = None + + def submit_login(self, username: str, password: str, token: str = ''): + """Submit login credentials (called by webapp when user submits form). + + Args: + username: Twitch username or email + password: Account password + token: Optional 2FA token + """ + self._login_data = LoginData(username, password, token) + self._login_event.set() + + def get_status(self) -> dict[str, Any]: + """Get current login status for client synchronization. + + Returns: + Dictionary with status, user_id, and optional oauth_pending data + """ + result = { + "status": self._status, + "user_id": self._user_id + } + # Include OAuth code if pending + if self._oauth_pending: + result["oauth_pending"] = self._oauth_pending + return result diff --git a/src/web/managers/settings.py b/src/web/managers/settings.py new file mode 100644 index 0000000..3154f73 --- /dev/null +++ b/src/web/managers/settings.py @@ -0,0 +1,75 @@ +"""Settings manager for application configuration.""" + +from __future__ import annotations + +import asyncio +from typing import Any, TYPE_CHECKING + +from src.models.game import Game + +if TYPE_CHECKING: + from src.web.managers.broadcaster import WebSocketBroadcaster + from src.config.settings import Settings + + +class SettingsManager: + """Manages application settings in the web interface. + + Provides access to and modification of user preferences including + game priorities, proxy configuration, and UI preferences. + """ + + def __init__(self, broadcaster: WebSocketBroadcaster, settings: Settings): + self._broadcaster = broadcaster + self._settings = settings + self._available_games: list[str] = [] + + def get_settings(self) -> dict[str, Any]: + """Get current settings for display. + + 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), + "tray_notifications": self._settings.tray_notifications, + "connection_quality": self._settings.connection_quality + } + + def update_settings(self, settings_data: dict[str, Any]): + """Update settings from user input. + + Args: + settings_data: Dictionary of settings to update + """ + if "games_to_watch" in settings_data: + self._settings.games_to_watch = settings_data["games_to_watch"] + if "dark_mode" in settings_data: + self._settings.dark_mode = settings_data["dark_mode"] + 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"] + if "tray_notifications" in settings_data: + self._settings.tray_notifications = settings_data["tray_notifications"] + self._settings.alter() + asyncio.create_task( + self._broadcaster.emit("settings_updated", self.get_settings()) + ) + + def set_games(self, games: set[Game]): + """Update the list of available games for settings panel. + + Args: + games: Set of Game objects discovered from campaigns + """ + # Store and broadcast available games for settings panel + game_names = sorted([g.name for g in games]) + self._available_games = game_names + asyncio.create_task( + self._broadcaster.emit("games_available", {"games": game_names}) + ) diff --git a/src/web/managers/status.py b/src/web/managers/status.py new file mode 100644 index 0000000..2001fb6 --- /dev/null +++ b/src/web/managers/status.py @@ -0,0 +1,74 @@ +"""Status managers for application and WebSocket connection status.""" + +from __future__ import annotations + +import asyncio +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from src.web.managers.broadcaster import WebSocketBroadcaster + + +class StatusManager: + """Manages main application status display in the web interface. + + Tracks and broadcasts the current status message shown to users + (e.g., "Mining drops...", "Fetching inventory...", etc.). + """ + + def __init__(self, broadcaster: WebSocketBroadcaster): + self._broadcaster = broadcaster + self._current_status = "Initializing..." + + def update(self, status: str): + """Update the current status and broadcast to all clients.""" + self._current_status = status + asyncio.create_task( + self._broadcaster.emit("status_update", {"status": status}) + ) + + def get(self) -> str: + """Get the current status message.""" + return self._current_status + + +class WebsocketStatusManager: + """Manages WebSocket connection status tracking and display. + + Tracks the status and topic count of each WebSocket connection in the pool, + providing real-time updates about connection health to the web interface. + """ + + def __init__(self, broadcaster: WebSocketBroadcaster): + self._broadcaster = broadcaster + self._websockets: dict[int, dict[str, Any]] = {} + + def update(self, idx: int, status: str | None = None, topics: int | None = None): + """Update a specific websocket's status and/or topic count. + + Args: + idx: WebSocket index/ID + status: Optional status string (e.g., "Connected", "Reconnecting") + topics: Optional number of topics this WebSocket is subscribed to + """ + if status is None and topics is None: + return # Nothing to update + + if idx not in self._websockets: + self._websockets[idx] = {"status": "Unknown", "topics": 0} + + if status is not None: + self._websockets[idx]["status"] = status + if topics is not None: + self._websockets[idx]["topics"] = topics + + # Broadcast the update + asyncio.create_task( + self._broadcaster.emit("websocket_status", { + "idx": idx, + "status": self._websockets[idx]["status"], + "topics": self._websockets[idx]["topics"], + "total_websockets": len(self._websockets), + "total_topics": sum(ws["topics"] for ws in self._websockets.values()) + }) + ) diff --git a/src/web/managers/tray.py b/src/web/managers/tray.py new file mode 100644 index 0000000..c6f1366 --- /dev/null +++ b/src/web/managers/tray.py @@ -0,0 +1,55 @@ +"""Tray icon stub for web-based GUI (no system tray in browser).""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.web.managers.broadcaster import WebSocketBroadcaster + + +class TrayIconStub: + """Stub implementation for system tray icon functionality. + + In web mode, traditional system tray operations are not applicable. + This class provides a compatible interface that translates tray + operations into browser notifications and UI indicators. + """ + + def __init__(self, broadcaster: WebSocketBroadcaster): + self._broadcaster = broadcaster + + def change_icon(self, icon: str): + """Change tray icon (translated to UI indicator in web mode). + + Args: + icon: Icon name/identifier to change to + """ + # Broadcast icon change for potential UI indicators + asyncio.create_task( + self._broadcaster.emit("tray_icon_change", {"icon": icon}) + ) + + def notify(self, message: str, title: str): + """Send a system notification (translated to browser notification). + + Args: + message: Notification message body + title: Notification title + """ + # Send browser notification + asyncio.create_task( + self._broadcaster.emit("notification", { + "title": title, + "message": message + }) + ) + + def minimize(self): + """Minimize to tray (no-op in web mode).""" + pass + + def restore(self): + """Restore from tray (no-op in web mode).""" + pass diff --git a/src/websocket/__init__.py b/src/websocket/__init__.py new file mode 100644 index 0000000..1b230b4 --- /dev/null +++ b/src/websocket/__init__.py @@ -0,0 +1,16 @@ +""" +Websocket layer for Twitch PubSub connections. + +This module provides websocket connection management for subscribing to +Twitch PubSub topics and receiving real-time updates about drops, channels, +and stream states. + +Classes: + Websocket: Manages a single websocket connection with topic subscriptions + WebsocketPool: Manages multiple websocket connections for topic distribution +""" + +from src.websocket.websocket import Websocket +from src.websocket.pool import WebsocketPool + +__all__ = ["Websocket", "WebsocketPool"] diff --git a/src/websocket/pool.py b/src/websocket/pool.py new file mode 100644 index 0000000..b825177 --- /dev/null +++ b/src/websocket/pool.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Literal, TYPE_CHECKING + +from src.exceptions import MinerException +from src.config import MAX_WEBSOCKETS, WS_TOPICS_LIMIT +from src.websocket.websocket import Websocket + +if TYPE_CHECKING: + from collections import abc + + from src.core.client import Twitch + from src.config import WebsocketTopic + + +logger = logging.getLogger("TwitchDrops") + + +class WebsocketPool: + """ + Manages a pool of websocket connections to distribute topics across multiple connections. + + Twitch limits the number of topics per websocket, so this pool automatically + creates additional websockets as needed to handle all subscribed topics. + """ + + def __init__(self, twitch: Twitch): + """ + Initialize the websocket pool. + + Args: + twitch: Twitch client instance + """ + self._twitch: Twitch = twitch + self._running = asyncio.Event() + self.websockets: list[Websocket] = [] + + @property + def running(self) -> bool: + """Check if the pool is currently running.""" + return self._running.is_set() + + def wait_until_connected(self) -> abc.Coroutine[Any, Any, Literal[True]]: + """Wait until the pool is running and connections are established.""" + return self._running.wait() + + async def start(self): + """Start all websockets in the pool.""" + self._running.set() + await asyncio.gather(*(ws.start() for ws in self.websockets)) + + async def stop(self, *, clear_topics: bool = False): + """ + Stop all websockets in the pool. + + Args: + clear_topics: If True, clear all topics and remove websockets from GUI + """ + self._running.clear() + await asyncio.gather(*(ws.stop(remove=clear_topics) for ws in self.websockets)) + + def add_topics(self, topics: abc.Iterable[WebsocketTopic]): + """ + Add topics to the pool, distributing across websockets as needed. + + Creates new websocket connections if existing ones are at capacity. + Raises MinerException if the maximum number of topics/websockets is reached. + + Args: + topics: Iterable of topics to add + + Raises: + MinerException: If maximum topics limit is reached + """ + # ensure no topics end up duplicated + topics_set = set(topics) + if not topics_set: + # nothing to add + return + topics_set.difference_update(*(ws.topics.values() for ws in self.websockets)) + if not topics_set: + # none left to add + return + for ws_idx in range(MAX_WEBSOCKETS): + if ws_idx < len(self.websockets): + # just read it back + ws = self.websockets[ws_idx] + else: + # create new + ws = Websocket(self, ws_idx) + if self.running: + ws.start_nowait() + self.websockets.append(ws) + # ask websocket to take any topics it can - this modifies the set in-place + ws.add_topics(topics_set) + # see if there's any leftover topics for the next websocket connection + if not topics_set: + return + # if we're here, there were leftover topics after filling up all websockets + raise MinerException("Maximum topics limit has been reached") + + def remove_topics(self, topics: abc.Iterable[str]): + """ + Remove topics from the pool. + + Automatically stops and removes websockets that become empty after topic removal. + Recycles topics from removed websockets to maintain efficient connection usage. + + Args: + topics: Iterable of topic strings to remove + """ + topics_set = set(topics) + if not topics_set: + # nothing to remove + return + for ws in self.websockets: + ws.remove_topics(topics_set) + # count up all the topics - if we happen to have more websockets connected than needed, + # stop the last one and recycle topics from it - repeat until we have enough + recycled_topics: list[WebsocketTopic] = [] + while True: + count = sum(len(ws.topics) for ws in self.websockets) + if count <= (len(self.websockets) - 1) * WS_TOPICS_LIMIT: + ws = self.websockets.pop() + recycled_topics.extend(ws.topics.values()) + ws.stop_nowait(remove=True) + else: + break + if recycled_topics: + self.add_topics(recycled_topics) diff --git a/websocket.py b/src/websocket/websocket.py similarity index 76% rename from websocket.py rename to src/websocket/websocket.py index 7ad044f..6ebf03b 100644 --- a/websocket.py +++ b/src/websocket/websocket.py @@ -5,14 +5,14 @@ import asyncio import logging from time import time from contextlib import suppress -from typing import Any, Literal, TYPE_CHECKING +from typing import TYPE_CHECKING import aiohttp -from translate import _ -from exceptions import MinerException, WebsocketClosed -from constants import PING_INTERVAL, PING_TIMEOUT, MAX_WEBSOCKETS, WS_TOPICS_LIMIT -from utils import ( +from src.i18n import _ +from src.exceptions import WebsocketClosed +from src.config import PING_INTERVAL, PING_TIMEOUT, WS_TOPICS_LIMIT +from src.utils import ( CHARS_ASCII, task_wrapper, create_nonce, @@ -23,11 +23,9 @@ from utils import ( ) if TYPE_CHECKING: - from collections import abc - - from twitch import Twitch - from gui import WebsocketStatus - from constants import JsonType, WebsocketTopic + from src.core.client import Twitch + from src.web.gui_manager import WebsocketStatus + from src.config import JsonType, WebsocketTopic WSMsgType = aiohttp.WSMsgType @@ -36,7 +34,23 @@ ws_logger = logging.getLogger("TwitchDrops.websocket") class Websocket: - def __init__(self, pool: WebsocketPool, index: int): + """ + Manages a single websocket connection to Twitch's PubSub service. + + Handles connection lifecycle, topic subscriptions, ping/pong heartbeat, + and message routing to registered topic handlers. + """ + + def __init__(self, pool, index: int): + """ + Initialize a Websocket instance. + + Args: + pool: WebsocketPool instance that owns this websocket + index: Numeric index for logging and identification + """ + from src.websocket.pool import WebsocketPool + self._pool: WebsocketPool = pool self._twitch: Twitch = pool._twitch self._ws_gui: WebsocketStatus = self._twitch.gui.websockets @@ -63,31 +77,49 @@ class Websocket: @property def connected(self) -> bool: + """Check if the websocket is currently connected.""" return self._ws.has_value() def wait_until_connected(self): + """Wait until the websocket is connected.""" return self._ws.wait() def set_status(self, status: str | None = None, refresh_topics: bool = False): + """ + Update the websocket status in the GUI. + + Args: + status: New status message, or None to keep current + refresh_topics: If True, update the topic count in the GUI + """ self._twitch.gui.websockets.update( self._idx, status=status, topics=(len(self.topics) if refresh_topics else None) ) def request_reconnect(self): + """Request a websocket reconnection.""" # reset our ping interval, so we send a PING after reconnect right away self._next_ping = time() self._reconnect_requested.set() async def start(self): + """Start the websocket connection and wait until connected.""" async with self._state_lock: self.start_nowait() await self.wait_until_connected() def start_nowait(self): + """Start the websocket connection without waiting.""" if self._handle_task is None or self._handle_task.done(): self._handle_task = asyncio.create_task(self._handle()) async def stop(self, *, remove: bool = False): + """ + Stop the websocket connection. + + Args: + remove: If True, clear topics and remove from GUI + """ async with self._state_lock: if self._closed.is_set(): return @@ -103,16 +135,31 @@ class Websocket: if remove: self.topics.clear() self._topics_changed.set() - self._twitch.gui.websockets.remove(self._idx) + # TODO: WebsocketStatusManager doesn't have a remove() method yet + # self._twitch.gui.websockets.remove(self._idx) def stop_nowait(self, *, remove: bool = False): - # weird syntax but that's what we get for using a decorator for this - # return type of 'task_wrapper' is a coro, so we need to instance it for the task - asyncio.create_task(task_wrapper(self.stop)(remove=remove)) + """ + Stop the websocket connection without waiting. + + Args: + remove: If True, clear topics and remove from GUI + """ + asyncio.create_task(self.stop(remove=remove)) async def _backoff_connect( self, ws_url: str, **kwargs - ) -> abc.AsyncGenerator[aiohttp.ClientWebSocketResponse, None]: + ): + """ + Connect to websocket with exponential backoff retry logic. + + Args: + ws_url: Websocket URL to connect to + **kwargs: Additional arguments passed to ExponentialBackoff + + Yields: + Connected websocket instances + """ session = await self._twitch.get_session() backoff = ExponentialBackoff(**kwargs) if self._twitch.settings.proxy: @@ -142,6 +189,7 @@ class Websocket: @task_wrapper(critical=True) async def _handle(self): + """Main websocket handler that manages connection lifecycle and message processing.""" # ensure we're logged in before connecting self.set_status(_("gui", "websocket", "initializing")) await self._twitch.wait_until_login() @@ -187,6 +235,7 @@ class Websocket: ws_logger.warning(f"Websocket[{self._idx}] reconnecting...") async def _handle_ping(self): + """Handle ping/pong heartbeat to keep connection alive.""" now = time() if now >= self._next_ping: self._next_ping = now + PING_INTERVAL.total_seconds() @@ -198,6 +247,7 @@ class Websocket: self.request_reconnect() async def _handle_topics(self): + """Handle topic subscription changes (LISTEN/UNLISTEN messages).""" if not self._topics_changed.is_set(): # nothing to do return @@ -239,7 +289,13 @@ class Websocket: async def _gather_recv(self, messages: list[JsonType], timeout: float = 0.5): """ Gather incoming messages over the timeout specified. - Note that there's no return value - this modifies `messages` in-place. + + Args: + messages: List to append received messages to (modified in-place) + timeout: How long to gather messages for in seconds + + Raises: + WebsocketClosed: When the websocket connection closes """ ws = self._ws.get_with_default(None) assert ws is not None @@ -264,6 +320,12 @@ class Websocket: ws_logger.error(f"Websocket[{self._idx}] error: Unknown message: {raw_message}") def _handle_message(self, message): + """ + Route a received MESSAGE to the appropriate topic handler. + + Args: + message: Websocket message dict with topic and message data + """ # request the assigned topic to process the response topic = self.topics.get(message["data"]["topic"]) if topic is not None: @@ -271,9 +333,7 @@ class Websocket: asyncio.create_task(topic(json.loads(message["data"]["message"]))) async def _handle_recv(self): - """ - Handle receiving messages from the websocket. - """ + """Handle receiving and processing messages from the websocket.""" # listen over 0.5s for incoming messages messages: list[JsonType] = [] with suppress(asyncio.TimeoutError): @@ -297,6 +357,12 @@ class Websocket: ws_logger.warning(f"Websocket[{self._idx}] received unknown payload: {message}") def add_topics(self, topics_set: set[WebsocketTopic]): + """ + Add topics to this websocket, up to the limit. + + Args: + topics_set: Set of topics to add (modified in-place, removing added topics) + """ changed: bool = False while topics_set and len(self.topics) < WS_TOPICS_LIMIT: topic = topics_set.pop() @@ -306,6 +372,12 @@ class Websocket: self._topics_changed.set() def remove_topics(self, topics_set: set[str]): + """ + Remove topics from this websocket. + + Args: + topics_set: Set of topic strings to remove (modified in-place) + """ existing = topics_set.intersection(self.topics.keys()) if not existing: # nothing to remove from here @@ -316,80 +388,15 @@ class Websocket: self._topics_changed.set() async def send(self, message: JsonType): + """ + Send a JSON message to the websocket. + + Args: + message: JSON-serializable message dict + """ ws = self._ws.get_with_default(None) assert ws is not None if message["type"] != "PING": message["nonce"] = create_nonce(CHARS_ASCII, 30) await ws.send_json(message, dumps=json_minify) ws_logger.debug(f"Websocket[{self._idx}] sent: {message}") - - -class WebsocketPool: - def __init__(self, twitch: Twitch): - self._twitch: Twitch = twitch - self._running = asyncio.Event() - self.websockets: list[Websocket] = [] - - @property - def running(self) -> bool: - return self._running.is_set() - - def wait_until_connected(self) -> abc.Coroutine[Any, Any, Literal[True]]: - return self._running.wait() - - async def start(self): - self._running.set() - await asyncio.gather(*(ws.start() for ws in self.websockets)) - - async def stop(self, *, clear_topics: bool = False): - self._running.clear() - await asyncio.gather(*(ws.stop(remove=clear_topics) for ws in self.websockets)) - - def add_topics(self, topics: abc.Iterable[WebsocketTopic]): - # ensure no topics end up duplicated - topics_set = set(topics) - if not topics_set: - # nothing to add - return - topics_set.difference_update(*(ws.topics.values() for ws in self.websockets)) - if not topics_set: - # none left to add - return - for ws_idx in range(MAX_WEBSOCKETS): - if ws_idx < len(self.websockets): - # just read it back - ws = self.websockets[ws_idx] - else: - # create new - ws = Websocket(self, ws_idx) - if self.running: - ws.start_nowait() - self.websockets.append(ws) - # ask websocket to take any topics it can - this modifies the set in-place - ws.add_topics(topics_set) - # see if there's any leftover topics for the next websocket connection - if not topics_set: - return - # if we're here, there were leftover topics after filling up all websockets - raise MinerException("Maximum topics limit has been reached") - - def remove_topics(self, topics: abc.Iterable[str]): - topics_set = set(topics) - if not topics_set: - # nothing to remove - return - for ws in self.websockets: - ws.remove_topics(topics_set) - # count up all the topics - if we happen to have more websockets connected than needed, - # stop the last one and recycle topics from it - repeat until we have enough - recycled_topics: list[WebsocketTopic] = [] - while True: - count = sum(len(ws.topics) for ws in self.websockets) - if count <= (len(self.websockets) - 1) * WS_TOPICS_LIMIT: - ws = self.websockets.pop() - recycled_topics.extend(ws.topics.values()) - ws.stop_nowait(remove=True) - else: - break - if recycled_topics: - self.add_topics(recycled_topics) diff --git a/utils.py b/utils.py deleted file mode 100644 index 02c3f2b..0000000 --- a/utils.py +++ /dev/null @@ -1,436 +0,0 @@ -from __future__ import annotations - -import io -import os -import re -import sys -import json -import random -import string -import asyncio -import logging -import traceback -import webbrowser -import tkinter as tk -from enum import Enum -from pathlib import Path -from functools import wraps -from contextlib import suppress -from functools import cached_property -from datetime import datetime, timezone -from collections import abc, OrderedDict -from typing import Any, Literal, Callable, Generic, Mapping, TypeVar, ParamSpec, cast - -from yarl import URL -from PIL.ImageTk import PhotoImage -from PIL import Image as Image_module - -from exceptions import ExitRequest, ReloadRequest -from constants import IS_PACKAGED, JsonType, PriorityMode -from constants import _resource_path as resource_path # noqa - - -_T = TypeVar("_T") # type -_D = TypeVar("_D") # default -_P = ParamSpec("_P") # params -_JSON_T = TypeVar("_JSON_T", bound=Mapping[Any, Any]) -logger = logging.getLogger("TwitchDrops") - - -def set_root_icon(root: tk.Tk, image_path: Path | str) -> None: - with Image_module.open(image_path) as image: - icon_photo = PhotoImage(master=root, image=image) - root.iconphoto(True, icon_photo) # type: ignore[arg-type] - # keep a reference to the PhotoImage to avoid the ResourceWarning - root._icon_image = icon_photo # type: ignore[attr-defined] - - -async def first_to_complete(coros: abc.Iterable[abc.Coroutine[Any, Any, _T]]) -> _T: - # In Python 3.11, we need to explicitly wrap awaitables - tasks = [asyncio.ensure_future(coro) for coro in coros] - done: set[asyncio.Task[Any]] - pending: set[asyncio.Task[Any]] - done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - for task in pending: - task.cancel() - return await next(iter(done)) - - -def chunk(to_chunk: abc.Iterable[_T], chunk_length: int) -> abc.Generator[list[_T], None, None]: - list_to_chunk = list(to_chunk) - for i in range(0, len(list_to_chunk), chunk_length): - yield list_to_chunk[i:i + chunk_length] - - -def format_traceback(exc: BaseException, **kwargs: Any) -> str: - """ - Like `traceback.print_exc` but returns a string. Uses the passed-in exception. - Any additional `**kwargs` are passed to the underlaying `traceback.format_exception`. - """ - return ''.join(traceback.format_exception(type(exc), exc, **kwargs)) - - -def lock_file(path: Path) -> tuple[bool, io.TextIOWrapper]: - file = path.open('w', encoding="utf8") - file.write('ツ') - file.flush() - if sys.platform == "win32": - import msvcrt - try: - # we need to lock at least one byte for this to work - msvcrt.locking(file.fileno(), msvcrt.LK_NBLCK, max(path.stat().st_size, 1)) - except Exception: - return False, file - return True, file - if sys.platform == "linux": - import fcntl - try: - fcntl.lockf(file, fcntl.LOCK_EX | fcntl.LOCK_NB) - except Exception: - return False, file - return True, file - # for unsupported systems, just always return True - return True, file - - -def json_minify(data: JsonType | list[JsonType]) -> str: - """ - Returns minified JSON for payload usage. - """ - return json.dumps(data, separators=(',', ':')) - - -def timestamp(string: str) -> datetime: - try: - return datetime.strptime(string, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) - except ValueError: - return datetime.strptime(string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) - - -CHARS_ASCII = string.ascii_letters + string.digits -CHARS_HEX_LOWER = string.digits + "abcdef" -CHARS_HEX_UPPER = string.digits + "ABCDEF" - - -def create_nonce(chars: str, length: int) -> str: - return ''.join(random.choices(chars, k=length)) - - -def deduplicate(iterable: abc.Iterable[_T]) -> list[_T]: - return list(OrderedDict.fromkeys(iterable).keys()) - - -def task_wrapper( - afunc: abc.Callable[_P, abc.Coroutine[Any, Any, _T]] | None = None, *, critical: bool = False -): - def decorator( - afunc: abc.Callable[_P, abc.Coroutine[Any, Any, _T]] - ) -> abc.Callable[_P, abc.Coroutine[Any, Any, _T]]: - @wraps(afunc) - async def wrapper(*args: _P.args, **kwargs: _P.kwargs): - try: - await afunc(*args, **kwargs) - except (ExitRequest, ReloadRequest): - pass - except Exception: - logger.exception(f"Exception in {afunc.__name__} task") - if critical: - # critical task's death should trigger a termination. - # there isn't an easy and sure way to obtain the Twitch instance here, - # but we can improvise finding it - from twitch import Twitch # cyclic import - probe = args and args[0] or None # extract from 'self' arg - if isinstance(probe, Twitch): - probe.close() - elif probe is not None: - probe = getattr(probe, "_twitch", None) # extract from '_twitch' attr - if isinstance(probe, Twitch): - probe.close() - raise # raise up to the wrapping task - return wrapper - if afunc is None: - return decorator - return decorator(afunc) - - -def invalidate_cache(instance, *attrnames): - """ - To be used to invalidate `functools.cached_property`. - """ - for name in attrnames: - with suppress(AttributeError): - delattr(instance, name) - - -def _serialize(obj: Any) -> Any: - # convert data - d: int | str | float | list[Any] | JsonType - if isinstance(obj, datetime): - if obj.tzinfo is None: - # assume naive objects are UTC - obj = obj.replace(tzinfo=timezone.utc) - d = obj.timestamp() - elif isinstance(obj, set): - d = list(obj) - elif isinstance(obj, Enum): - # NOTE: IntEnum cannot be used, as it will get serialized as a plain integer, - # then loaded back as an integer as well. - d = obj.value - elif isinstance(obj, URL): - d = str(obj) - else: - raise TypeError(obj) - # store with type - return { - "__type": type(obj).__name__, - "data": d, - } - - -_MISSING = object() -SERIALIZE_ENV: dict[str, Callable[[Any], object]] = { - "set": set, - "URL": URL, - "PriorityMode": PriorityMode, - "datetime": lambda d: datetime.fromtimestamp(d, timezone.utc), -} - - -def _remove_missing(obj: JsonType) -> JsonType: - # this modifies obj in place, but we return it just in case - for key, value in obj.copy().items(): - if value is _MISSING: - del obj[key] - elif isinstance(value, dict): - _remove_missing(value) - if not value: - # the dict is empty now, so remove it's key entirely - del obj[key] - return obj - - -def _deserialize(obj: JsonType) -> Any: - if "__type" in obj: - obj_type = obj["__type"] - if obj_type in SERIALIZE_ENV: - return SERIALIZE_ENV[obj_type](obj["data"]) - else: - return _MISSING - return obj - - -def merge_json(obj: JsonType, template: Mapping[Any, Any]) -> None: - # NOTE: This modifies object in place - for k, v in list(obj.items()): - if k not in template: - # unknown key: overwrite from template - del obj[k] - elif type(v) is not type(template[k]): - # types don't match: overwrite from template - obj[k] = template[k] - elif isinstance(v, dict): - assert isinstance(template[k], dict) - merge_json(v, template[k]) - # ensure the object is not missing any keys - for k in template.keys(): - if k not in obj: - obj[k] = template[k] - - -def json_load(path: Path, defaults: _JSON_T, *, merge: bool = True) -> _JSON_T: - defaults_dict: JsonType = dict(defaults) - if path.exists(): - with open(path, 'r', encoding="utf8") as file: - combined: JsonType = _remove_missing(json.load(file, object_hook=_deserialize)) - if merge: - merge_json(combined, defaults_dict) - else: - combined = defaults_dict - return cast(_JSON_T, combined) - - -def json_save(path: Path, contents: Mapping[Any, Any], *, sort: bool = False) -> None: - with open(path, 'w', encoding="utf8") as file: - json.dump(contents, file, default=_serialize, sort_keys=sort, indent=4) - - -def webopen(url: URL | str): - url_str = str(url) - if IS_PACKAGED and sys.platform == "linux": - # https://pyinstaller.org/en/stable/ - # runtime-information.html#ld-library-path-libpath-considerations - # NOTE: All 4 cases need to be handled here: either of the two values can be there or not. - ld_env = "LD_LIBRARY_PATH" - ld_path_curr = os.environ.get(ld_env) - ld_path_orig = os.environ.get(f"{ld_env}_ORIG") - if ld_path_orig is not None: - os.environ[ld_env] = ld_path_orig - elif ld_path_curr is not None: - # pop current - os.environ.pop(ld_env) - - webbrowser.open_new_tab(url_str) - - if ld_path_curr is not None: - os.environ[ld_env] = ld_path_curr - elif ld_path_orig is not None: - # pop original - os.environ.pop(ld_env) - else: - webbrowser.open_new_tab(url_str) - - -class ExponentialBackoff: - def __init__( - self, - *, - base: float = 2, - variance: float | tuple[float, float] = 0.1, - shift: float = 0, - maximum: float = 300, - ): - if base <= 1: - raise ValueError("Base has to be greater than 1") - self.steps: int = 0 - self.base: float = float(base) - self.shift: float = float(shift) - self.maximum: float = float(maximum) - self.variance_min: float - self.variance_max: float - if isinstance(variance, tuple): - self.variance_min, self.variance_max = variance - else: - self.variance_min = 1 - variance - self.variance_max = 1 + variance - - @property - def exp(self) -> int: - return max(0, self.steps - 1) - - def __iter__(self) -> abc.Iterator[float]: - return self - - def __next__(self) -> float: - value: float = ( - pow(self.base, self.steps) - * random.uniform(self.variance_min, self.variance_max) - + self.shift - ) - if value > self.maximum: - return self.maximum - # NOTE: variance can cause the returned value to be lower than the previous one already, - # so this should be safe to move past the first return, - # to prevent the exponent from getting very big after reaching max and many iterations - self.steps += 1 - return value - - def reset(self) -> None: - self.steps = 0 - - -class RateLimiter: - def __init__(self, *, capacity: int, window: int): - self.total: int = 0 - self.concurrent: int = 0 - self.window: int = window - self.capacity: int = capacity - self._reset_task: asyncio.Task[None] | None = None - self._cond: asyncio.Condition = asyncio.Condition() - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.concurrent}/{self.total}/{self.capacity})" - - def __del__(self) -> None: - if self._reset_task is not None: - self._reset_task.cancel() - - def _can_proceed(self) -> bool: - return max(self.total, self.concurrent) < self.capacity - - async def __aenter__(self): - async with self._cond: - await self._cond.wait_for(self._can_proceed) - self.total += 1 - self.concurrent += 1 - if self._reset_task is None: - self._reset_task = asyncio.create_task(self._rtask()) - - async def __aexit__(self, exc_type, exc, tb): - self.concurrent -= 1 - async with self._cond: - self._cond.notify(self.capacity - self.concurrent) - - async def _reset(self) -> None: - if self._reset_task is not None: - self._reset_task = None - async with self._cond: - self.total = 0 - if self.concurrent < self.capacity: - self._cond.notify(self.capacity - self.concurrent) - - async def _rtask(self) -> None: - await asyncio.sleep(self.window) - await self._reset() - - -class AwaitableValue(Generic[_T]): - def __init__(self): - self._value: _T - self._event = asyncio.Event() - - def has_value(self) -> bool: - return self._event.is_set() - - def wait(self) -> abc.Coroutine[Any, Any, Literal[True]]: - return self._event.wait() - - def get_with_default(self, default: _D) -> _T | _D: - if self._event.is_set(): - return self._value - return default - - async def get(self) -> _T: - await self._event.wait() - return self._value - - def set(self, value: _T) -> None: - self._value = value - self._event.set() - - def clear(self) -> None: - self._event.clear() - - -class Game: - def __init__(self, data: JsonType): - self.id: int = int(data["id"]) - self.name: str = data.get("displayName") or data["name"] - if "slug" in data: - self.slug = data["slug"] - - def __str__(self) -> str: - return self.name - - def __repr__(self) -> str: - return f"Game({self.id}, {self.name})" - - def __eq__(self, other: object) -> bool: - if isinstance(other, self.__class__): - return self.id == other.id - return NotImplemented - - def __hash__(self) -> int: - return self.id - - @cached_property - def slug(self) -> str: - """ - Converts the game name into a slug, useable for the GQL API. - """ - # remove specific characters - slug_text = re.sub(r'\'', '', self.name.lower()) - # remove non alpha-numeric characters - slug_text = re.sub(r'\W+', '-', slug_text) - # strip and collapse dashes - slug_text = re.sub(r'-{2,}', '-', slug_text.strip('-')) - return slug_text diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0f1c057 --- /dev/null +++ b/web/index.html @@ -0,0 +1,177 @@ + + + + + + Twitch Drops Miner + + + + +
+
+

🎮 Twitch Drops Miner

+
+ Initializing... + ● Connected +
+
+ + + + +
+
+ + + + +
+

Current Drop

+
+
No active drop
+ +
+
+ + +
+

Console Output

+
+
+ + +
+

Channels

+
+
+
+
+ + +
+
+

No campaigns loaded yet...

+
+
+ + +
+
+
+

General Settings

+ + + +
+ +
+

Games to Watch

+

Select games to watch. Order matters - drag to reorder priority (top = highest priority).

+ +
+ +
+ + +
+
+ +
+
+

Selected Games (drag to reorder)

+
+
+ +
+

Available Games

+
+
+
+
+ +
+

Actions

+ +
+
+
+ + +
+
+

About Twitch Drops Miner

+

This application automatically mines timed Twitch drops without downloading stream data.

+ +

How to Use

+
    +
  1. Login using your Twitch account (OAuth device code flow)
  2. +
  3. Link your accounts at twitch.tv/drops/campaigns
  4. +
  5. The miner will automatically discover campaigns and start mining
  6. +
  7. Configure priority games in Settings to focus on what you want
  8. +
  9. Monitor progress in the Main and Inventory tabs
  10. +
+ +

Features

+
    +
  • Stream-less drop mining - saves bandwidth
  • +
  • Game priority and exclusion lists
  • +
  • Tracks up to 199 channels simultaneously
  • +
  • Automatic channel switching
  • +
  • Real-time progress tracking
  • +
+ +

Important Notes

+
    +
  • Do not watch streams on the same account while mining
  • +
  • Keep your cookies.jar file secure
  • +
  • Requires linked game accounts for drops
  • +
+ + +
+
+
+ + + + diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..adda66e --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,724 @@ +// Twitch Drops Miner Web Client +// Socket.IO and API communication + +// Global state +const state = { + connected: false, + channels: {}, + campaigns: {}, + settings: {}, + currentDrop: null +}; + +// Initialize Socket.IO connection +const socket = io({ + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + reconnectionAttempts: Infinity +}); + +// ==================== Socket.IO Event Handlers ==================== + +socket.on('connect', () => { + console.log('Connected to server'); + state.connected = true; + document.getElementById('connection-indicator').textContent = '● Connected'; + document.getElementById('connection-indicator').className = 'connected'; +}); + +socket.on('disconnect', () => { + console.log('Disconnected from server'); + state.connected = false; + document.getElementById('connection-indicator').textContent = '● Disconnected'; + document.getElementById('connection-indicator').className = 'disconnected'; +}); + +socket.on('initial_state', (data) => { + console.log('Received initial state', data); + if (data.status) updateStatus(data.status); + if (data.channels) data.channels.forEach(ch => updateChannel(ch)); + if (data.campaigns) data.campaigns.forEach(camp => addCampaign(camp)); + if (data.console) data.console.forEach(line => addConsoleLineRaw(line)); + if (data.settings) updateSettingsUI(data.settings); + if (data.login) updateLoginStatus(data.login); +}); + +socket.on('status_update', (data) => { + updateStatus(data.status); +}); + +socket.on('console_output', (data) => { + addConsoleLine(data.message); +}); + +socket.on('channel_add', (data) => { + updateChannel(data); +}); + +socket.on('channel_update', (data) => { + updateChannel(data); +}); + +socket.on('channel_remove', (data) => { + removeChannel(data.id); +}); + +socket.on('channels_clear', () => { + clearChannels(); +}); + +socket.on('channel_watching', (data) => { + setWatchingChannel(data.id); +}); + +socket.on('channel_watching_clear', () => { + clearWatchingChannel(); +}); + +socket.on('drop_progress', (data) => { + updateDropProgress(data); +}); + +socket.on('drop_progress_stop', () => { + clearDropProgress(); +}); + +socket.on('campaign_add', (data) => { + addCampaign(data); +}); + +socket.on('inventory_clear', () => { + clearInventory(); +}); + +socket.on('drop_update', (data) => { + updateDrop(data.campaign_id, data.drop); +}); + +socket.on('login_required', () => { + showLoginForm(); +}); + +socket.on('oauth_code_required', (data) => { + showOAuthCode(data.url, data.code); +}); + +socket.on('login_status', (data) => { + updateLoginStatus(data); +}); + +socket.on('login_clear', (data) => { + if (data.login) document.getElementById('username').value = ''; + if (data.password) document.getElementById('password').value = ''; + if (data.token) document.getElementById('2fa-token').value = ''; +}); + +socket.on('settings_updated', (data) => { + updateSettingsUI(data); +}); + +socket.on('games_available', (data) => { + state.availableGames = data.games; +}); + +socket.on('theme_change', (data) => { + if (data.dark_mode) { + document.body.classList.add('dark-mode'); + } else { + document.body.classList.remove('dark-mode'); + } +}); + +socket.on('notification', (data) => { + if ('Notification' in window && Notification.permission === 'granted') { + new Notification(data.title, { + body: data.message, + icon: '/static/icon.png' + }); + } +}); + +socket.on('attention_required', (data) => { + if (data.sound) { + // Play notification sound + const audio = new Audio('/static/notification.mp3'); + audio.play().catch(() => {}); + } + // Flash title + flashTitle(); +}); + +// ==================== UI Update Functions ==================== + +function updateStatus(status) { + document.getElementById('status-text').textContent = status; +} + +function addConsoleLine(message) { + addConsoleLineRaw(message); +} + +function addConsoleLineRaw(line) { + const console = document.getElementById('console-output'); + const div = document.createElement('div'); + div.textContent = line; + console.appendChild(div); + // Auto-scroll to bottom + console.scrollTop = console.scrollHeight; + // Limit lines + while (console.children.length > 1000) { + console.removeChild(console.firstChild); + } +} + +function updateChannel(channelData) { + state.channels[channelData.id] = channelData; + renderChannels(); +} + +function removeChannel(channelId) { + delete state.channels[channelId]; + renderChannels(); +} + +function clearChannels() { + state.channels = {}; + renderChannels(); +} + +function setWatchingChannel(channelId) { + Object.values(state.channels).forEach(ch => ch.watching = false); + if (state.channels[channelId]) { + state.channels[channelId].watching = true; + } + renderChannels(); +} + +function clearWatchingChannel() { + Object.values(state.channels).forEach(ch => ch.watching = false); + renderChannels(); +} + +function renderChannels() { + const container = document.getElementById('channels-list'); + container.innerHTML = ''; + + const channels = Object.values(state.channels); + if (channels.length === 0) { + container.innerHTML = '

No channels tracked yet...

'; + return; + } + + // Sort: watching first, then online, then by viewers + channels.sort((a, b) => { + if (a.watching !== b.watching) return b.watching ? 1 : -1; + if (a.online !== b.online) return b.online ? 1 : -1; + return (b.viewers || 0) - (a.viewers || 0); + }); + + channels.forEach(channel => { + const div = document.createElement('div'); + div.className = 'channel-item'; + if (channel.watching) div.classList.add('watching'); + if (channel.online) div.classList.add('online'); + else div.classList.add('offline'); + + let badges = ''; + if (channel.drops_enabled) badges += 'DROPS'; + if (channel.acl_based) badges += 'ACL'; + + div.innerHTML = ` +
${channel.name} ${badges}
+
+ ${channel.game || 'No game'} • + ${channel.viewers !== null ? channel.viewers.toLocaleString() + ' viewers' : 'Offline'} + ${channel.watching ? ' • WATCHING' : ''} +
+ `; + + div.onclick = () => selectChannel(channel.id); + container.appendChild(div); + }); +} + +function updateDropProgress(data) { + state.currentDrop = data; + document.getElementById('no-drop-message').style.display = 'none'; + document.getElementById('drop-info').style.display = 'block'; + + document.getElementById('drop-name').textContent = data.drop_name; + document.getElementById('drop-game').textContent = `${data.campaign_name} (${data.game_name})`; + + const progress = data.progress * 100; + const fill = document.getElementById('progress-fill'); + fill.style.width = `${progress}%`; + fill.textContent = `${Math.round(progress)}%`; + + document.getElementById('progress-text').textContent = + `${data.current_minutes} / ${data.required_minutes} minutes`; + + // Update remaining time + updateRemainingTime(data.remaining_seconds); +} + +function updateRemainingTime(seconds) { + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + document.getElementById('progress-time').textContent = + `Time remaining: ${minutes}:${secs.toString().padStart(2, '0')}`; + + if (seconds > 0) { + setTimeout(() => updateRemainingTime(seconds - 1), 1000); + } +} + +function clearDropProgress() { + state.currentDrop = null; + document.getElementById('no-drop-message').style.display = 'block'; + document.getElementById('drop-info').style.display = 'none'; +} + +function addCampaign(campaignData) { + state.campaigns[campaignData.id] = campaignData; + renderInventory(); +} + +function clearInventory() { + state.campaigns = {}; + renderInventory(); +} + +function updateDrop(campaignId, dropData) { + if (state.campaigns[campaignId]) { + const drops = state.campaigns[campaignId].drops; + const index = drops.findIndex(d => d.id === dropData.id); + if (index !== -1) { + drops[index] = dropData; + renderInventory(); + } + } +} + +function renderInventory() { + const container = document.getElementById('inventory-grid'); + container.innerHTML = ''; + + const campaigns = Object.values(state.campaigns); + if (campaigns.length === 0) { + container.innerHTML = '

No campaigns loaded yet...

'; + return; + } + + campaigns.forEach(campaign => { + const card = document.createElement('div'); + card.className = 'campaign-card'; + + let statusClass = ''; + let statusText = ''; + if (campaign.active) { + statusClass = 'active'; + statusText = 'Active'; + } else if (campaign.upcoming) { + statusClass = 'upcoming'; + statusText = 'Upcoming'; + } else if (campaign.expired) { + statusClass = 'expired'; + statusText = 'Expired'; + } + + const dropsHtml = campaign.drops.map(drop => ` +
+
${drop.name}
+
${drop.rewards}
+
${drop.current_minutes} / ${drop.required_minutes} minutes (${Math.round(drop.progress * 100)}%)
+ ${drop.is_claimed ? '
✓ Claimed
' : ''} +
+ `).join(''); + + card.innerHTML = ` +
+
${campaign.game_name}
+
${campaign.name}
+
+
+ ${statusText} + ${campaign.claimed_drops} / ${campaign.total_drops} claimed +
+
+ ${dropsHtml} +
+ `; + + container.appendChild(card); + }); +} + +function showLoginForm() { + document.getElementById('login-form').style.display = 'block'; + document.getElementById('oauth-code-display').style.display = 'none'; +} + +function showOAuthCode(url, code) { + document.getElementById('login-form').style.display = 'none'; + document.getElementById('oauth-code-display').style.display = 'block'; + document.getElementById('oauth-url').href = url; + document.getElementById('oauth-code').textContent = code; +} + +function updateLoginStatus(data) { + const statusEl = document.getElementById('login-status'); + if (data.user_id) { + statusEl.textContent = `Logged in (User ID: ${data.user_id})`; + statusEl.style.color = 'var(--success-color)'; + document.getElementById('login-form').style.display = 'none'; + document.getElementById('oauth-code-display').style.display = 'none'; + } else { + statusEl.textContent = data.status || 'Not logged in'; + statusEl.style.color = 'var(--text-secondary)'; + // Check if OAuth is pending (for late-connecting clients) + if (data.oauth_pending) { + showOAuthCode(data.oauth_pending.url, data.oauth_pending.code); + } + } +} + +function updateSettingsUI(settings) { + state.settings = settings; + document.getElementById('dark-mode').checked = settings.dark_mode || false; + document.getElementById('connection-quality').value = settings.connection_quality || 1; + + if (settings.dark_mode) { + document.body.classList.add('dark-mode'); + } else { + document.body.classList.remove('dark-mode'); + } + + // Update available games if provided in settings + if (settings.games_available) { + availableGames = new Set(settings.games_available); + } + + // Update games to watch lists + renderGamesToWatch(); +} + +// ==================== Games to Watch Management ==================== + +let availableGames = new Set(); // All games from campaigns +let draggedElement = null; + +socket.on('games_available', (data) => { + availableGames = new Set(data.games || []); + renderGamesToWatch(); +}); + +function renderGamesToWatch() { + const selectedGames = state.settings.games_to_watch || []; + const filterText = document.getElementById('games-filter')?.value.toLowerCase() || ''; + + // Render selected games (sortable) + renderSelectedGames(selectedGames); + + // Render available games (checkboxes for unselected games) + const unselectedGames = Array.from(availableGames) + .filter(game => !selectedGames.includes(game)) + .filter(game => game.toLowerCase().includes(filterText)) + .sort(); + + renderAvailableGames(unselectedGames, filterText); +} + +function renderSelectedGames(games) { + const container = document.getElementById('selected-games-list'); + if (!container) return; + + container.innerHTML = ''; + + if (games.length === 0) { + container.innerHTML = '

No games selected. Check games below to add them.

'; + return; + } + + games.forEach((game, index) => { + const div = document.createElement('div'); + div.className = 'sortable-item'; + div.draggable = true; + div.dataset.game = game; + div.innerHTML = ` + + ${index + 1} + ${game} + + `; + + // Drag event handlers + div.addEventListener('dragstart', handleDragStart); + div.addEventListener('dragover', handleDragOver); + div.addEventListener('drop', handleDrop); + div.addEventListener('dragend', handleDragEnd); + + container.appendChild(div); + }); +} + +function renderAvailableGames(games, filterText) { + const container = document.getElementById('available-games-list'); + if (!container) return; + + container.innerHTML = ''; + + if (games.length === 0) { + if (filterText) { + container.innerHTML = '

No games match your search.

'; + } else { + container.innerHTML = '

All games are selected or no games available.

'; + } + return; + } + + games.forEach(game => { + const label = document.createElement('label'); + label.className = 'game-checkbox'; + label.innerHTML = ` + + ${game} + `; + container.appendChild(label); + }); +} + +// Drag and drop handlers +function handleDragStart(e) { + draggedElement = e.target; + e.target.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', e.target.innerHTML); +} + +function handleDragOver(e) { + if (e.preventDefault) { + e.preventDefault(); + } + e.dataTransfer.dropEffect = 'move'; + + const target = e.target.closest('.sortable-item'); + if (target && target !== draggedElement) { + const container = target.parentNode; + const allItems = [...container.querySelectorAll('.sortable-item')]; + const draggedIndex = allItems.indexOf(draggedElement); + const targetIndex = allItems.indexOf(target); + + if (draggedIndex < targetIndex) { + target.parentNode.insertBefore(draggedElement, target.nextSibling); + } else { + target.parentNode.insertBefore(draggedElement, target); + } + } + return false; +} + +function handleDrop(e) { + if (e.stopPropagation) { + e.stopPropagation(); + } + return false; +} + +function handleDragEnd(e) { + e.target.classList.remove('dragging'); + + // Update the order in state + const container = document.getElementById('selected-games-list'); + const items = container.querySelectorAll('.sortable-item'); + const newOrder = Array.from(items).map(item => item.dataset.game); + + state.settings.games_to_watch = newOrder; + + // Re-render to update priority numbers + renderSelectedGames(newOrder); + + // Save settings + saveSettings(); +} + +function toggleGameWatch(gameName, checked) { + const games = state.settings.games_to_watch || []; + + if (checked && !games.includes(gameName)) { + games.push(gameName); + } else if (!checked) { + const index = games.indexOf(gameName); + if (index > -1) { + games.splice(index, 1); + } + } + + state.settings.games_to_watch = games; + renderGamesToWatch(); + saveSettings(); +} + +function removeGameFromWatch(gameName) { + const games = state.settings.games_to_watch || []; + const index = games.indexOf(gameName); + if (index > -1) { + games.splice(index, 1); + state.settings.games_to_watch = games; + renderGamesToWatch(); + saveSettings(); + } +} + +function selectAllGames() { + state.settings.games_to_watch = Array.from(availableGames).sort(); + renderGamesToWatch(); + saveSettings(); +} + +function deselectAllGames() { + state.settings.games_to_watch = []; + renderGamesToWatch(); + saveSettings(); +} + +function flashTitle() { + const originalTitle = document.title; + let count = 0; + const interval = setInterval(() => { + document.title = count % 2 === 0 ? '🔔 Attention!' : originalTitle; + count++; + if (count >= 10) { + document.title = originalTitle; + clearInterval(interval); + } + }, 1000); +} + +// ==================== API Functions ==================== + +async function selectChannel(channelId) { + try { + await fetch('/api/channels/select', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({channel_id: channelId}) + }); + } catch (error) { + console.error('Failed to select channel:', error); + } +} + +async function submitLogin() { + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + const token = document.getElementById('2fa-token').value; + + try { + await fetch('/api/login', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username, password, token}) + }); + } catch (error) { + console.error('Failed to submit login:', error); + } +} + +async function confirmOAuth() { + // Signal that OAuth code has been entered + try { + await fetch('/api/oauth/confirm', { + method: 'POST' + }); + // Hide the OAuth form and show waiting message + document.getElementById('oauth-code-display').style.display = 'none'; + document.getElementById('login-status').textContent = 'Waiting for authentication...'; + } catch (error) { + console.error('Failed to confirm OAuth:', error); + } +} + +async function saveSettings() { + const settings = { + dark_mode: document.getElementById('dark-mode').checked, + connection_quality: parseInt(document.getElementById('connection-quality').value), + games_to_watch: state.settings.games_to_watch || [] + }; + + try { + await fetch('/api/settings', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(settings) + }); + console.log('Settings saved automatically'); + } catch (error) { + console.error('Failed to save settings:', error); + } +} + +async function reloadCampaigns() { + try { + await fetch('/api/reload', {method: 'POST'}); + } catch (error) { + console.error('Failed to reload:', error); + } +} + + +// ==================== Tab Management ==================== + +function switchTab(tabName) { + // Hide all tabs + document.querySelectorAll('.tab-content').forEach(tab => { + tab.classList.remove('active'); + }); + document.querySelectorAll('.tab-button').forEach(btn => { + btn.classList.remove('active'); + }); + + // Show selected tab + document.getElementById(`${tabName}-tab`).classList.add('active'); + document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); +} + +// ==================== Event Listeners ==================== + +document.addEventListener('DOMContentLoaded', () => { + // Tab switching + document.querySelectorAll('.tab-button').forEach(button => { + button.addEventListener('click', () => { + switchTab(button.dataset.tab); + }); + }); + + // Login form + document.getElementById('login-button').addEventListener('click', submitLogin); + document.getElementById('oauth-confirm').addEventListener('click', confirmOAuth); + + // Settings - auto-save on change + document.getElementById('dark-mode').addEventListener('change', (e) => { + // Apply dark mode immediately for instant feedback + if (e.target.checked) { + document.body.classList.add('dark-mode'); + } else { + document.body.classList.remove('dark-mode'); + } + // Then save settings + saveSettings(); + }); + document.getElementById('connection-quality').addEventListener('change', saveSettings); + document.getElementById('reload-btn').addEventListener('click', reloadCampaigns); + + // Games to watch management + document.getElementById('select-all-btn').addEventListener('click', selectAllGames); + document.getElementById('deselect-all-btn').addEventListener('click', deselectAllGames); + document.getElementById('games-filter').addEventListener('input', renderGamesToWatch); + + // Request notification permission + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); + } +}); diff --git a/web/static/styles.css b/web/static/styles.css new file mode 100644 index 0000000..a0c4efb --- /dev/null +++ b/web/static/styles.css @@ -0,0 +1,713 @@ +/* Global Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f5f5f5; + --bg-panel: #ffffff; + --text-primary: #333333; + --text-secondary: #666666; + --border-color: #dddddd; + --accent-color: #9146ff; + --success-color: #00c853; + --warning-color: #ffa726; + --error-color: #f44336; + --progress-bg: #e0e0e0; +} + +body.dark-mode { + --bg-primary: #1a1a1a; + --bg-secondary: #2a2a2a; + --bg-panel: #2a2a2a; + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --border-color: #444444; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +/* Header */ +header { + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +header h1 { + margin-bottom: 10px; + color: var(--accent-color); +} + +.status-bar { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; +} + +#connection-indicator { + font-weight: bold; +} + +#connection-indicator.connected { + color: var(--success-color); +} + +#connection-indicator.disconnected { + color: var(--error-color); +} + +/* Tabs */ +.tabs { + display: flex; + gap: 5px; + margin-bottom: 20px; +} + +.tab-button { + flex: 1; + padding: 12px; + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 8px 8px 0 0; + cursor: pointer; + font-size: 16px; + color: var(--text-primary); + transition: background 0.2s; +} + +.tab-button:hover { + background: var(--bg-secondary); +} + +.tab-button.active { + background: var(--accent-color); + color: white; + border-color: var(--accent-color); +} + +/* Tab Content */ +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Panels */ +.panel { + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.panel h2 { + margin-bottom: 15px; + font-size: 18px; + border-bottom: 2px solid var(--accent-color); + padding-bottom: 8px; +} + +/* Main Tab Grid */ +.main-grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto 1fr; + gap: 20px; + min-height: 70vh; +} + +.login-panel { + grid-column: 1; +} + +.progress-panel { + grid-column: 2; +} + +.console-panel { + grid-column: 1; + grid-row: 2 / 4; +} + +.channels-panel { + grid-column: 2; + grid-row: 2 / 4; +} + +/* Login */ +#login-form input { + display: block; + width: 100%; + padding: 10px; + margin-bottom: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); +} + +#login-form button, #oauth-confirm { + width: 100%; + padding: 12px; + background: var(--accent-color); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + font-weight: bold; +} + +#login-form button:hover, #oauth-confirm:hover { + opacity: 0.9; +} + +.oauth-code { + font-size: 32px; + font-weight: bold; + text-align: center; + padding: 20px; + background: var(--bg-secondary); + border-radius: 8px; + margin: 15px 0; + letter-spacing: 4px; +} + +/* Drop Progress */ +.drop-name { + font-weight: bold; + font-size: 16px; + margin-bottom: 5px; +} + +.drop-game { + color: var(--text-secondary); + margin-bottom: 15px; +} + +.progress-bar { + width: 100%; + height: 30px; + background: var(--progress-bg); + border-radius: 15px; + overflow: hidden; + margin-bottom: 10px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-color), var(--success-color)); + transition: width 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; +} + +.progress-text { + text-align: center; + font-size: 14px; +} + +.progress-time { + text-align: center; + color: var(--text-secondary); + font-size: 12px; +} + +/* Console Output */ +.console-output { + height: 400px; + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px; +} + +.console-output div { + margin-bottom: 2px; +} + +/* Channels List */ +.channels-list { + height: 400px; + overflow-y: auto; +} + +.channel-item { + padding: 12px; + margin-bottom: 8px; + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.channel-item:hover { + border-color: var(--accent-color); +} + +.channel-item.watching { + border-color: var(--success-color); + background: rgba(145, 70, 255, 0.1); +} + +.channel-item.online { + border-left: 4px solid var(--success-color); +} + +.channel-item.offline { + opacity: 0.6; +} + +.channel-name { + font-weight: bold; + font-size: 14px; +} + +.channel-info { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; +} + +.channel-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + margin-left: 5px; +} + +.channel-badge.drops { + background: var(--success-color); + color: white; +} + +.channel-badge.acl { + background: var(--accent-color); + color: white; +} + +/* Inventory Grid */ +.inventory-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; +} + +.campaign-card { + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.campaign-header { + padding: 15px; + background: var(--bg-secondary); +} + +.campaign-game { + font-weight: bold; + font-size: 16px; +} + +.campaign-name { + font-size: 14px; + color: var(--text-secondary); +} + +.campaign-status { + padding: 10px 15px; + font-size: 12px; + display: flex; + justify-content: space-between; +} + +.campaign-drops { + padding: 15px; +} + +.drop-item { + padding: 10px; + margin-bottom: 8px; + background: var(--bg-secondary); + border-radius: 4px; + border-left: 3px solid var(--border-color); +} + +.drop-item.claimed { + border-left-color: var(--success-color); + opacity: 0.7; +} + +.drop-item.active { + border-left-color: var(--accent-color); +} + +/* Settings */ +.settings-container { + max-width: 800px; +} + +.settings-section { + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; +} + +.settings-section h2 { + margin-bottom: 15px; +} + +.settings-section label { + display: block; + margin-bottom: 15px; +} + +.settings-section input[type="checkbox"] { + margin-right: 8px; +} + +.settings-section select, +.settings-section input[type="number"] { + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + margin-left: 10px; +} + +.list-manager { + margin-top: 20px; +} + +.game-list { + min-height: 100px; + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px; + margin-bottom: 10px; + background: var(--bg-secondary); +} + +.game-item { + padding: 8px; + margin-bottom: 5px; + background: var(--bg-panel); + border-radius: 4px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.game-item button { + padding: 4px 8px; + background: var(--error-color); + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 12px; +} + +.action-button { + padding: 12px 24px; + margin-right: 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + font-size: 14px; + color: var(--text-primary); +} + +.action-button.primary { + background: var(--accent-color); + color: white; + border-color: var(--accent-color); +} + +.action-button:hover { + opacity: 0.9; +} + +/* Help */ +.help-content { + max-width: 800px; + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 30px; +} + +.help-content h2 { + color: var(--accent-color); + margin-bottom: 15px; +} + +.help-content h3 { + margin-top: 20px; + margin-bottom: 10px; +} + +.help-content ul, .help-content ol { + margin-left: 20px; + margin-bottom: 15px; +} + +.help-content li { + margin-bottom: 8px; +} + +.help-content a { + color: var(--accent-color); + text-decoration: none; +} + +.help-content a:hover { + text-decoration: underline; +} + +.help-links { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--border-color); +} + +.help-links a { + display: inline-block; + padding: 10px 20px; + background: var(--accent-color); + color: white; + border-radius: 4px; + margin-right: 10px; +} + +/* Games to Watch Styles */ +.help-text { + color: var(--text-secondary); + font-size: 14px; + margin-bottom: 15px; +} + +.games-filter { + margin-bottom: 20px; +} + +.games-filter input { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; + margin-bottom: 10px; +} + +.filter-actions { + display: flex; + gap: 10px; +} + +.small-btn { + padding: 8px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + font-size: 13px; + color: var(--text-primary); + transition: all 0.2s; +} + +.small-btn:hover { + background: var(--accent-color); + color: white; + border-color: var(--accent-color); +} + +.games-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-top: 20px; +} + +.selected-games, .available-games { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 15px; + background: var(--bg-secondary); +} + +.selected-games h3, .available-games h3 { + font-size: 14px; + margin-bottom: 15px; + color: var(--text-secondary); +} + +/* Sortable list for selected games */ +.sortable-list { + min-height: 200px; + max-height: 400px; + overflow-y: auto; +} + +.sortable-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + margin-bottom: 8px; + background: var(--bg-panel); + border: 2px solid var(--border-color); + border-radius: 6px; + cursor: move; + transition: all 0.2s; + user-select: none; +} + +.sortable-item:hover { + border-color: var(--accent-color); + background: rgba(145, 70, 255, 0.1); +} + +.sortable-item.dragging { + opacity: 0.5; + transform: scale(0.95); +} + +.drag-handle { + font-size: 18px; + color: var(--text-secondary); + cursor: grab; +} + +.drag-handle:active { + cursor: grabbing; +} + +.priority-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--accent-color); + color: white; + border-radius: 50%; + font-size: 12px; + font-weight: bold; +} + +.game-name { + flex: 1; + font-weight: 500; +} + +.remove-btn { + padding: 4px 8px; + background: var(--error-color); + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 14px; + font-weight: bold; + opacity: 0.8; + transition: opacity 0.2s; +} + +.remove-btn:hover { + opacity: 1; +} + +/* Checkbox list for available games */ +.checkbox-list { + max-height: 400px; + overflow-y: auto; +} + +.game-checkbox { + display: flex; + align-items: center; + padding: 8px; + margin-bottom: 5px; + background: var(--bg-panel); + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; +} + +.game-checkbox:hover { + background: rgba(145, 70, 255, 0.1); +} + +.game-checkbox input[type="checkbox"] { + margin-right: 10px; + cursor: pointer; +} + +.game-checkbox span { + user-select: none; +} + +/* Utilities */ +.empty-message { + text-align: center; + color: var(--text-secondary); + padding: 40px; +} + +/* Responsive */ +@media (max-width: 1024px) { + .main-grid { + grid-template-columns: 1fr; + grid-template-rows: auto; + } + + .login-panel, .progress-panel, .console-panel, .channels-panel { + grid-column: 1; + grid-row: auto; + } + + .inventory-grid { + grid-template-columns: 1fr; + } +}