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:
commit
364b312064
72 files changed
+9707
No files matched your search
@@ -0,0 +1,108 @@
|
||||
"""Conversion worker: polls the DB for queued versions and converts them."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.blobs import read_bytes
|
||||
from app.config import get_settings
|
||||
from app.converter.pipeline import convert_archive
|
||||
from app.db import SessionLocal
|
||||
from app.models import Game, GameVersion
|
||||
from app.routers.games import make_cover
|
||||
from app.uploads import remove_upload, upload_path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("worker")
|
||||
|
||||
|
||||
async def set_progress(version_id: int, text: str) -> None:
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(update(GameVersion).where(GameVersion.id == version_id)
|
||||
.values(progress=text[:255]))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def claim() -> GameVersion | None:
|
||||
async with SessionLocal() as db:
|
||||
version_id = await db.scalar(
|
||||
select(GameVersion.id).where(GameVersion.status == "queued").order_by(GameVersion.id)
|
||||
.limit(1).with_for_update(skip_locked=True)
|
||||
)
|
||||
if version_id is None:
|
||||
return None
|
||||
v = await db.get(GameVersion, version_id)
|
||||
v.status = "converting"
|
||||
v.progress = "Начало конвертации"
|
||||
await db.commit()
|
||||
return v
|
||||
|
||||
|
||||
async def process(v: GameVersion) -> None:
|
||||
settings = get_settings()
|
||||
loop = asyncio.get_running_loop()
|
||||
work = settings.work_dir / str(v.id)
|
||||
|
||||
def progress(text: str) -> None:
|
||||
asyncio.run_coroutine_threadsafe(set_progress(v.id, text), loop)
|
||||
|
||||
log.info("converting version %s (%s)", v.id, v.upload_name)
|
||||
try:
|
||||
result = await asyncio.to_thread(convert_archive, upload_path(v.id), v.upload_name or "",
|
||||
work, progress)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("version %s failed", v.id)
|
||||
async with SessionLocal() as db:
|
||||
version = await db.get(GameVersion, v.id)
|
||||
if version:
|
||||
version.status = "failed"
|
||||
version.progress = f"Ошибка: {e}"[:255]
|
||||
version.report = {"error": str(e)}
|
||||
await db.commit()
|
||||
return
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
async with SessionLocal() as db:
|
||||
version = await db.get(GameVersion, v.id)
|
||||
if version is None: # deleted while converting
|
||||
return
|
||||
version.status = "ready"
|
||||
version.progress = ""
|
||||
version.manifest = result.manifest
|
||||
version.report = result.report
|
||||
version.size_bytes = result.size
|
||||
game = await db.get(Game, version.game_id)
|
||||
if game and not game.cover_hash and result.cover_source:
|
||||
try:
|
||||
game.cover_hash = await asyncio.to_thread(make_cover,
|
||||
read_bytes(result.cover_source))
|
||||
except OSError:
|
||||
pass
|
||||
await db.commit()
|
||||
remove_upload(v.id) # sources are not kept once converted
|
||||
log.info("version %s ready: %s", v.id, result.report.get("media"))
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
settings = get_settings()
|
||||
for d in (settings.blobs_dir, settings.uploads_dir, settings.work_dir):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
async with SessionLocal() as db:
|
||||
# jobs interrupted by a restart go back to the queue
|
||||
await db.execute(update(GameVersion).where(GameVersion.status == "converting")
|
||||
.values(status="queued"))
|
||||
await db.commit()
|
||||
log.info("worker started")
|
||||
while True:
|
||||
v = await claim()
|
||||
if v is None:
|
||||
await asyncio.sleep(settings.worker_poll_seconds)
|
||||
continue
|
||||
await process(v)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in new issue
Block a user