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

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:

  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

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/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