mirror of
https://github.com/rangermix/TwitchDropsMiner.git
synced 2026-05-30 08:59:36 +00:00
Backend enhancements: - Expand /api/translations endpoint with all available translation strings - Include login form fields, OAuth prompts, progress labels, channel statuses - Add inventory status texts, settings empty messages, help content - Include connection status and viewer/channel count translations Frontend improvements: - Update all input placeholders (username, password, 2FA, search) - Translate all button texts (Login, OAuth confirm, reload, manual mode) - Apply translations to empty state messages across all sections - Translate campaign statuses (Active, Upcoming, Expired, Claimed) - Update channel and viewer count labels - Apply translations to settings empty messages - Update help tab with translated how-it-works and getting-started text - Translate connection status indicators Now supports translating: - Login form (all fields and buttons) - OAuth device code flow prompts - Progress section labels and manual mode controls - Channel list empty states and status labels - Inventory campaign statuses and empty states - Settings section empty messages and labels - Help content sections - Header connection indicators All dynamically rendered content now respects language selection. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
450 lines
15 KiB
Python
450 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
import socketio
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
import uvicorn
|
|
|
|
from src.core.client import Twitch
|
|
from src.web.gui_manager import WebGUIManager
|
|
|
|
|
|
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
|
|
language: str | None = None
|
|
proxy: str | None = None
|
|
connection_quality: int | None = None
|
|
minimum_refresh_interval_minutes: 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"<h1>Twitch Drops Miner</h1><p>Web interface files not found. Please check installation.</p><p>Debug: Looking for {index_file}</p>",
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
@app.get("/api/status")
|
|
async def get_status():
|
|
"""Get current application status"""
|
|
if not gui_manager or not twitch_client:
|
|
raise HTTPException(status_code=503, detail="GUI not initialized")
|
|
|
|
return {
|
|
"status": gui_manager.status.get(),
|
|
"login": gui_manager.login.get_status(),
|
|
"manual_mode": twitch_client.get_manual_mode_info(),
|
|
}
|
|
|
|
|
|
@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 or not twitch_client:
|
|
raise HTTPException(status_code=503, detail="GUI not initialized")
|
|
|
|
# Validate channel exists
|
|
channel = twitch_client.channels.get(request.channel_id)
|
|
if not channel:
|
|
raise HTTPException(status_code=404, detail="Channel not found")
|
|
|
|
# Validate channel has a game
|
|
if not channel.game:
|
|
raise HTTPException(status_code=400, detail="Channel is not playing any game")
|
|
|
|
# Warn if channel has no drops (shouldn't happen if GUI is filtering correctly)
|
|
if not any(campaign.can_earn(channel) for campaign in twitch_client.inventory):
|
|
logger.warning(f"User selected channel {channel.name} but it has no available drops")
|
|
|
|
gui_manager.select_channel(request.channel_id)
|
|
|
|
# Trigger channel switch to apply the selection
|
|
from src.config import State
|
|
|
|
twitch_client.change_state(State.CHANNEL_SWITCH)
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@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.get("/api/languages")
|
|
async def get_languages():
|
|
"""Get available languages"""
|
|
if not gui_manager:
|
|
raise HTTPException(status_code=503, detail="GUI not initialized")
|
|
|
|
return gui_manager.settings.get_languages()
|
|
|
|
|
|
@app.get("/api/translations")
|
|
async def get_translations():
|
|
"""Get GUI translations for current language"""
|
|
from src.i18n.translator import _
|
|
|
|
# Return comprehensive GUI translations
|
|
return {
|
|
"language": _.current,
|
|
"translations": {
|
|
"tabs": {
|
|
"main": _("gui", "tabs", "main"),
|
|
"inventory": _("gui", "tabs", "inventory"),
|
|
"settings": _("gui", "tabs", "settings"),
|
|
"help": _("gui", "tabs", "help"),
|
|
},
|
|
"login": {
|
|
"title": _("gui", "login", "name"),
|
|
"status_label": _("gui", "login", "labels").split("\n")[0].rstrip(":"),
|
|
"logged_in": _("gui", "login", "logged_in"),
|
|
"not_logged_in": _("gui", "login", "logged_out"),
|
|
"username": _("gui", "login", "username"),
|
|
"password": _("gui", "login", "password"),
|
|
"twofa_code": _("gui", "login", "twofa_code"),
|
|
"button": _("gui", "login", "button"),
|
|
"oauth_prompt": "Enter this code at:",
|
|
"oauth_activate": "Twitch Activate",
|
|
"oauth_confirm": "I've entered the code",
|
|
},
|
|
"progress": {
|
|
"title": _("gui", "progress", "name"),
|
|
"no_drop": "No active drop",
|
|
"drop": _("gui", "progress", "drop").rstrip(":"),
|
|
"game": _("gui", "progress", "game").rstrip(":"),
|
|
"campaign": _("gui", "progress", "campaign").rstrip(":"),
|
|
"remaining": _("gui", "progress", "remaining"),
|
|
"progress_label": _("gui", "progress", "drop_progress").rstrip(":"),
|
|
"return_to_auto": "Return to Auto Mode",
|
|
"manual_mode_info": "Manual Mode: Mining",
|
|
},
|
|
"console": {
|
|
"title": _("gui", "output"),
|
|
},
|
|
"channels": {
|
|
"title": _("gui", "channels", "name"),
|
|
"online": _("gui", "channels", "online"),
|
|
"pending": _("gui", "channels", "pending"),
|
|
"offline": _("gui", "channels", "offline"),
|
|
"no_channels": "No channels tracked yet...",
|
|
"no_channels_for_games": "No channels found for selected games...",
|
|
"channel_count": "channel",
|
|
"channel_count_plural": "channels",
|
|
"viewers": "viewers",
|
|
},
|
|
"inventory": {
|
|
"title": _("gui", "tabs", "inventory"),
|
|
"no_campaigns": "No campaigns loaded yet...",
|
|
"status": {
|
|
"active": _("gui", "inventory", "status", "active"),
|
|
"upcoming": _("gui", "inventory", "status", "upcoming"),
|
|
"expired": _("gui", "inventory", "status", "expired"),
|
|
"claimed": _("gui", "inventory", "status", "claimed"),
|
|
},
|
|
"starts": _("gui", "inventory", "starts"),
|
|
"ends": _("gui", "inventory", "ends"),
|
|
"claimed_drops": "claimed",
|
|
},
|
|
"settings": {
|
|
"title": _("gui", "tabs", "settings"),
|
|
"general": _("gui", "settings", "general", "name"),
|
|
"dark_mode": _("gui", "settings", "general", "dark_mode").rstrip(": "),
|
|
"games_to_watch": "Games to Watch",
|
|
"games_help": "Select games to watch. Order matters - drag to reorder priority (top = highest priority).",
|
|
"search_games": "Search games...",
|
|
"select_all": "Select All",
|
|
"deselect_all": "Deselect All",
|
|
"selected_games": "Selected Games (drag to reorder)",
|
|
"available_games": "Available Games",
|
|
"no_games_selected": "No games selected. Check games below to add them.",
|
|
"no_games_match": "No games match your search.",
|
|
"all_games_selected": "All games are selected or no games available.",
|
|
"actions": "Actions",
|
|
"reload_campaigns": _("gui", "settings", "reload"),
|
|
"connection_quality": "Connection Quality:",
|
|
"minimum_refresh": "Minimum Refresh Interval (minutes):",
|
|
},
|
|
"help": {
|
|
"title": _("gui", "tabs", "help"),
|
|
"about": "About Twitch Drops Miner",
|
|
"about_text": "This application automatically mines timed Twitch drops without downloading stream data.",
|
|
"how_to_use": "How to Use",
|
|
"how_it_works": _("gui", "help", "how_it_works"),
|
|
"how_it_works_text": _("gui", "help", "how_it_works_text"),
|
|
"getting_started": _("gui", "help", "getting_started"),
|
|
"getting_started_text": _("gui", "help", "getting_started_text"),
|
|
"features": "Features",
|
|
"important_notes": "Important Notes",
|
|
"useful_links": _("gui", "help", "links", "name"),
|
|
"github_repo": "GitHub Repository",
|
|
},
|
|
"header": {
|
|
"title": "Twitch Drops Miner",
|
|
"language": "Language:",
|
|
"initializing": "Initializing...",
|
|
"connected": _("gui", "websocket", "connected"),
|
|
"disconnected": _("gui", "websocket", "disconnected"),
|
|
"auto_mode": "AUTO",
|
|
"manual_mode": "MANUAL",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
@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 twitch_client:
|
|
raise HTTPException(status_code=503, detail="Twitch client not initialized")
|
|
|
|
twitch_client.close()
|
|
return {"success": True}
|
|
|
|
|
|
@app.post("/api/mode/exit-manual")
|
|
async def exit_manual_mode():
|
|
"""Exit manual mode and return to automatic channel selection"""
|
|
if not twitch_client:
|
|
raise HTTPException(status_code=503, detail="Twitch client not initialized")
|
|
|
|
if not twitch_client.is_manual_mode():
|
|
return {"success": False, "message": "Not in manual mode"}
|
|
|
|
twitch_client.exit_manual_mode("User requested")
|
|
return {"success": True}
|
|
|
|
|
|
# ==================== Socket.IO Events ====================
|
|
|
|
|
|
@sio.event
|
|
async def connect(sid, environ):
|
|
"""Client connected"""
|
|
logger.info(f"Web client connected: {sid}")
|
|
|
|
# Send initial state to new client
|
|
if gui_manager and twitch_client:
|
|
await sio.emit(
|
|
"initial_state",
|
|
{
|
|
"status": gui_manager.status.get(),
|
|
"channels": gui_manager.channels.get_channels(),
|
|
"campaigns": gui_manager.inv.get_campaigns(),
|
|
"console": gui_manager.output.get_history(),
|
|
"settings": gui_manager.settings.get_settings(),
|
|
"login": gui_manager.login.get_status(),
|
|
"manual_mode": twitch_client.get_manual_mode_info(),
|
|
"current_drop": gui_manager.progress.get_current_drop(),
|
|
},
|
|
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"""
|
|
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())
|