Initial MVP: local visual novel library with Ren'Py → PGVN converter

- FastAPI backend: admin bootstrap with forced credential change, admin-created
  users with one-time generated passwords, roles as permission sets
- Games and versions with content-addressed blob storage; forks copy only the
  manifest, so mods share assets with the original
- Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python
  subset translated via ast, media converted to WebP/Opus/WebM
- React panel (library search, game page, upload, users, roles) and a
  framework-free web player with saves, history, skip/auto, transitions
- docker compose: postgres, backend, worker, nginx on a single HTTP port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 02:41:32 +03:00
commit 364b312064
72 files changed
+9707

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
import io
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from PIL import Image, UnidentifiedImageError
from sqlalchemy import exists, or_, select, true
from starlette.concurrency import run_in_threadpool
from app.blobs import put_bytes
from app.uploads import remove_upload
from app.deps import DB, CurrentUser, has_perm, require
from app.models import Game, GameVersion, User
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn
router = APIRouter(prefix="/api/games", tags=["games"])
Player = Annotated[User, Depends(require("games.play"))]
Uploader = Annotated[User, Depends(require("games.upload"))]
MAX_COVER_BYTES = 20 * 1024 * 1024
def visible_versions_filter(user: User):
"""Versions a user can see: ready ones, plus their own in any state."""
if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"):
return true()
return or_(GameVersion.status == "ready", GameVersion.owner_id == user.id)
async def get_game(db: DB, game_id: int) -> Game:
game = await db.get(Game, game_id)
if not game:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Игра не найдена")
return game
def can_edit_game(user: User, game: Game) -> bool:
return has_perm(user, "games.edit") or (
has_perm(user, "games.upload") and game.created_by_id == user.id
)
@router.get("", response_model=list[GameOut])
async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[Game]:
stmt = select(Game).order_by(Game.title)
if q.strip():
pattern = f"%{q.strip()}%"
stmt = stmt.where(
or_(
Game.title.ilike(pattern),
Game.description.ilike(pattern),
Game.tags.any(q.strip().lower()),
)
)
if tag:
stmt = stmt.where(Game.tags.any(tag.lower()))
stmt = stmt.where(
exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user))
| (Game.created_by_id == user.id)
)
return list((await db.scalars(stmt.limit(500))).all())
@router.post("", response_model=GameOut, status_code=201)
async def create_game(body: GameIn, user: Uploader, db: DB) -> Game:
game = Game(
title=body.title.strip(),
description=body.description,
tags=sorted({t.strip().lower() for t in body.tags if t.strip()}),
created_by_id=user.id,
)
db.add(game)
await db.commit()
return game
@router.get("/{game_id}", response_model=GameDetailOut)
async def game_detail(game_id: int, user: Player, db: DB) -> dict:
game = await get_game(db, game_id)
versions = (
await db.scalars(
select(GameVersion)
.where(GameVersion.game_id == game.id, visible_versions_filter(user))
.order_by(GameVersion.parent_id.is_not(None), GameVersion.created_at)
)
).all()
return {**GameOut.model_validate(game).model_dump(), "versions": list(versions)}
@router.patch("/{game_id}", response_model=GameOut)
async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: DB) -> Game:
game = await get_game(db, game_id)
if not can_edit_game(user, game):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
if body.title is not None:
game.title = body.title.strip()
if body.description is not None:
game.description = body.description
if body.tags is not None:
game.tags = sorted({t.strip().lower() for t in body.tags if t.strip()})
await db.commit()
return game
@router.put("/{game_id}/cover", response_model=GameOut)
async def upload_cover(game_id: int, request: Request, user: CurrentUser, db: DB) -> Game:
game = await get_game(db, game_id)
if not can_edit_game(user, game):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
data = await request.body()
if len(data) > MAX_COVER_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл")
try:
game.cover_hash = await run_in_threadpool(make_cover, data)
except (UnidentifiedImageError, OSError):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Не удалось прочитать изображение")
await db.commit()
return game
@router.delete("/{game_id}", status_code=204)
async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.delete"))],
db: DB) -> None:
game = await get_game(db, game_id)
version_ids = (await db.scalars(select(GameVersion.id).where(GameVersion.game_id == game.id))).all()
await db.delete(game)
await db.commit()
for vid in version_ids:
remove_upload(vid)
def make_cover(data: bytes) -> str:
img = Image.open(io.BytesIO(data))
img.thumbnail((640, 640))
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA")
out = io.BytesIO()
img.save(out, "WEBP", quality=85)
return put_bytes(out.getvalue(), ".webp")[0]