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
12 KiB
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
ARMA_APP_ID = 107410
Steam Web API — httpx replaces Java steam-web-api-client library
# 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
# 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
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:
- Move directory to
<server_dir>/mods/@<name> - Normalize filenames to lowercase
- Insert/update
InstalledModEntityin DB - Broadcast
WorkshopModInstallationStatusviaModInstallProgressHandler.broadcast()
Player list — python-a2s
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:
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()
def can_use_workshop(self) -> bool:
path = settings.steamcmd_path
return bool(path) and Path(path).exists()
Completion Checklist
GET /api/v1/workshop/activereturns{active: bool}based on steamcmd path existencePOST /api/v1/workshop/queryreturns paginated workshop results with cursor- Workshop query results cached 5 minutes (TTLCache)
GET /api/v1/workshop/installed-itemsreturns installed + in-progress listsGET /api/v1/workshop/download-queuereturns tasks with fileId, modName, issuerPOST /api/v1/workshop/installqueues 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/steamnever returns password field valuePOST /api/v1/settings/steampersists to properties filepython-a2splayer query works (returns empty list if server offline)
Contract for Phase 7
Phase 7 imports:
from src.domain.steam.steam_service import SteamServicefrom src.domain.steam.steam_cmd_handler import queue_task, run_task_loop, get_tasks_by_typefrom src.domain.steam.models import SteamTask, WorkshopModInstallSteamTask, GameUpdateSteamTask