Files
arma-server-web-manager/phases/phase-06-steam-integration.md
Khoa (Revenovich) Tran Gia e02db3ddde feat: add Java→Python migration plan with 9 self-contained phase files
Converts Spring Boot 4.0.3 ARMA Server Web GUI to FastAPI/Python.
Each phase file is fully self-contained: lists Java source files to
read, output files to create, implementation patterns, REST endpoint
contracts, and a completion checklist. A future agent can execute any
single phase without rescanning the Java project.

Phases:
- 01: Foundation — SQLAlchemy models, Alembic, settings, base schemas
- 02: Auth & Users — JWT middleware, RBAC, user CRUD
- 03: CFG parser + server process — server.cfg round-trip, start/stop
- 04: Server settings — general/network/logging/security/difficulty
- 05: Mod management — mod CRUD, presets, settings, WebSocket progress
- 06: Steam integration — SteamCMD queue, Workshop API, python-a2s
- 07: Missions, CDLC, Discord, APScheduler jobs
- 08: Middleware & polish — global exception handler, SPA redirect, structlog
- 09: Testing — pytest-asyncio, respx, 80% coverage target
2026-04-14 15:06:56 +07:00

297 lines
12 KiB
Markdown

# 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
`<JAVA_SRC>` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\`
| File | What to extract |
|------|----------------|
| `<JAVA_SRC>domain/steam/SteamServiceImpl.java` | scheduleWorkshopModDownload(), scheduleArmaUpdate(), queryWorkshopMods(), getWorkshopMod(), getServerPlayers(), isServerRunning(), canUseWorkshop() |
| `<JAVA_SRC>domain/steam/SteamWebApiService.java` | queryWorkshopMods(), getWorkshopMod(), getWorkshopMods() — HTTP + caching |
| `<JAVA_SRC>domain/steam/SteamCmdHandler.java` | queueSteamTask(), @Scheduled consumer loop every 5s, retry logic |
| `<JAVA_SRC>domain/steam/SteamUtils.java` | ARMA_APP_ID = 107410 |
| `<JAVA_SRC>domain/steam/SteamArmaBranch.java` | Enum: PUBLIC, EXPERIMENTAL |
| `<JAVA_SRC>domain/steam/SteamTaskRetryPolicy.java` | Max retries, retryable exception check |
| `<JAVA_SRC>domain/steam/handler/WorkshopModDownloadTaskHandler.java` | steamcmd command construction + execution |
| `<JAVA_SRC>domain/steam/handler/WorkshopBatchModDownloadTaskHandler.java` | Batch download command |
| `<JAVA_SRC>domain/steam/handler/GameUpdateTaskHandler.java` | Server update command |
| `<JAVA_SRC>domain/steam/helper/SteamCmdModInstallHelper.java` | Post-install: move files, update DB, broadcast WS status |
| `<JAVA_SRC>domain/steam/model/WorkshopMod.java` | Fields: title, workshopFileId, previewUrl, children:[long] |
| `<JAVA_SRC>domain/steam/model/ArmaWorkshopQueryResponse.java` | Fields: nextCursor, mods |
| `<JAVA_SRC>domain/steam/model/WorkshopQueryParams.java` | Fields: cursor, searchText |
| `<JAVA_SRC>domain/steam/model/SteamTask.java` | Base + Type enum (WORKSHOP_MOD_INSTALL, GAME_UPDATE, WORKSHOP_BATCH_MOD_DOWNLOAD) |
| `<JAVA_SRC>domain/steam/model/QueuedSteamTask.java` | Fields: id(UUID), steamTask, attemptCount |
| `<JAVA_SRC>domain/steam/model/WorkshopModInstallSteamTask.java` | Fields: fileId, title, forced, issuer |
| `<JAVA_SRC>domain/steam/model/GameUpdateSteamTask.java` | Fields: issuer |
| `<JAVA_SRC>domain/steam/model/WorkshopBatchModDownloadTask.java` | Fields: Map<fileId, title>, forced, issuer |
| `<JAVA_SRC>domain/steam/model/SteamCmdWorkshopDownloadParameters.java` | CLI args list |
| `<JAVA_SRC>domain/steam/model/SteamCmdAppUpdateParameters.java` | CLI args list for game update |
| `<JAVA_SRC>domain/steam/model/ModDownloadResult.java` | Fields: success, modDirectory |
| `<JAVA_SRC>web/WorkshopRestController.java` | All workshop endpoints with exact JSON shapes |
| `<JAVA_SRC>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:
`<workshop_content_path>/steamapps/workshop/content/107410/<fileId>/`
Post-install steps:
1. Move directory to `<server_dir>/mods/@<name>`
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`