import asyncio from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import select from starlette.concurrency import run_in_threadpool from starlette.requests import ClientDisconnect from app import translation from app.deps import DB, CurrentUser, has_perm, require from app.models import Game, GameVersion, Revision, Save, User, VersionHistory from app.routers.games import get_game from app.schemas import ( ForkIn, HistoryOut, PublishIn, SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut, ) from app.uploads import remove_upload, upload_path router = APIRouter(prefix="/api", tags=["versions"]) Player = Annotated[User, Depends(require("games.play"))] Uploader = Annotated[User, Depends(require("games.upload"))] ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz") def can_see(user: User, v: GameVersion) -> bool: published = v.visibility == "published" or has_perm(user, "versions.publish") return (v.status == "ready" and published) or v.owner_id == user.id or can_manage(user, v) def can_manage(user: User, v: GameVersion) -> bool: return ( v.owner_id == user.id or has_perm(user, "versions.manage_any") or has_perm(user, "games.delete") ) async def get_version(db: DB, user: User, version_id: int) -> GameVersion: v = await db.get(GameVersion, version_id) if not v or not can_see(user, v): raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена") return v @router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201) async def upload_version( game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал" ) -> GameVersion: """Stream a raw archive body to disk and queue it for conversion.""" game = await get_game(db, game_id) if not filename.lower().endswith(ARCHIVE_EXTS): raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}") version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал", upload_name=filename, status="uploading") db.add(version) await db.commit() path = upload_path(version.id) path.parent.mkdir(parents=True, exist_ok=True) size = 0 try: with path.open("wb") as f: async for chunk in request.stream(): f.write(chunk) size += len(chunk) except Exception as e: remove_upload(version.id) await db.delete(version) await db.commit() if isinstance(e, ClientDisconnect): # usually a proxy in front cut the body off (e.g. its own 413) raise HTTPException(status.HTTP_400_BAD_REQUEST, "Загрузка прервана") from None raise if size == 0: remove_upload(version.id) await db.delete(version) await db.commit() raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл") from app.converter.archive import ArchiveError from app.converter.compatibility import CompatibilityError from app.converter.preflight import probe_archive try: probe = await asyncio.to_thread(probe_archive, path, filename) except (CompatibilityError, ArchiveError, OSError, ValueError) as exc: remove_upload(version.id) await db.delete(version) await db.commit() raise HTTPException(422, str(exc)) from None version.status = "queued" version.progress = "В очереди на проверку совместимости" version.report = {"engine": probe["engine"], "compatible": None} if getattr(game, "main_version_id", None) is None: game.engine = probe["engine"] version.size_bytes = size await db.commit() return version @router.get("/versions/{version_id}", response_model=VersionDetailOut) async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion: return await get_version(db, user, version_id) @router.get("/versions/{version_id}/manifest") async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]: v = await get_version(db, user, version_id) if v.status != "ready" or not v.manifest: raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") return {**v.manifest, "revision": v.revision_id, "versionId": v.id} @router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201) async def fork_version(version_id: int, body: ForkIn, user: Annotated[User, Depends(require("games.play"))], db: DB) -> GameVersion: needed = "translations.edit" if body.kind == "translation" else "versions.fork" if not has_perm(user, needed): raise HTTPException(status.HTTP_403_FORBIDDEN, f"Нет прав: {needed}") parent = await get_version(db, user, version_id) if parent.status != "ready": raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию") fork = GameVersion( game_id=parent.game_id, parent_id=parent.id, owner_id=user.id, name=body.name.strip(), description=body.description, status="ready", visibility="draft", kind=body.kind, manifest=parent.manifest, base_revision_id=parent.revision_id, report=parent.report, size_bytes=parent.size_bytes, ) fork._revision_parents = [parent.revision_id] if parent.revision_id else [] db.add(fork) await db.commit() return fork Publisher = Annotated[User, Depends(require("versions.publish"))] async def lock_version(db: DB, version_id: int) -> GameVersion: """Row-locked version for read-modify-write of its manifest.""" v = await db.scalar(select(GameVersion).where(GameVersion.id == version_id) .with_for_update(of=GameVersion) .execution_options(populate_existing=True)) if v is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена") return v @router.post("/versions/{version_id}/publish", response_model=VersionOut) async def publish_version(version_id: int, body: PublishIn, user: Publisher, db: DB) -> GameVersion: """Publish a draft. A translation is merged into the game's main version in place, so players keep their saves; the previous manifest goes to the history.""" v = await get_version(db, user, version_id) if v.status != "ready": raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") game = await db.get(Game, v.game_id) if v.kind == "translation" and game.main_version_id and game.main_version_id != v.id: locked = {key: await lock_version(db, key) for key in sorted((v.id, game.main_version_id))} v, main = locked[v.id], locked[game.main_version_id] base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None if base is None: raise HTTPException(409, "Историческая база перевода неизвестна. Создайте новый форк перевода") langs = translation.changed_languages(v.manifest, base.manifest) if not langs: raise HTTPException(status.HTTP_409_CONFLICT, "В версии нет изменённых переводов") merged, conflicts = await run_in_threadpool(translation.merge_translation, base.manifest, v.manifest, main.manifest) if conflicts: raise HTTPException(409, "Конфликт перевода; согласуйте изменения строк: " + ", ".join(c["path"] for c in conflicts[:10])) db.add(VersionHistory(version_id=main.id, user_id=user.id, manifest=main.manifest, note=f"Перевод из «{v.name}»: {', '.join(langs)}")) main._revision_parents = [main.revision_id, v.revision_id] main.manifest = merged main.size_bytes = sum(f["size"] for f in merged["files"].values()) else: v = await lock_version(db, v.id) if body.make_main: game.main_version_id = v.id v.visibility = "published" await db.commit() await db.refresh(v) return v @router.post("/versions/{version_id}/make-main", response_model=VersionOut) async def make_main(version_id: int, user: Publisher, db: DB) -> GameVersion: v = await get_version(db, user, version_id) if v.status != "ready": raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") game = await db.get(Game, v.game_id) game.main_version_id = v.id v.visibility = "published" await db.commit() await db.refresh(v) return v @router.get("/versions/{version_id}/history", response_model=list[HistoryOut]) async def version_history(version_id: int, user: Player, db: DB) -> list[VersionHistory]: await get_version(db, user, version_id) return list((await db.scalars(select(VersionHistory).where(VersionHistory.version_id == version_id) .order_by(VersionHistory.id.desc()))).all()) @router.post("/versions/{version_id}/history/{history_id}/restore", response_model=VersionOut) async def restore_history(version_id: int, history_id: int, user: Publisher, db: DB) -> GameVersion: v = await lock_version(db, version_id) h = await db.get(VersionHistory, history_id) if h is None or h.version_id != v.id: raise HTTPException(status.HTTP_404_NOT_FOUND, "Запись истории не найдена") db.add(VersionHistory(version_id=v.id, user_id=user.id, manifest=v.manifest, note=f"До отката к записи от {h.created_at:%d.%m.%Y %H:%M}")) v.manifest = h.manifest await db.commit() await db.refresh(v) return v @router.patch("/versions/{version_id}", response_model=VersionOut) async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion: v = await get_version(db, user, version_id) if not can_manage(user, v): raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") v.name = body.name.strip() v.description = body.description await db.commit() return v @router.post("/versions/{version_id}/retry", response_model=VersionOut) async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion: v = await get_version(db, user, version_id) if not can_manage(user, v): raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") if v.status != "failed" or not upload_path(v.id).exists(): raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна") v.status = "queued" v.progress = "В очереди на конвертацию" await db.commit() return v @router.delete("/versions/{version_id}", status_code=204) async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None: v = await get_version(db, user, version_id) if not can_manage(user, v): raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") if v.status in ("converting", "importing"): raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется") await db.delete(v) await db.commit() remove_upload(version_id) # --- saves ----------------------------------------------------------------- def _check_slot(slot: str) -> str: if not slot or len(slot) > 32 or not all(c.isalnum() or c in "_-" for c in slot): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот") return slot @router.get("/versions/{version_id}/saves", response_model=list[SaveOut]) async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]: await get_version(db, user, version_id) return list((await db.scalars( select(Save).where(Save.user_id == user.id, Save.version_id == version_id) .order_by(Save.updated_at.desc()) )).all()) async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None: return await db.scalar(select(Save).where( Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot))) @router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut) async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save: save = await _find_save(db, user, version_id, slot) if not save: raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено") return save @router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut) async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: DB) -> Save: await get_version(db, user, version_id) save = await _find_save(db, user, version_id, slot) if save is None: save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255], data=body.data) db.add(save) else: save.title = body.title[:255] save.data = body.data await db.commit() await db.refresh(save) return save @router.delete("/versions/{version_id}/saves/{slot}", status_code=204) async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None: save = await _find_save(db, user, version_id, slot) if save: await db.delete(save) await db.commit()