# Phase 6 — Steam Integration **Status**: PENDING **Depends on**: Phase 5 complete (ModService, ModInstallProgressHandler); Phase 3 complete (ProcessService) **Next phase**: `phase-07-missions-cdlc-discord-jobs.md` --- ## Goal SteamCMD task queue, Steam Web API client (httpx-based), workshop mod querying/installing, server game update scheduling, WebSocket progress broadcasting, player list via python-a2s, and the steam settings endpoint. After this phase: workshop mod browsing, install, and Arma server update all work end-to-end. --- ## Java Source Files to Read `` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\` | File | What to extract | |------|----------------| | `domain/steam/SteamServiceImpl.java` | scheduleWorkshopModDownload(), scheduleArmaUpdate(), queryWorkshopMods(), getWorkshopMod(), getServerPlayers(), isServerRunning(), canUseWorkshop() | | `domain/steam/SteamWebApiService.java` | queryWorkshopMods(), getWorkshopMod(), getWorkshopMods() — HTTP + caching | | `domain/steam/SteamCmdHandler.java` | queueSteamTask(), @Scheduled consumer loop every 5s, retry logic | | `domain/steam/SteamUtils.java` | ARMA_APP_ID = 107410 | | `domain/steam/SteamArmaBranch.java` | Enum: PUBLIC, EXPERIMENTAL | | `domain/steam/SteamTaskRetryPolicy.java` | Max retries, retryable exception check | | `domain/steam/handler/WorkshopModDownloadTaskHandler.java` | steamcmd command construction + execution | | `domain/steam/handler/WorkshopBatchModDownloadTaskHandler.java` | Batch download command | | `domain/steam/handler/GameUpdateTaskHandler.java` | Server update command | | `domain/steam/helper/SteamCmdModInstallHelper.java` | Post-install: move files, update DB, broadcast WS status | | `domain/steam/model/WorkshopMod.java` | Fields: title, workshopFileId, previewUrl, children:[long] | | `domain/steam/model/ArmaWorkshopQueryResponse.java` | Fields: nextCursor, mods | | `domain/steam/model/WorkshopQueryParams.java` | Fields: cursor, searchText | | `domain/steam/model/SteamTask.java` | Base + Type enum (WORKSHOP_MOD_INSTALL, GAME_UPDATE, WORKSHOP_BATCH_MOD_DOWNLOAD) | | `domain/steam/model/QueuedSteamTask.java` | Fields: id(UUID), steamTask, attemptCount | | `domain/steam/model/WorkshopModInstallSteamTask.java` | Fields: fileId, title, forced, issuer | | `domain/steam/model/GameUpdateSteamTask.java` | Fields: issuer | | `domain/steam/model/WorkshopBatchModDownloadTask.java` | Fields: Map, forced, issuer | | `domain/steam/model/SteamCmdWorkshopDownloadParameters.java` | CLI args list | | `domain/steam/model/SteamCmdAppUpdateParameters.java` | CLI args list for game update | | `domain/steam/model/ModDownloadResult.java` | Fields: success, modDirectory | | `web/WorkshopRestController.java` | All workshop endpoints with exact JSON shapes | | `web/SteamSettingsController.java` | GET/POST /api/v1/settings/steam | --- ## Output Files to Create ``` src/domain/steam/__init__.py src/domain/steam/steam_service.py src/domain/steam/steam_web_api_service.py src/domain/steam/steam_cmd_handler.py src/domain/steam/models.py (WorkshopMod, ArmaWorkshopQueryResponse, WorkshopQueryParams, SteamTask + Type enum, QueuedSteamTask, WorkshopModInstallSteamTask, GameUpdateSteamTask, WorkshopBatchModDownloadTask) src/web/schemas/steam.py src/web/workshop_router.py src/web/steam_settings_router.py ``` Update `src/main.py`: register `workshop_router`, `steam_settings_router`; in `lifespan` start `asyncio.create_task(run_task_loop())`. --- ## Implementation Notes ### ARMA_APP_ID ```python ARMA_APP_ID = 107410 ``` ### Steam Web API — httpx replaces Java steam-web-api-client library ```python # src/domain/steam/steam_web_api_service.py import httpx from cachetools import TTLCache _query_cache: TTLCache = TTLCache(maxsize=100, ttl=300) # 5-min TTL STEAM_API_BASE = "https://api.steampowered.com" class SteamWebApiService: def __init__(self, api_key: str): self._api_key = api_key self._client = httpx.AsyncClient(timeout=15.0) async def query_workshop_mods(self, params: WorkshopQueryParams) -> ArmaWorkshopQueryResponse: if not self._api_key: raise MissingSteamApiKeyException() cache_key = f"q:{params.cursor}:{params.search_text}" if cache_key in _query_cache: return _query_cache[cache_key] resp = await self._client.post( f"{STEAM_API_BASE}/IPublishedFileService/QueryFiles/v1/", data={ "key": self._api_key, "appid": ARMA_APP_ID, "cursor": params.cursor or "*", "numperpage": 10, "search_text": params.search_text or "", "return_previews": 1, "return_children": 1, "query_type": 3, # RANKED_BY_TREND "filetype": 0, } ) resp.raise_for_status() data = resp.json().get("response", {}) result = ArmaWorkshopQueryResponse( next_cursor=data.get("next_cursor"), mods=[_convert_mod(m) for m in data.get("publishedfiledetails", [])] ) _query_cache[cache_key] = result return result async def get_workshop_mod(self, mod_id: int) -> WorkshopMod | None: # GET /IPublishedFileService/GetDetails/v1/?key=...&publishedfileids[0]=... ... ``` ### SteamCMD task queue — asyncio background task ```python # src/domain/steam/steam_cmd_handler.py import asyncio, uuid from collections import deque _queue: deque[QueuedSteamTask] = deque() _current: QueuedSteamTask | None = None MAX_RETRIES = 3 def queue_task(task: SteamTask) -> uuid.UUID: task_id = uuid.uuid4() _queue.append(QueuedSteamTask(id=task_id, steam_task=task, attempt_count=0)) return task_id def get_tasks_by_type(task_type: SteamTask.Type) -> list[SteamTask]: tasks = [q.steam_task for q in _queue if q.steam_task.type == task_type] if _current and _current.steam_task.type == task_type: tasks.append(_current.steam_task) return tasks async def run_task_loop(): """Registered with asyncio.create_task() in lifespan.""" global _current while True: await asyncio.sleep(5) if not _queue: continue queued = _queue.popleft() _current = queued try: await _dispatch(queued.steam_task) except RetryableException: if queued.attempt_count < MAX_RETRIES: _queue.appendleft(QueuedSteamTask( id=queued.id, steam_task=queued.steam_task, attempt_count=queued.attempt_count + 1, )) except Exception as e: log.error("Steam task failed permanently: %s", e) finally: _current = None ``` ### SteamCMD command construction ```python def build_workshop_download_command( steamcmd_path: str, username: str, password: str, workshop_content_path: str, file_id: int ) -> list[str]: return [ steamcmd_path, "+login", username, password, "+force_install_dir", workshop_content_path, "+workshop_download_item", str(ARMA_APP_ID), str(file_id), "+quit", ] def build_game_update_command( steamcmd_path: str, username: str, password: str, install_dir: str ) -> list[str]: return [ steamcmd_path, "+login", username, password, "+force_install_dir", install_dir, "+app_update", str(ARMA_APP_ID), "validate", "+quit", ] ``` Run with `asyncio.create_subprocess_exec()`. On success, the mod appears at: `/steamapps/workshop/content/107410//` Post-install steps: 1. Move directory to `/mods/@` 2. Normalize filenames to lowercase 3. Insert/update `InstalledModEntity` in DB 4. Broadcast `WorkshopModInstallationStatus` via `ModInstallProgressHandler.broadcast()` ### Player list — python-a2s ```python import a2s async def get_server_players() -> list[ArmaServerPlayer]: try: players = await asyncio.to_thread( a2s.players, ("localhost", 2303), timeout=3.0 ) return [ArmaServerPlayer(name=p.name, score=p.score, duration=p.duration) for p in players] except Exception: return [] ``` ### Steam settings persistence After a POST to `/api/v1/settings/steam`, write back to `config/aswg_default_config.properties`: ```python from jproperties import Properties def save_settings_to_file(): p = Properties() with open("config/aswg_default_config.properties", "rb") as f: p.load(f) p["aswg.steamcmd.path"] = settings.steamcmd_path p["aswg.steamcmd.username"] = settings.steamcmd_username # etc. with open("config/aswg_default_config.properties", "wb") as f: p.store(f) ``` ### REST Endpoints **Workshop** (`/api/v1/workshop`) ``` GET /api/v1/workshop/active → {active: bool} no auth required POST /api/v1/workshop/query body:{cursor,searchText} WORKSHOP_INSTALL → {nextCursor, mods:[...]} GET /api/v1/workshop/installed-items → {mods:[...], modsUnderInstallation:[...]} WORKSHOP_VIEW GET /api/v1/workshop/download-queue → {downloadingMods:[{fileId,modName,installAttemptCount,issuer}]} WORKSHOP_VIEW GET /api/v1/workshop/mod/{id}/dependencies → {modId, dependencies:[{modId,modName,status}]} WORKSHOP_VIEW POST /api/v1/workshop/install body:{fileId,modName,installDependencies} WORKSHOP_INSTALL → {fileId: 123456} HTTP 200 ``` **Steam Settings** (`/api/v1/settings/steam`) ``` GET /api/v1/settings/steam → {steamCmdPath,steamCmdUsername,steamCmdPassword:null, steamCmdWorkshopContentPath,steamWebApiToken} STEAM_SETTINGS_UPDATE POST /api/v1/settings/steam body: same shape → 200 STEAM_SETTINGS_UPDATE POST /api/v1/settings/steam/password-change body:{password} → 200 STEAM_SETTINGS_UPDATE ``` Note: `steamCmdPassword` is always `null` in GET responses (never returned to client). ### canUseWorkshop() ```python def can_use_workshop(self) -> bool: path = settings.steamcmd_path return bool(path) and Path(path).exists() ``` --- ## Completion Checklist - [ ] `GET /api/v1/workshop/active` returns `{active: bool}` based on steamcmd path existence - [ ] `POST /api/v1/workshop/query` returns paginated workshop results with cursor - [ ] Workshop query results cached 5 minutes (TTLCache) - [ ] `GET /api/v1/workshop/installed-items` returns installed + in-progress lists - [ ] `GET /api/v1/workshop/download-queue` returns tasks with fileId, modName, issuer - [ ] `POST /api/v1/workshop/install` queues SteamCMD task, returns `{fileId}` - [ ] SteamCMD task loop started in lifespan, polls every 5 seconds - [ ] After download: mod moves to `server_dir/mods/`, DB record inserted, WS broadcast sent - [ ] `GET /api/v1/settings/steam` never returns password field value - [ ] `POST /api/v1/settings/steam` persists to properties file - [ ] `python-a2s` player query works (returns empty list if server offline) ## Contract for Phase 7 Phase 7 imports: - `from src.domain.steam.steam_service import SteamService` - `from src.domain.steam.steam_cmd_handler import queue_task, run_task_loop, get_tasks_by_type` - `from src.domain.steam.models import SteamTask, WorkshopModInstallSteamTask, GameUpdateSteamTask`