Add .gitattributes: LF in the repository, CRLF for batch files

Normalize the files that had been committed with CRLF (line endings only,
no content changes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:41:24 +03:00
1 parent e1d7b29f77
commit 51a7e77e56
14 files changed
+1461 -1447

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# Keep LF in the repository regardless of the OS that edits the files.
* text=auto eol=lf
# cmd.exe needs CRLF in batch files
*.bat text eol=crlf
*.cmd text eol=crlf
*.png binary
*.jpg binary
*.webp binary
*.ogg binary
*.wav binary
*.zip binary
*.rpa binary
+102 -102
View File
@@ -1,102 +1,102 @@
# PGames — локальная библиотека визуальных новелл # PGames — локальная библиотека визуальных новелл
Загружаете архив с игрой на Ren'Py — сервер распаковывает его, переводит Загружаете архив с игрой на Ren'Py — сервер распаковывает его, переводит
скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM) скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM)
и удаляет исходники. Играете в браузере во встроенном плеере. У каждой игры и удаляет исходники. Играете в браузере во встроенном плеере. У каждой игры
может быть несколько версий (форков) от разных пользователей; версии делят может быть несколько версий (форков) от разных пользователей; версии делят
общие ассеты, поэтому форк почти не занимает места. общие ассеты, поэтому форк почти не занимает места.
Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Установка на VM ## Установка на VM
```bash ```bash
git clone <repo> pgames && cd pgames git clone <repo> pgames && cd pgames
cp .env.example .env cp .env.example .env
# обязательно поменяйте SECRET_KEY (openssl rand -hex 32) и пароли # обязательно поменяйте SECRET_KEY (openssl rand -hex 32) и пароли
docker compose up -d --build docker compose up -d --build
``` ```
Приложение слушает `http://<IP VM>:8080` (порт — `HTTP_PORT` в `.env`). Приложение слушает `http://<IP VM>:8080` (порт — `HTTP_PORT` в `.env`).
Caddy на хосте Proxmox: Caddy на хосте Proxmox:
``` ```
games.example.lan { games.example.lan {
reverse_proxy 192.168.1.50:8080 reverse_proxy 192.168.1.50:8080
} }
``` ```
Если в браузере сайт открывается по HTTPS через Caddy, оставьте Если в браузере сайт открывается по HTTPS через Caddy, оставьте
`COOKIE_SECURE=true`; при доступе напрямую по HTTP поставьте `false`, иначе `COOKIE_SECURE=true`; при доступе напрямую по HTTP поставьте `false`, иначе
браузер не сохранит сессию. браузер не сохранит сессию.
## Первый вход ## Первый вход
Логин `admin` / пароль из `ADMIN_PASSWORD` (по умолчанию `admin`). Сразу после Логин `admin` / пароль из `ADMIN_PASSWORD` (по умолчанию `admin`). Сразу после
входа нужно задать свой логин и пароль. входа нужно задать свой логин и пароль.
Регистрации нет: пользователей создаёт админ в разделе «Пользователи». Регистрации нет: пользователей создаёт админ в разделе «Пользователи».
Сервер генерирует пароль и показывает его один раз — передайте его человеку, Сервер генерирует пароль и показывает его один раз — передайте его человеку,
при первом входе он задаст свой. Права настраиваются через роли при первом входе он задаст свой. Права настраиваются через роли
(«Роли»): `admin`, `moderator`, `player` плюс свои. («Роли»): `admin`, `moderator`, `player` плюс свои.
## Импорт уже распакованных игр ## Импорт уже распакованных игр
Если игры уже лежат на VM распакованными, архивировать их не нужно. Если игры уже лежат на VM распакованными, архивировать их не нужно.
Подключите папку к контейнеру (только чтение) и запустите импорт: Подключите папку к контейнеру (только чтение) и запустите импорт:
```bash ```bash
docker compose run --rm -v /srv/games:/games:ro backend \ docker compose run --rm -v /srv/games:/games:ro backend \
python -m app.import_dir "/games/Some-Game" --title "Some Game" python -m app.import_dir "/games/Some-Game" --title "Some Game"
``` ```
Папка не изменяется, сконвертированная копия попадает в библиотеку как Папка не изменяется, сконвертированная копия попадает в библиотеку как
обычная загрузка. Проверить, насколько хорошо конвертер понимает игру, обычная загрузка. Проверить, насколько хорошо конвертер понимает игру,
не трогая медиа (минуты вместо часов), можно сухим прогоном: не трогая медиа (минуты вместо часов), можно сухим прогоном:
```bash ```bash
docker compose run --rm -v /srv/games:/games:ro backend \ docker compose run --rm -v /srv/games:/games:ro backend \
python -W ignore -m app.converter.analyze "/games/Some-Game" python -W ignore -m app.converter.analyze "/games/Some-Game"
``` ```
## Данные и бэкап ## Данные и бэкап
* `./data/blobs` — сконвертированные игры (неизменяемые файлы по sha256); * `./data/blobs` — сконвертированные игры (неизменяемые файлы по sha256);
* том `db` — PostgreSQL (пользователи, игры, версии, сохранения). * том `db` — PostgreSQL (пользователи, игры, версии, сохранения).
Для бэкапа достаточно `pg_dump` и копии `data/blobs`. Для бэкапа достаточно `pg_dump` и копии `data/blobs`.
## Разработка ## Разработка
На Windows тестовый стенд поднимается одной командой: `start-test.bat`. На Windows тестовый стенд поднимается одной командой: `start-test.bat`.
Он запускает отдельный compose-проект `pgames-test` на http://localhost:8090 Он запускает отдельный compose-проект `pgames-test` на http://localhost:8090
(свой `.env.test`, данные в `data-test\`), генерирует тестовую игру (свой `.env.test`, данные в `data-test\`), генерирует тестовую игру
`data-test\sample.zip` и открывает браузер. `start-test.bat stop` — остановить, `data-test\sample.zip` и открывает браузер. `start-test.bat stop` — остановить,
`reset` — удалить стенд вместе с данными, `logs` — логи бэкенда и воркера. `reset` — удалить стенд вместе с данными, `logs` — логи бэкенда и воркера.
```bash ```bash
cd backend && pip install -r requirements.txt pytest && python -m pytest cd backend && pip install -r requirements.txt pytest && python -m pytest
cd frontend && npm install && npm run dev # проксирует /api на :8000 cd frontend && npm install && npm run dev # проксирует /api на :8000
python -m tests.make_sample sample.zip # тестовая игра (из backend/) python -m tests.make_sample sample.zip # тестовая игра (из backend/)
``` ```
## Что поддерживает конвертер Ren'Py ## Что поддерживает конвертер Ren'Py
`label`, реплики (в т.ч. атрибуты, `extend`, `centered`, NVL), `scene/show/hide` `label`, реплики (в т.ч. атрибуты, `extend`, `centered`, NVL), `scene/show/hide`
с `at`/`as`/`behind`/`zorder`/`with`, переходы (`dissolve`, `fade`, с `at`/`as`/`behind`/`zorder`/`with`, переходы (`dissolve`, `fade`,
`Fade(...)`, `vpunch`, wipe/slide…), `menu` с условиями и `set`, `if/elif/else`, `Fade(...)`, `vpunch`, wipe/slide…), `menu` с условиями и `set`, `if/elif/else`,
`while`, `jump/call/return` (включая `expression`), `$`/`python:`/`init python:` `while`, `jump/call/return` (включая `expression`), `$`/`python:`/`init python:`
(подмножество Python: переменные, выражения, списки/словари, циклы), (подмножество Python: переменные, выражения, списки/словари, циклы),
`define/default`, `Character(...)`, `image` (файлы, цвета, `Transform`, `im.*`, `define/default`, `Character(...)`, `image` (файлы, цвета, `Transform`, `im.*`,
`Composite`, `ConditionSwitch`, `Movie`, покадровые ATL-анимации с `pause`/`repeat`), `Composite`, `ConditionSwitch`, `Movie`, покадровые ATL-анимации с `pause`/`repeat`),
`layeredimage` (always/attribute/group/if), `transform` со статическими `layeredimage` (always/attribute/group/if), `transform` со статическими
свойствами, `Shake`, `camera`, вложенные метки и именованные `menu`, свойствами, `Shake`, `camera`, вложенные метки и именованные `menu`,
`call label(args)` и метки с параметрами, `play/queue/stop/voice` и свои `call label(args)` и метки с параметрами, `play/queue/stop/voice` и свои
каналы (`register_channel`), `pause`, `.rpa`, `.rpyc` (через unrpyc), каналы (`register_channel`), `pause`, `.rpa`, `.rpyc` (через unrpyc),
автоопределение картинок из `images/`, GUI-текстбокс и шрифты игры. автоопределение картинок из `images/`, GUI-текстбокс и шрифты игры.
Пока **не** поддерживается: `screen`-язык (кастомные экраны, карты, Пока **не** поддерживается: `screen`-язык (кастомные экраны, карты,
мини-игры), движение и твины в ATL, функции/классы в `python` и картинки, мини-игры), движение и твины в ATL, функции/классы в `python` и картинки,
которые игра вычисляет своими функциями. Такие места перечислены в отчёте которые игра вычисляет своими функциями. Такие места перечислены в отчёте
конвертации у версии. Игры-«песочницы», где навигация построена на экранах, конвертации у версии. Игры-«песочницы», где навигация построена на экранах,
пока не играбельны. пока не играбельны.
+24 -24
View File
@@ -1,24 +1,24 @@
[alembic] [alembic]
script_location = migrations script_location = migrations
prepend_sys_path = . prepend_sys_path = .
[loggers] [loggers]
keys = root keys = root
[handlers] [handlers]
keys = console keys = console
[formatters] [formatters]
keys = generic keys = generic
[logger_root] [logger_root]
level = WARN level = WARN
handlers = console handlers = console
[handler_console] [handler_console]
class = logging.StreamHandler class = logging.StreamHandler
args = (sys.stderr,) args = (sys.stderr,)
formatter = generic formatter = generic
[formatter_generic] [formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s format = %(levelname)-5.5s [%(name)s] %(message)s
+69 -69
View File
@@ -1,69 +1,69 @@
"""Content-addressed blob store. """Content-addressed blob store.
A blob id is ``<sha256><ext>`` (e.g. ``3fa1….webp``); the file lives at A blob id is ``<sha256><ext>`` (e.g. ``3fa1….webp``); the file lives at
``blobs/3f/a1/<id>``. The extension is part of the id so nginx can serve ``blobs/3f/a1/<id>``. The extension is part of the id so nginx can serve
blobs with the right Content-Type straight from disk. blobs with the right Content-Type straight from disk.
""" """
import hashlib import hashlib
import os import os
import re import re
import shutil import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from app.config import get_settings from app.config import get_settings
BLOB_ID_RE = re.compile(r"^[0-9a-f]{64}(\.[a-z0-9]{1,8})?$") BLOB_ID_RE = re.compile(r"^[0-9a-f]{64}(\.[a-z0-9]{1,8})?$")
_CHUNK = 1 << 20 _CHUNK = 1 << 20
def blob_path(blob_id: str) -> Path: def blob_path(blob_id: str) -> Path:
if not BLOB_ID_RE.match(blob_id): if not BLOB_ID_RE.match(blob_id):
raise ValueError(f"bad blob id {blob_id!r}") raise ValueError(f"bad blob id {blob_id!r}")
return get_settings().blobs_dir / blob_id[:2] / blob_id[2:4] / blob_id return get_settings().blobs_dir / blob_id[:2] / blob_id[2:4] / blob_id
def _finalize(tmp: Path, digest: str, ext: str) -> tuple[str, int]: def _finalize(tmp: Path, digest: str, ext: str) -> tuple[str, int]:
blob_id = digest + ext.lower() blob_id = digest + ext.lower()
dest = blob_path(blob_id) dest = blob_path(blob_id)
size = tmp.stat().st_size size = tmp.stat().st_size
if dest.exists(): if dest.exists():
tmp.unlink() tmp.unlink()
else: else:
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
tmp.chmod(0o644) # mkstemp creates 0600; nginx serves blobs as another user tmp.chmod(0o644) # mkstemp creates 0600; nginx serves blobs as another user
os.replace(tmp, dest) os.replace(tmp, dest)
return blob_id, size return blob_id, size
def _tmp_file() -> Path: def _tmp_file() -> Path:
root = get_settings().blobs_dir / "tmp" root = get_settings().blobs_dir / "tmp"
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=root) fd, name = tempfile.mkstemp(dir=root)
os.close(fd) os.close(fd)
return Path(name) return Path(name)
def put_file(src: Path, ext: str, move: bool = False) -> tuple[str, int]: def put_file(src: Path, ext: str, move: bool = False) -> tuple[str, int]:
"""Store a file, returning (blob id, size). ``move`` consumes ``src``.""" """Store a file, returning (blob id, size). ``move`` consumes ``src``."""
h = hashlib.sha256() h = hashlib.sha256()
with src.open("rb") as f: with src.open("rb") as f:
while chunk := f.read(_CHUNK): while chunk := f.read(_CHUNK):
h.update(chunk) h.update(chunk)
tmp = _tmp_file() tmp = _tmp_file()
if move: if move:
shutil.move(src, tmp) shutil.move(src, tmp)
else: else:
shutil.copyfile(src, tmp) shutil.copyfile(src, tmp)
return _finalize(tmp, h.hexdigest(), ext) return _finalize(tmp, h.hexdigest(), ext)
def put_bytes(data: bytes, ext: str) -> tuple[str, int]: def put_bytes(data: bytes, ext: str) -> tuple[str, int]:
tmp = _tmp_file() tmp = _tmp_file()
tmp.write_bytes(data) tmp.write_bytes(data)
return _finalize(tmp, hashlib.sha256(data).hexdigest(), ext) return _finalize(tmp, hashlib.sha256(data).hexdigest(), ext)
def read_bytes(blob_id: str) -> bytes: def read_bytes(blob_id: str) -> bytes:
return blob_path(blob_id).read_bytes() return blob_path(blob_id).read_bytes()
+86 -86
View File
@@ -1,86 +1,86 @@
"""Media conversion into web-friendly formats.""" """Media conversion into web-friendly formats."""
import shutil import shutil
import subprocess import subprocess
import uuid import uuid
from pathlib import Path from pathlib import Path
from PIL import Image, ImageSequence from PIL import Image, ImageSequence
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tga", ".avif"} IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tga", ".avif"}
AUDIO_EXTS = {".ogg", ".mp3", ".wav", ".opus", ".flac", ".m4a", ".oga"} AUDIO_EXTS = {".ogg", ".mp3", ".wav", ".opus", ".flac", ".m4a", ".oga"}
VIDEO_EXTS = {".webm", ".ogv", ".mp4", ".mkv", ".avi", ".mpg", ".mpeg", ".m4v"} VIDEO_EXTS = {".webm", ".ogv", ".mp4", ".mkv", ".avi", ".mpg", ".mpeg", ".m4v"}
FONT_EXTS = {".ttf", ".otf", ".woff", ".woff2", ".ttc"} FONT_EXTS = {".ttf", ".otf", ".woff", ".woff2", ".ttc"}
WEBP_MAX = 16383 WEBP_MAX = 16383
def kind_of(path: Path) -> str | None: def kind_of(path: Path) -> str | None:
ext = path.suffix.lower() ext = path.suffix.lower()
if ext in IMAGE_EXTS: if ext in IMAGE_EXTS:
return "image" return "image"
if ext in AUDIO_EXTS: if ext in AUDIO_EXTS:
return "audio" return "audio"
if ext in VIDEO_EXTS: if ext in VIDEO_EXTS:
return "video" return "video"
if ext in FONT_EXTS: if ext in FONT_EXTS:
return "font" return "font"
return None return None
def target_ext(path: Path) -> str: def target_ext(path: Path) -> str:
kind = kind_of(path) kind = kind_of(path)
return {"image": ".webp", "audio": ".ogg", "video": ".webm"}.get(kind, path.suffix.lower()) return {"image": ".webp", "audio": ".ogg", "video": ".webm"}.get(kind, path.suffix.lower())
def convert(src: Path, out_dir: Path) -> tuple[Path, str]: def convert(src: Path, out_dir: Path) -> tuple[Path, str]:
"""Convert one file into out_dir. Returns (output file, extension used for the blob).""" """Convert one file into out_dir. Returns (output file, extension used for the blob)."""
kind = kind_of(src) kind = kind_of(src)
tmp = str(out_dir / uuid.uuid4().hex) tmp = str(out_dir / uuid.uuid4().hex)
if kind == "image": if kind == "image":
return _image(src, Path(tmp)) return _image(src, Path(tmp))
if kind == "audio": if kind == "audio":
return _ffmpeg(src, Path(tmp + ".ogg"), return _ffmpeg(src, Path(tmp + ".ogg"),
["-vn", "-c:a", "libopus", "-b:a", "112k", "-vbr", "on"]), ".ogg" ["-vn", "-c:a", "libopus", "-b:a", "112k", "-vbr", "on"]), ".ogg"
if kind == "video": if kind == "video":
if src.suffix.lower() == ".webm": if src.suffix.lower() == ".webm":
out = Path(tmp + ".webm") out = Path(tmp + ".webm")
shutil.copyfile(src, out) shutil.copyfile(src, out)
return out, ".webm" return out, ".webm"
return _ffmpeg(src, Path(tmp + ".webm"), return _ffmpeg(src, Path(tmp + ".webm"),
["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime", ["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime",
"-cpu-used", "8", "-row-mt", "1", "-c:a", "libopus", "-b:a", "112k"] "-cpu-used", "8", "-row-mt", "1", "-c:a", "libopus", "-b:a", "112k"]
), ".webm" ), ".webm"
out = Path(tmp + src.suffix.lower()) out = Path(tmp + src.suffix.lower())
shutil.copyfile(src, out) shutil.copyfile(src, out)
return out, src.suffix.lower() return out, src.suffix.lower()
def _image(src: Path, tmp: Path) -> tuple[Path, str]: def _image(src: Path, tmp: Path) -> tuple[Path, str]:
with Image.open(src) as img: with Image.open(src) as img:
animated = getattr(img, "is_animated", False) animated = getattr(img, "is_animated", False)
if max(img.size) > WEBP_MAX: if max(img.size) > WEBP_MAX:
out = tmp.with_suffix(".png") out = tmp.with_suffix(".png")
img.save(out, "PNG", optimize=False) img.save(out, "PNG", optimize=False)
return out, ".png" return out, ".png"
out = tmp.with_suffix(".webp") out = tmp.with_suffix(".webp")
if animated: if animated:
frames = [f.convert("RGBA") for f in ImageSequence.Iterator(img)] frames = [f.convert("RGBA") for f in ImageSequence.Iterator(img)]
frames[0].save(out, "WEBP", save_all=True, append_images=frames[1:], quality=85, frames[0].save(out, "WEBP", save_all=True, append_images=frames[1:], quality=85,
duration=img.info.get("duration", 100), loop=0) duration=img.info.get("duration", 100), loop=0)
return out, ".webp" return out, ".webp"
has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info) has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
img = img.convert("RGBA" if has_alpha else "RGB") img = img.convert("RGBA" if has_alpha else "RGB")
img.save(out, "WEBP", quality=88, method=4, alpha_quality=95) img.save(out, "WEBP", quality=88, method=4, alpha_quality=95)
return out, ".webp" return out, ".webp"
def _ffmpeg(src: Path, out: Path, args: list[str]) -> Path: def _ffmpeg(src: Path, out: Path, args: list[str]) -> Path:
proc = subprocess.run( proc = subprocess.run(
["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src), ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src),
*args, str(out)], *args, str(out)],
capture_output=True, text=True, timeout=3600, capture_output=True, text=True, timeout=3600,
) )
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(f"ffmpeg: {proc.stderr.strip()[-300:]}") raise RuntimeError(f"ffmpeg: {proc.stderr.strip()[-300:]}")
return out return out
+189 -189
View File
@@ -1,189 +1,189 @@
"""Translate the subset of Python used by visual novels into PGVN IR. """Translate the subset of Python used by visual novels into PGVN IR.
Expressions become JSON trees ``{"k": kind, ...}``; statements become Expressions become JSON trees ``{"k": kind, ...}``; statements become
``{"op": ..., ...}`` nodes executed by the player's JS interpreter. ``{"op": ..., ...}`` nodes executed by the player's JS interpreter.
Anything outside the subset raises :class:`Unsupported` so the caller can Anything outside the subset raises :class:`Unsupported` so the caller can
report it and degrade gracefully. report it and degrade gracefully.
""" """
import ast import ast
from typing import Any from typing import Any
Expr = dict[str, Any] Expr = dict[str, Any]
Stmt = dict[str, Any] Stmt = dict[str, Any]
BIN_OPS = { BIN_OPS = {
ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//", ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//",
ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^", ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^",
ast.LShift: "<<", ast.RShift: ">>", ast.LShift: "<<", ast.RShift: ">>",
} }
CMP_OPS = { CMP_OPS = {
ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=", ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=",
ast.In: "in", ast.NotIn: "not in", ast.Is: "is", ast.IsNot: "is not", ast.In: "in", ast.NotIn: "not in", ast.Is: "is", ast.IsNot: "is not",
} }
UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"} UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
# Modules whose import is harmless: the player provides the functions it supports. # Modules whose import is harmless: the player provides the functions it supports.
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"} KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"}
class Unsupported(Exception): class Unsupported(Exception):
pass pass
def const(v: Any) -> Expr: def const(v: Any) -> Expr:
return {"k": "const", "v": v} return {"k": "const", "v": v}
def parse_expr(source: str) -> Expr: def parse_expr(source: str) -> Expr:
try: try:
tree = ast.parse(source.strip(), mode="eval") tree = ast.parse(source.strip(), mode="eval")
except SyntaxError as e: except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
return expr(tree.body) return expr(tree.body)
def parse_stmts(source: str) -> list[Stmt]: def parse_stmts(source: str) -> list[Stmt]:
try: try:
tree = ast.parse(_dedent(source)) tree = ast.parse(_dedent(source))
except SyntaxError as e: except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
return stmts(tree.body) return stmts(tree.body)
def _dedent(source: str) -> str: def _dedent(source: str) -> str:
import textwrap import textwrap
return textwrap.dedent(source) return textwrap.dedent(source)
def literal(source: str) -> Any: def literal(source: str) -> Any:
"""Evaluate a constant expression at conversion time, or raise Unsupported.""" """Evaluate a constant expression at conversion time, or raise Unsupported."""
try: try:
return ast.literal_eval(source.strip()) return ast.literal_eval(source.strip())
except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e: except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e:
raise Unsupported(f"не константа: {source!r}") from e raise Unsupported(f"не константа: {source!r}") from e
def expr(node: ast.AST) -> Expr: def expr(node: ast.AST) -> Expr:
match node: match node:
case ast.Constant(value=v): case ast.Constant(value=v):
if isinstance(v, bytes): if isinstance(v, bytes):
v = v.decode("utf-8", "replace") v = v.decode("utf-8", "replace")
if isinstance(v, complex) or v is Ellipsis: if isinstance(v, complex) or v is Ellipsis:
raise Unsupported("неподдерживаемая константа") raise Unsupported("неподдерживаемая константа")
return const(v) return const(v)
case ast.Name(id=name): case ast.Name(id=name):
return {"k": "name", "id": name} return {"k": "name", "id": name}
case ast.Attribute(value=ast.Name(id="store"), attr=attr): case ast.Attribute(value=ast.Name(id="store"), attr=attr):
return {"k": "name", "id": attr} return {"k": "name", "id": attr}
case ast.Attribute(value=value, attr=attr): case ast.Attribute(value=value, attr=attr):
return {"k": "attr", "o": expr(value), "a": attr} return {"k": "attr", "o": expr(value), "a": attr}
case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)): case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)):
return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)} return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)}
case ast.Subscript(value=value, slice=index): case ast.Subscript(value=value, slice=index):
return {"k": "sub", "o": expr(value), "i": expr(index)} return {"k": "sub", "o": expr(value), "i": expr(index)}
case ast.BinOp(left=l, op=op, right=r): case ast.BinOp(left=l, op=op, right=r):
if type(op) not in BIN_OPS: if type(op) not in BIN_OPS:
raise Unsupported(f"оператор {type(op).__name__}") raise Unsupported(f"оператор {type(op).__name__}")
return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)} return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)}
case ast.BoolOp(op=op, values=values): case ast.BoolOp(op=op, values=values):
return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or", return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or",
"v": [expr(v) for v in values]} "v": [expr(v) for v in values]}
case ast.UnaryOp(op=op, operand=operand): case ast.UnaryOp(op=op, operand=operand):
return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)} return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)}
case ast.Compare(left=l, ops=ops, comparators=rs): case ast.Compare(left=l, ops=ops, comparators=rs):
return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops], return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops],
"r": [expr(r) for r in rs]} "r": [expr(r) for r in rs]}
case ast.IfExp(test=t, body=b, orelse=e): case ast.IfExp(test=t, body=b, orelse=e):
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)} return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
case ast.List(elts=elts) | ast.Set(elts=elts): case ast.List(elts=elts) | ast.Set(elts=elts):
return {"k": "list", "v": [expr(e) for e in elts]} return {"k": "list", "v": [expr(e) for e in elts]}
case ast.Tuple(elts=elts): case ast.Tuple(elts=elts):
return {"k": "tuple", "v": [expr(e) for e in elts]} return {"k": "tuple", "v": [expr(e) for e in elts]}
case ast.Dict(keys=keys, values=values): case ast.Dict(keys=keys, values=values):
if any(k is None for k in keys): if any(k is None for k in keys):
raise Unsupported("распаковка ** в словаре") raise Unsupported("распаковка ** в словаре")
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]} return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
case ast.Call(func=func, args=args, keywords=kws): case ast.Call(func=func, args=args, keywords=kws):
if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws): if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws):
raise Unsupported("распаковка аргументов *args/**kwargs") raise Unsupported("распаковка аргументов *args/**kwargs")
return {"k": "call", "f": expr(func), "a": [expr(a) for a in args], return {"k": "call", "f": expr(func), "a": [expr(a) for a in args],
"kw": {k.arg: expr(k.value) for k in kws}} "kw": {k.arg: expr(k.value) for k in kws}}
case ast.JoinedStr(values=values): case ast.JoinedStr(values=values):
parts: list[Any] = [] parts: list[Any] = []
for v in values: for v in values:
if isinstance(v, ast.Constant): if isinstance(v, ast.Constant):
parts.append(v.value) parts.append(v.value)
elif isinstance(v, ast.FormattedValue): elif isinstance(v, ast.FormattedValue):
parts.append(expr(v.value)) parts.append(expr(v.value))
return {"k": "fmt", "v": parts} return {"k": "fmt", "v": parts}
case (ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs, case (ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)]) is_async=0)])
| ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it, | ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it,
ifs=ifs, is_async=0)]) ifs=ifs, is_async=0)])
| ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs, | ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)])): is_async=0)])):
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it), return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
"if": [expr(i) for i in ifs]} "if": [expr(i) for i in ifs]}
case ast.Lambda(): case ast.Lambda():
raise Unsupported("lambda") raise Unsupported("lambda")
raise Unsupported(f"выражение {type(node).__name__}") raise Unsupported(f"выражение {type(node).__name__}")
def _opt(node: ast.AST | None) -> Expr | None: def _opt(node: ast.AST | None) -> Expr | None:
return None if node is None else expr(node) return None if node is None else expr(node)
def target(node: ast.AST) -> Expr: def target(node: ast.AST) -> Expr:
match node: match node:
case ast.Name() | ast.Attribute() | ast.Subscript(): case ast.Name() | ast.Attribute() | ast.Subscript():
return expr(node) return expr(node)
case ast.Tuple(elts=elts) | ast.List(elts=elts): case ast.Tuple(elts=elts) | ast.List(elts=elts):
return {"k": "tuple", "v": [target(e) for e in elts]} return {"k": "tuple", "v": [target(e) for e in elts]}
raise Unsupported(f"присваивание в {type(node).__name__}") raise Unsupported(f"присваивание в {type(node).__name__}")
def stmts(nodes: list[ast.stmt]) -> list[Stmt]: def stmts(nodes: list[ast.stmt]) -> list[Stmt]:
out: list[Stmt] = [] out: list[Stmt] = []
for n in nodes: for n in nodes:
out.extend(stmt(n)) out.extend(stmt(n))
return out return out
def stmt(node: ast.stmt) -> list[Stmt]: def stmt(node: ast.stmt) -> list[Stmt]:
match node: match node:
case ast.Assign(targets=targets, value=value): case ast.Assign(targets=targets, value=value):
v = expr(value) v = expr(value)
return [{"op": "set", "t": target(t), "v": v} for t in targets] return [{"op": "set", "t": target(t), "v": v} for t in targets]
case ast.AnnAssign(target=t, value=value) if value is not None: case ast.AnnAssign(target=t, value=value) if value is not None:
return [{"op": "set", "t": target(t), "v": expr(value)}] return [{"op": "set", "t": target(t), "v": expr(value)}]
case ast.AugAssign(target=t, op=op, value=value): case ast.AugAssign(target=t, op=op, value=value):
if type(op) not in BIN_OPS: if type(op) not in BIN_OPS:
raise Unsupported(f"оператор {type(op).__name__}") raise Unsupported(f"оператор {type(op).__name__}")
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}] return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
case ast.Expr(value=ast.Constant()): case ast.Expr(value=ast.Constant()):
return [] # docstring / bare literal return [] # docstring / bare literal
case ast.Expr(value=value): case ast.Expr(value=value):
return [{"op": "eval", "e": expr(value)}] return [{"op": "eval", "e": expr(value)}]
case ast.If(test=test, body=body, orelse=orelse): case ast.If(test=test, body=body, orelse=orelse):
return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}] return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}]
case ast.While(test=test, body=body, orelse=[]): case ast.While(test=test, body=body, orelse=[]):
return [{"op": "while", "c": expr(test), "body": stmts(body)}] return [{"op": "while", "c": expr(test), "body": stmts(body)}]
case ast.For(target=t, iter=it, body=body, orelse=[]): case ast.For(target=t, iter=it, body=body, orelse=[]):
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}] return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
case ast.Delete(targets=targets): case ast.Delete(targets=targets):
return [{"op": "del", "t": target(t)} for t in targets] return [{"op": "del", "t": target(t)} for t in targets]
case ast.Pass() | ast.Global() | ast.Nonlocal(): case ast.Pass() | ast.Global() | ast.Nonlocal():
return [] return []
case ast.Break(): case ast.Break():
return [{"op": "break"}] return [{"op": "break"}]
case ast.Continue(): case ast.Continue():
return [{"op": "continue"}] return [{"op": "continue"}]
case ast.Import(names=names) | ast.ImportFrom(names=names): case ast.Import(names=names) | ast.ImportFrom(names=names):
mod = getattr(node, "module", None) or names[0].name mod = getattr(node, "module", None) or names[0].name
if mod.split(".")[0] in KNOWN_MODULES: if mod.split(".")[0] in KNOWN_MODULES:
return [] return []
raise Unsupported(f"импорт модуля {mod}") raise Unsupported(f"импорт модуля {mod}")
case ast.FunctionDef(name=name) | ast.ClassDef(name=name): case ast.FunctionDef(name=name) | ast.ClassDef(name=name):
raise Unsupported(f"определение {name}()") raise Unsupported(f"определение {name}()")
raise Unsupported(f"оператор {type(node).__name__}") raise Unsupported(f"оператор {type(node).__name__}")
+109 -109
View File
@@ -1,109 +1,109 @@
"""Reader for Ren'Py .rpa archives (RPA-2.0, RPA-3.x and common variants). """Reader for Ren'Py .rpa archives (RPA-2.0, RPA-3.x and common variants).
The index is a zlib-compressed pickle, so it is loaded with a restricted The index is a zlib-compressed pickle, so it is loaded with a restricted
unpickler that only allows plain containers — archives are untrusted input. unpickler that only allows plain containers — archives are untrusted input.
""" """
import io import io
import pickle import pickle
import zlib import zlib
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
class RpaError(Exception): class RpaError(Exception):
pass pass
class _SafeUnpickler(pickle.Unpickler): class _SafeUnpickler(pickle.Unpickler):
_ALLOWED = {("_codecs", "encode"), ("builtins", "set"), ("builtins", "frozenset"), _ALLOWED = {("_codecs", "encode"), ("builtins", "set"), ("builtins", "frozenset"),
("__builtin__", "set"), ("builtins", "bytearray"), ("__builtin__", "bytearray"), ("__builtin__", "set"), ("builtins", "bytearray"), ("__builtin__", "bytearray"),
("builtins", "bytes"), ("__builtin__", "bytes")} ("builtins", "bytes"), ("__builtin__", "bytes")}
def find_class(self, module, name): def find_class(self, module, name):
if (module, name) in self._ALLOWED: if (module, name) in self._ALLOWED:
import builtins import builtins
import codecs import codecs
return codecs.encode if module == "_codecs" else getattr(builtins, name) return codecs.encode if module == "_codecs" else getattr(builtins, name)
raise pickle.UnpicklingError(f"запрещённый тип в индексе RPA: {module}.{name}") raise pickle.UnpicklingError(f"запрещённый тип в индексе RPA: {module}.{name}")
def _text(v) -> str: def _text(v) -> str:
return v.decode("utf-8", "replace") if isinstance(v, bytes) else str(v) return v.decode("utf-8", "replace") if isinstance(v, bytes) else str(v)
def read_index(path: Path) -> dict[str, list[tuple[int, int, bytes]]]: def read_index(path: Path) -> dict[str, list[tuple[int, int, bytes]]]:
with path.open("rb") as f: with path.open("rb") as f:
header = f.readline(200).decode("latin-1").strip() header = f.readline(200).decode("latin-1").strip()
parts = header.split() parts = header.split()
if not parts or not parts[0].startswith("RPA-"): if not parts or not parts[0].startswith("RPA-"):
raise RpaError(f"{path.name}: неизвестный формат архива ({header[:20]!r})") raise RpaError(f"{path.name}: неизвестный формат архива ({header[:20]!r})")
version = parts[0] version = parts[0]
try: try:
offset = int(parts[1], 16) offset = int(parts[1], 16)
key = 0 key = 0
if version != "RPA-2.0": if version != "RPA-2.0":
for token in parts[2:]: for token in parts[2:]:
key ^= int(token, 16) key ^= int(token, 16)
except (IndexError, ValueError) as e: except (IndexError, ValueError) as e:
raise RpaError(f"{path.name}: повреждён заголовок") from e raise RpaError(f"{path.name}: повреждён заголовок") from e
f.seek(offset) f.seek(offset)
try: try:
raw = _SafeUnpickler(io.BytesIO(zlib.decompress(f.read())), encoding="bytes").load() raw = _SafeUnpickler(io.BytesIO(zlib.decompress(f.read())), encoding="bytes").load()
except (zlib.error, pickle.UnpicklingError, EOFError) as e: except (zlib.error, pickle.UnpicklingError, EOFError) as e:
raise RpaError(f"{path.name}: не удалось прочитать индекс: {e}") from e raise RpaError(f"{path.name}: не удалось прочитать индекс: {e}") from e
index: dict[str, list[tuple[int, int, bytes]]] = {} index: dict[str, list[tuple[int, int, bytes]]] = {}
for name, entries in raw.items(): for name, entries in raw.items():
fixed = [] fixed = []
for entry in entries: for entry in entries:
off, length = entry[0] ^ key, entry[1] ^ key off, length = entry[0] ^ key, entry[1] ^ key
prefix = entry[2] if len(entry) > 2 else b"" prefix = entry[2] if len(entry) > 2 else b""
if isinstance(prefix, str): if isinstance(prefix, str):
prefix = prefix.encode("latin-1") prefix = prefix.encode("latin-1")
fixed.append((off, length, prefix)) fixed.append((off, length, prefix))
index[_text(name)] = fixed index[_text(name)] = fixed
return index return index
def extract_rpa(path: Path, dest: Path, only: Callable[[str], bool] | None = None) -> int: def extract_rpa(path: Path, dest: Path, only: Callable[[str], bool] | None = None) -> int:
"""Extract files of an archive into dest (all, or those `only` accepts).""" """Extract files of an archive into dest (all, or those `only` accepts)."""
index = read_index(path) index = read_index(path)
root = dest.resolve() root = dest.resolve()
count = 0 count = 0
with path.open("rb") as f: with path.open("rb") as f:
for name, entries in index.items(): for name, entries in index.items():
if only is not None and not only(name): if only is not None and not only(name):
continue continue
target = (dest / name).resolve() target = (dest / name).resolve()
if not target.is_relative_to(root): if not target.is_relative_to(root):
continue continue
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
with target.open("wb") as out: with target.open("wb") as out:
for off, length, prefix in entries: for off, length, prefix in entries:
out.write(prefix) out.write(prefix)
f.seek(off) f.seek(off)
remaining = length - len(prefix) remaining = length - len(prefix)
while remaining > 0: while remaining > 0:
chunk = f.read(min(remaining, 1 << 20)) chunk = f.read(min(remaining, 1 << 20))
if not chunk: if not chunk:
break break
out.write(chunk) out.write(chunk)
remaining -= len(chunk) remaining -= len(chunk)
count += 1 count += 1
return count return count
def write_rpa(path: Path, files: dict[str, bytes], key: int = 0x42424242) -> None: def write_rpa(path: Path, files: dict[str, bytes], key: int = 0x42424242) -> None:
"""Write an RPA-3.0 archive (used by tests).""" """Write an RPA-3.0 archive (used by tests)."""
with path.open("wb") as f: with path.open("wb") as f:
f.write(b"RPA-3.0 " + b"0" * 16 + b" " + b"0" * 8 + b"\n") f.write(b"RPA-3.0 " + b"0" * 16 + b" " + b"0" * 8 + b"\n")
index = {} index = {}
for name, data in files.items(): for name, data in files.items():
off = f.tell() off = f.tell()
f.write(data) f.write(data)
index[name] = [(off ^ key, len(data) ^ key, b"")] index[name] = [(off ^ key, len(data) ^ key, b"")]
idx_off = f.tell() idx_off = f.tell()
f.write(zlib.compress(pickle.dumps(index, protocol=2))) f.write(zlib.compress(pickle.dumps(index, protocol=2)))
f.seek(0) f.seek(0)
f.write(f"RPA-3.0 {idx_off:016x} {key:08x}\n".encode()) f.write(f"RPA-3.0 {idx_off:016x} {key:08x}\n".encode())
+48 -48
View File
@@ -1,48 +1,48 @@
"""Import an already unpacked game from a folder on the server. """Import an already unpacked game from a folder on the server.
python -m app.import_dir /games/Some-Game [--title "Name"] [--owner admin] python -m app.import_dir /games/Some-Game [--title "Name"] [--owner admin]
The folder is only read; the converted copy goes to the blob store like an upload. The folder is only read; the converted copy goes to the blob store like an upload.
""" """
import argparse import argparse
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from app.db import SessionLocal from app.db import SessionLocal
from app.models import Game, GameVersion, Role, User from app.models import Game, GameVersion, Role, User
from app.worker import convert from app.worker import convert
async def main() -> None: async def main() -> None:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("path", type=Path) ap.add_argument("path", type=Path)
ap.add_argument("--title") ap.add_argument("--title")
ap.add_argument("--owner", help="username; defaults to the first admin") ap.add_argument("--owner", help="username; defaults to the first admin")
args = ap.parse_args() args = ap.parse_args()
if not args.path.is_dir(): if not args.path.is_dir():
raise SystemExit(f"{args.path} is not a directory") raise SystemExit(f"{args.path} is not a directory")
async with SessionLocal() as db: async with SessionLocal() as db:
stmt = select(User).join(Role).order_by(User.id) stmt = select(User).join(Role).order_by(User.id)
stmt = stmt.where(User.username == args.owner) if args.owner else stmt.where(Role.name == "admin") stmt = stmt.where(User.username == args.owner) if args.owner else stmt.where(Role.name == "admin")
owner = await db.scalar(stmt.limit(1)) owner = await db.scalar(stmt.limit(1))
if owner is None: if owner is None:
raise SystemExit("owner not found") raise SystemExit("owner not found")
game = Game(title=args.title or args.path.name, created_by_id=owner.id) game = Game(title=args.title or args.path.name, created_by_id=owner.id)
db.add(game) db.add(game)
await db.flush() await db.flush()
version = GameVersion(game_id=game.id, owner_id=owner.id, name="Оригинал", version = GameVersion(game_id=game.id, owner_id=owner.id, name="Оригинал",
upload_name=args.path.name, status="importing") upload_name=args.path.name, status="importing")
db.add(version) db.add(version)
await db.commit() await db.commit()
game_id, version_id = game.id, version.id game_id, version_id = game.id, version.id
ok = await convert(version_id, args.path, args.path.name) ok = await convert(version_id, args.path, args.path.name)
print(f"game {game_id}, version {version_id}: {'ready' if ok else 'failed'}") print(f"game {game_id}, version {version_id}: {'ready' if ok else 'failed'}")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
+41 -41
View File
@@ -1,41 +1,41 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.blobs import BLOB_ID_RE, blob_path from app.blobs import BLOB_ID_RE, blob_path
from app.bootstrap import bootstrap from app.bootstrap import bootstrap
from app.config import get_settings from app.config import get_settings
from app.db import SessionLocal from app.db import SessionLocal
from app.routers import auth, games, roles, users, versions from app.routers import auth, games, roles, users, versions
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
if get_settings().secret_key == "change-me": if get_settings().secret_key == "change-me":
logging.getLogger(__name__).warning("SECRET_KEY is the default value — set it in .env") logging.getLogger(__name__).warning("SECRET_KEY is the default value — set it in .env")
async with SessionLocal() as db: async with SessionLocal() as db:
await bootstrap(db) await bootstrap(db)
yield yield
app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs", app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs",
openapi_url="/api/openapi.json") openapi_url="/api/openapi.json")
for r in (auth, users, roles, games, versions): for r in (auth, users, roles, games, versions):
app.include_router(r.router) app.include_router(r.router)
@app.get("/api/health") @app.get("/api/health")
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get("/blobs/{blob_id}") @app.get("/blobs/{blob_id}")
async def blob(blob_id: str) -> FileResponse: async def blob(blob_id: str) -> FileResponse:
"""Fallback for running without nginx; in compose nginx serves /blobs itself.""" """Fallback for running without nginx; in compose nginx serves /blobs itself."""
if not BLOB_ID_RE.match(blob_id) or not (path := blob_path(blob_id)).is_file(): if not BLOB_ID_RE.match(blob_id) or not (path := blob_path(blob_id)).is_file():
raise HTTPException(404) raise HTTPException(404)
return FileResponse(path, headers={"Cache-Control": "public, max-age=31536000, immutable"}) return FileResponse(path, headers={"Cache-Control": "public, max-age=31536000, immutable"})
+99 -99
View File
@@ -1,99 +1,99 @@
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import ( from sqlalchemy import (
ARRAY, ARRAY,
BigInteger, BigInteger,
Boolean, Boolean,
DateTime, DateTime,
ForeignKey, ForeignKey,
Integer, Integer,
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
func, func,
) )
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base from app.db import Base
class Role(Base): class Role(Base):
__tablename__ = "roles" __tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True) name: Mapped[str] = mapped_column(String(64), unique=True)
permissions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) permissions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
is_system: Mapped[bool] = mapped_column(Boolean, default=False) is_system: Mapped[bool] = mapped_column(Boolean, default=False)
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True) username: Mapped[str] = mapped_column(String(64), unique=True)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True) must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_username: Mapped[bool] = mapped_column(Boolean, default=False) must_change_username: Mapped[bool] = mapped_column(Boolean, default=False)
token_version: Mapped[int] = mapped_column(Integer, default=0) token_version: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
role: Mapped[Role] = relationship(lazy="joined") role: Mapped[Role] = relationship(lazy="joined")
@property @property
def permissions(self) -> list[str]: def permissions(self) -> list[str]:
return sorted(self.role.permissions) if self.is_active else [] return sorted(self.role.permissions) if self.is_active else []
class Game(Base): class Game(Base):
__tablename__ = "games" __tablename__ = "games"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
engine: Mapped[str] = mapped_column(String(32), default="renpy") engine: Mapped[str] = mapped_column(String(32), default="renpy")
cover_hash: Mapped[str | None] = mapped_column(String(80)) cover_hash: Mapped[str | None] = mapped_column(String(80))
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL")) created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_by: Mapped[User | None] = relationship(lazy="joined") created_by: Mapped[User | None] = relationship(lazy="joined")
class GameVersion(Base): class GameVersion(Base):
__tablename__ = "game_versions" __tablename__ = "game_versions"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True) game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("game_versions.id", ondelete="SET NULL")) parent_id: Mapped[int | None] = mapped_column(ForeignKey("game_versions.id", ondelete="SET NULL"))
owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL")) owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
name: Mapped[str] = mapped_column(String(255)) name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
# uploading -> queued -> converting -> ready | failed; importing = app.import_dir run # uploading -> queued -> converting -> ready | failed; importing = app.import_dir run
status: Mapped[str] = mapped_column(String(16), default="uploading", index=True) status: Mapped[str] = mapped_column(String(16), default="uploading", index=True)
progress: Mapped[str] = mapped_column(String(255), default="") progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255)) upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB) manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
report: Mapped[dict[str, Any] | None] = mapped_column(JSONB) report: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0) size_bytes: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
owner: Mapped[User | None] = relationship(lazy="joined") owner: Mapped[User | None] = relationship(lazy="joined")
class Save(Base): class Save(Base):
__tablename__ = "saves" __tablename__ = "saves"
__table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),) __table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE")) version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE"))
slot: Mapped[str] = mapped_column(String(32)) slot: Mapped[str] = mapped_column(String(32))
title: Mapped[str] = mapped_column(String(255), default="") title: Mapped[str] = mapped_column(String(255), default="")
data: Mapped[dict[str, Any]] = mapped_column(JSONB) data: Mapped[dict[str, Any]] = mapped_column(JSONB)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) )
+205 -205
View File
@@ -1,205 +1,205 @@
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from starlette.requests import ClientDisconnect from starlette.requests import ClientDisconnect
from app.deps import DB, CurrentUser, has_perm, require from app.deps import DB, CurrentUser, has_perm, require
from app.models import GameVersion, Save, User from app.models import GameVersion, Save, User
from app.routers.games import get_game from app.routers.games import get_game
from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut
from app.uploads import remove_upload, upload_path from app.uploads import remove_upload, upload_path
router = APIRouter(prefix="/api", tags=["versions"]) router = APIRouter(prefix="/api", tags=["versions"])
Player = Annotated[User, Depends(require("games.play"))] Player = Annotated[User, Depends(require("games.play"))]
Uploader = Annotated[User, Depends(require("games.upload"))] Uploader = Annotated[User, Depends(require("games.upload"))]
ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz") ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")
def can_see(user: User, v: GameVersion) -> bool: def can_see(user: User, v: GameVersion) -> bool:
return v.status == "ready" or v.owner_id == user.id or can_manage(user, v) return v.status == "ready" or v.owner_id == user.id or can_manage(user, v)
def can_manage(user: User, v: GameVersion) -> bool: def can_manage(user: User, v: GameVersion) -> bool:
return ( return (
v.owner_id == user.id v.owner_id == user.id
or has_perm(user, "versions.manage_any") or has_perm(user, "versions.manage_any")
or has_perm(user, "games.delete") or has_perm(user, "games.delete")
) )
async def get_version(db: DB, user: User, version_id: int) -> GameVersion: async def get_version(db: DB, user: User, version_id: int) -> GameVersion:
v = await db.get(GameVersion, version_id) v = await db.get(GameVersion, version_id)
if not v or not can_see(user, v): if not v or not can_see(user, v):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена") raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
return v return v
@router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201) @router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201)
async def upload_version( async def upload_version(
game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал" game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал"
) -> GameVersion: ) -> GameVersion:
"""Stream a raw archive body to disk and queue it for conversion.""" """Stream a raw archive body to disk and queue it for conversion."""
game = await get_game(db, game_id) game = await get_game(db, game_id)
if not filename.lower().endswith(ARCHIVE_EXTS): if not filename.lower().endswith(ARCHIVE_EXTS):
raise HTTPException(status.HTTP_400_BAD_REQUEST, raise HTTPException(status.HTTP_400_BAD_REQUEST,
f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}") f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}")
version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал", version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал",
upload_name=filename, status="uploading") upload_name=filename, status="uploading")
db.add(version) db.add(version)
await db.commit() await db.commit()
path = upload_path(version.id) path = upload_path(version.id)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
size = 0 size = 0
try: try:
with path.open("wb") as f: with path.open("wb") as f:
async for chunk in request.stream(): async for chunk in request.stream():
f.write(chunk) f.write(chunk)
size += len(chunk) size += len(chunk)
except Exception as e: except Exception as e:
remove_upload(version.id) remove_upload(version.id)
await db.delete(version) await db.delete(version)
await db.commit() await db.commit()
if isinstance(e, ClientDisconnect): if isinstance(e, ClientDisconnect):
# usually a proxy in front cut the body off (e.g. its own 413) # usually a proxy in front cut the body off (e.g. its own 413)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Загрузка прервана") from None raise HTTPException(status.HTTP_400_BAD_REQUEST, "Загрузка прервана") from None
raise raise
if size == 0: if size == 0:
remove_upload(version.id) remove_upload(version.id)
await db.delete(version) await db.delete(version)
await db.commit() await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл")
version.status = "queued" version.status = "queued"
version.progress = "В очереди на конвертацию" version.progress = "В очереди на конвертацию"
version.size_bytes = size version.size_bytes = size
await db.commit() await db.commit()
return version return version
@router.get("/versions/{version_id}", response_model=VersionDetailOut) @router.get("/versions/{version_id}", response_model=VersionDetailOut)
async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion: async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion:
return await get_version(db, user, version_id) return await get_version(db, user, version_id)
@router.get("/versions/{version_id}/manifest") @router.get("/versions/{version_id}/manifest")
async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]: async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest: if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return v.manifest return v.manifest
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201) @router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
async def fork_version(version_id: int, body: VersionIn, async def fork_version(version_id: int, body: VersionIn,
user: Annotated[User, Depends(require("games.play", "versions.fork"))], user: Annotated[User, Depends(require("games.play", "versions.fork"))],
db: DB) -> GameVersion: db: DB) -> GameVersion:
parent = await get_version(db, user, version_id) parent = await get_version(db, user, version_id)
if parent.status != "ready": if parent.status != "ready":
raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию") raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию")
fork = GameVersion( fork = GameVersion(
game_id=parent.game_id, game_id=parent.game_id,
parent_id=parent.id, parent_id=parent.id,
owner_id=user.id, owner_id=user.id,
name=body.name.strip(), name=body.name.strip(),
description=body.description, description=body.description,
status="ready", status="ready",
manifest=parent.manifest, manifest=parent.manifest,
report=parent.report, report=parent.report,
size_bytes=parent.size_bytes, size_bytes=parent.size_bytes,
) )
db.add(fork) db.add(fork)
await db.commit() await db.commit()
return fork return fork
@router.patch("/versions/{version_id}", response_model=VersionOut) @router.patch("/versions/{version_id}", response_model=VersionOut)
async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion: async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
if not can_manage(user, v): if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
v.name = body.name.strip() v.name = body.name.strip()
v.description = body.description v.description = body.description
await db.commit() await db.commit()
return v return v
@router.post("/versions/{version_id}/retry", response_model=VersionOut) @router.post("/versions/{version_id}/retry", response_model=VersionOut)
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion: async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
if not can_manage(user, v): if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status != "failed" or not upload_path(v.id).exists(): if v.status != "failed" or not upload_path(v.id).exists():
raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна") raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна")
v.status = "queued" v.status = "queued"
v.progress = "В очереди на конвертацию" v.progress = "В очереди на конвертацию"
await db.commit() await db.commit()
return v return v
@router.delete("/versions/{version_id}", status_code=204) @router.delete("/versions/{version_id}", status_code=204)
async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None: async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
if not can_manage(user, v): if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status in ("converting", "importing"): if v.status in ("converting", "importing"):
raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется") raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется")
await db.delete(v) await db.delete(v)
await db.commit() await db.commit()
remove_upload(version_id) remove_upload(version_id)
# --- saves ----------------------------------------------------------------- # --- saves -----------------------------------------------------------------
def _check_slot(slot: str) -> str: 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): 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, "Неверный слот") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот")
return slot return slot
@router.get("/versions/{version_id}/saves", response_model=list[SaveOut]) @router.get("/versions/{version_id}/saves", response_model=list[SaveOut])
async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]: async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]:
await get_version(db, user, version_id) await get_version(db, user, version_id)
return list((await db.scalars( return list((await db.scalars(
select(Save).where(Save.user_id == user.id, Save.version_id == version_id) select(Save).where(Save.user_id == user.id, Save.version_id == version_id)
.order_by(Save.updated_at.desc()) .order_by(Save.updated_at.desc())
)).all()) )).all())
async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None: async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None:
return await db.scalar(select(Save).where( return await db.scalar(select(Save).where(
Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot))) 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) @router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut)
async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save: async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save:
save = await _find_save(db, user, version_id, slot) save = await _find_save(db, user, version_id, slot)
if not save: if not save:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено") raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено")
return save return save
@router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut) @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: async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: DB) -> Save:
await get_version(db, user, version_id) await get_version(db, user, version_id)
save = await _find_save(db, user, version_id, slot) save = await _find_save(db, user, version_id, slot)
if save is None: if save is None:
save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255], save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255],
data=body.data) data=body.data)
db.add(save) db.add(save)
else: else:
save.title = body.title[:255] save.title = body.title[:255]
save.data = body.data save.data = body.data
await db.commit() await db.commit()
await db.refresh(save) await db.refresh(save)
return save return save
@router.delete("/versions/{version_id}/saves/{slot}", status_code=204) @router.delete("/versions/{version_id}/saves/{slot}", status_code=204)
async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None: async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None:
save = await _find_save(db, user, version_id, slot) save = await _find_save(db, user, version_id, slot)
if save: if save:
await db.delete(save) await db.delete(save)
await db.commit() await db.commit()
+116 -116
View File
@@ -1,116 +1,116 @@
"""Conversion worker: polls the DB for queued versions and converts them.""" """Conversion worker: polls the DB for queued versions and converts them."""
import asyncio import asyncio
import logging import logging
import shutil import shutil
from pathlib import Path from pathlib import Path
from sqlalchemy import select, update from sqlalchemy import select, update
from app.blobs import read_bytes from app.blobs import read_bytes
from app.config import get_settings from app.config import get_settings
from app.converter.pipeline import convert_archive from app.converter.pipeline import convert_archive
from app.db import SessionLocal from app.db import SessionLocal
from app.models import Game, GameVersion from app.models import Game, GameVersion
from app.routers.games import make_cover from app.routers.games import make_cover
from app.uploads import remove_upload, upload_path from app.uploads import remove_upload, upload_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("worker") log = logging.getLogger("worker")
async def set_progress(version_id: int, text: str) -> None: async def set_progress(version_id: int, text: str) -> None:
async with SessionLocal() as db: async with SessionLocal() as db:
await db.execute(update(GameVersion).where(GameVersion.id == version_id) await db.execute(update(GameVersion).where(GameVersion.id == version_id)
.values(progress=text[:255])) .values(progress=text[:255]))
await db.commit() await db.commit()
async def claim() -> GameVersion | None: async def claim() -> GameVersion | None:
async with SessionLocal() as db: async with SessionLocal() as db:
version_id = await db.scalar( version_id = await db.scalar(
select(GameVersion.id).where(GameVersion.status == "queued").order_by(GameVersion.id) select(GameVersion.id).where(GameVersion.status == "queued").order_by(GameVersion.id)
.limit(1).with_for_update(skip_locked=True) .limit(1).with_for_update(skip_locked=True)
) )
if version_id is None: if version_id is None:
return None return None
v = await db.get(GameVersion, version_id) v = await db.get(GameVersion, version_id)
v.status = "converting" v.status = "converting"
v.progress = "Начало конвертации" v.progress = "Начало конвертации"
await db.commit() await db.commit()
return v return v
async def process(v: GameVersion) -> None: async def process(v: GameVersion) -> None:
ok = await convert(v.id, upload_path(v.id), v.upload_name or "") ok = await convert(v.id, upload_path(v.id), v.upload_name or "")
if ok: if ok:
remove_upload(v.id) # sources are not kept once converted remove_upload(v.id) # sources are not kept once converted
async def convert(version_id: int, source: Path, filename: str) -> bool: async def convert(version_id: int, source: Path, filename: str) -> bool:
"""Run the converter for a version and store the outcome. Returns success.""" """Run the converter for a version and store the outcome. Returns success."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
work = get_settings().work_dir / str(version_id) work = get_settings().work_dir / str(version_id)
def progress(text: str) -> None: def progress(text: str) -> None:
asyncio.run_coroutine_threadsafe(set_progress(version_id, text), loop) asyncio.run_coroutine_threadsafe(set_progress(version_id, text), loop)
log.info("converting version %s (%s)", version_id, filename) log.info("converting version %s (%s)", version_id, filename)
try: try:
result = await asyncio.to_thread(convert_archive, source, filename, work, progress) result = await asyncio.to_thread(convert_archive, source, filename, work, progress)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log.exception("version %s failed", version_id) log.exception("version %s failed", version_id)
async with SessionLocal() as db: async with SessionLocal() as db:
version = await db.get(GameVersion, version_id) version = await db.get(GameVersion, version_id)
if version: if version:
version.status = "failed" version.status = "failed"
version.progress = f"Ошибка: {e}"[:255] version.progress = f"Ошибка: {e}"[:255]
version.report = {"error": str(e)} version.report = {"error": str(e)}
await db.commit() await db.commit()
return False return False
finally: finally:
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
async with SessionLocal() as db: async with SessionLocal() as db:
version = await db.get(GameVersion, version_id) version = await db.get(GameVersion, version_id)
if version is None: # deleted while converting if version is None: # deleted while converting
return True return True
version.status = "ready" version.status = "ready"
version.progress = "" version.progress = ""
version.manifest = result.manifest version.manifest = result.manifest
version.report = result.report version.report = result.report
version.size_bytes = result.size version.size_bytes = result.size
game = await db.get(Game, version.game_id) game = await db.get(Game, version.game_id)
if game and not game.cover_hash and result.cover_source: if game and not game.cover_hash and result.cover_source:
try: try:
game.cover_hash = await asyncio.to_thread(make_cover, game.cover_hash = await asyncio.to_thread(make_cover,
read_bytes(result.cover_source)) read_bytes(result.cover_source))
except OSError: except OSError:
pass pass
if game and result.title and game.title == source.name: if game and result.title and game.title == source.name:
game.title = result.title game.title = result.title
await db.commit() await db.commit()
log.info("version %s ready: %s", version_id, result.report.get("media")) log.info("version %s ready: %s", version_id, result.report.get("media"))
return True return True
async def main() -> None: async def main() -> None:
settings = get_settings() settings = get_settings()
for d in (settings.blobs_dir, settings.uploads_dir, settings.work_dir): for d in (settings.blobs_dir, settings.uploads_dir, settings.work_dir):
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
async with SessionLocal() as db: async with SessionLocal() as db:
# jobs interrupted by a restart go back to the queue # jobs interrupted by a restart go back to the queue
await db.execute(update(GameVersion).where(GameVersion.status == "converting") await db.execute(update(GameVersion).where(GameVersion.status == "converting")
.values(status="queued")) .values(status="queued"))
await db.commit() await db.commit()
log.info("worker started") log.info("worker started")
while True: while True:
v = await claim() v = await claim()
if v is None: if v is None:
await asyncio.sleep(settings.worker_poll_seconds) await asyncio.sleep(settings.worker_poll_seconds)
continue continue
await process(v) await process(v)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
+233 -233
View File
@@ -1,233 +1,233 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api"; import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
const STATUS: Record<Version["status"], string> = { const STATUS: Record<Version["status"], string> = {
uploading: "загружается", uploading: "загружается",
queued: "в очереди", queued: "в очереди",
converting: "конвертируется", converting: "конвертируется",
importing: "импортируется", importing: "импортируется",
ready: "готова", ready: "готова",
failed: "ошибка", failed: "ошибка",
}; };
export function GamePage() { export function GamePage() {
const { gameId } = useParams(); const { gameId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { me, can } = useAuth(); const { me, can } = useAuth();
const [game, setGame] = useState<GameDetail | null>(null); const [game, setGame] = useState<GameDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
setGame(await api.get<GameDetail>(`/api/games/${gameId}`)); setGame(await api.get<GameDetail>(`/api/games/${gameId}`));
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} }
}, [gameId]); }, [gameId]);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
// poll while something is being converted // poll while something is being converted
const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status)); const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status));
useEffect(() => { useEffect(() => {
if (!busy) return; if (!busy) return;
const t = setInterval(() => void load(), 2000); const t = setInterval(() => void load(), 2000);
return () => clearInterval(t); return () => clearInterval(t);
}, [busy, load]); }, [busy, load]);
if (error) return <div className="page"><div className="error">{error}</div></div>; if (error) return <div className="page"><div className="error">{error}</div></div>;
if (!game) return <div className="page muted">Загрузка…</div>; if (!game) return <div className="page muted">Загрузка…</div>;
// the backend also lets uploaders edit only games they created // the backend also lets uploaders edit only games they created
const canEdit = can("games.edit") || can("games.upload"); const canEdit = can("games.edit") || can("games.upload");
const run = async (fn: () => Promise<unknown>) => { const run = async (fn: () => Promise<unknown>) => {
setError(""); setError("");
try { try {
await fn(); await fn();
await load(); await load();
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} }
}; };
const fork = (v: Version) => { const fork = (v: Version) => {
const name = prompt("Название новой версии", `${v.name} (${me?.username})`); const name = prompt("Название новой версии", `${v.name} (${me?.username})`);
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name })); if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }));
}; };
const uploadCover = (file: File) => const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {})); run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}));
const uploadVersion = (file: File) => { const uploadVersion = (file: File) => {
const name = prompt("Название версии", file.name); const name = prompt("Название версии", file.name);
if (name) if (name)
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`, void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
file, () => {})); file, () => {}));
}; };
const byParent = new Map<number | null, Version[]>(); const byParent = new Map<number | null, Version[]>();
for (const v of game.versions) { for (const v of game.versions) {
const parent = game.versions.some((p) => p.id === v.parent_id) ? v.parent_id : null; const parent = game.versions.some((p) => p.id === v.parent_id) ? v.parent_id : null;
byParent.set(parent, [...(byParent.get(parent) ?? []), v]); byParent.set(parent, [...(byParent.get(parent) ?? []), v]);
} }
const renderTree = (parent: number | null, depth: number): JSX.Element[] => const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [ (byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")} <VersionRow key={v.id} v={v} depth={depth} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)} canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))} onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />, onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />,
...renderTree(v.id, depth + 1), ...renderTree(v.id, depth + 1),
]); ]);
return ( return (
<div className="page"> <div className="page">
<Link to="/" className="muted">← Библиотека</Link> <Link to="/" className="muted">← Библиотека</Link>
<div className="game-head"> <div className="game-head">
<div className="cover big"> <div className="cover big">
{game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>} {game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>}
</div> </div>
<div className="game-info"> <div className="game-info">
{editing ? ( {editing ? (
<GameEditor game={game} onDone={() => { setEditing(false); void load(); }} /> <GameEditor game={game} onDone={() => { setEditing(false); void load(); }} />
) : ( ) : (
<> <>
<h1>{game.title}</h1> <h1>{game.title}</h1>
<div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div> <div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div>
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p> <p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
<div className="actions"> <div className="actions">
{canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>} {canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>}
{canEdit && ( {canEdit && (
<label className="button"> <label className="button">
Обложка Обложка
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} /> <input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
</label> </label>
)} )}
{can("games.upload") && ( {can("games.upload") && (
<label className="button"> <label className="button">
Загрузить ещё архив Загрузить ещё архив
<input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} /> <input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} />
</label> </label>
)} )}
{can("games.delete") && ( {can("games.delete") && (
<button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") && <button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") &&
void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button> void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button>
)} )}
</div> </div>
</> </>
)} )}
</div> </div>
</div> </div>
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
<h2>Версии</h2> <h2>Версии</h2>
<div className="versions">{renderTree(null, 0)}</div> <div className="versions">{renderTree(null, 0)}</div>
</div> </div>
); );
} }
function VersionRow(props: { function VersionRow(props: {
v: Version; depth: number; canManage: boolean; canFork: boolean; v: Version; depth: number; canManage: boolean; canFork: boolean;
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void; onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
}) { }) {
const { v } = props; const { v } = props;
const [report, setReport] = useState<Version["report"] | undefined>(undefined); const [report, setReport] = useState<Version["report"] | undefined>(undefined);
const toggleReport = async () => { const toggleReport = async () => {
if (report !== undefined) setReport(undefined); if (report !== undefined) setReport(undefined);
else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null); else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null);
}; };
return ( return (
<div className="version" style={{ marginLeft: props.depth * 24 }}> <div className="version" style={{ marginLeft: props.depth * 24 }}>
<div className="version-main"> <div className="version-main">
<div> <div>
<b>{props.depth > 0 && "↳ "}{v.name}</b> <b>{props.depth > 0 && "↳ "}{v.name}</b>
<span className={`status status-${v.status}`}>{STATUS[v.status]}</span> <span className={`status status-${v.status}`}>{STATUS[v.status]}</span>
<div className="muted small"> <div className="muted small">
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)} {v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`} {v.progress && ` · ${v.progress}`}
</div> </div>
</div> </div>
<div className="actions"> <div className="actions">
{v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>} {v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>}
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>} {v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>} {(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>}
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>} {v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>}
{props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</button>} {props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</button>}
</div> </div>
</div> </div>
{report !== undefined && <Report report={report} />} {report !== undefined && <Report report={report} />}
</div> </div>
); );
} }
function Report({ report }: { report: Record<string, any> | null }) { function Report({ report }: { report: Record<string, any> | null }) {
if (!report) return <div className="report muted">Отчёта нет</div>; if (!report) return <div className="report muted">Отчёта нет</div>;
if (report.error) return <div className="report error">{report.error}</div>; if (report.error) return <div className="report error">{report.error}</div>;
return ( return (
<div className="report"> <div className="report">
<div className="report-stats"> <div className="report-stats">
<span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span> <span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span>
<span>Меток: {report.labels}</span> <span>Меток: {report.labels}</span>
<span>Реплик: {report.stats?.say ?? 0}</span> <span>Реплик: {report.stats?.say ?? 0}</span>
<span>Меню: {report.stats?.menu ?? 0}</span> <span>Меню: {report.stats?.menu ?? 0}</span>
<span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span> <span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span>
<span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span> <span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span>
<span>Время: {report.durationSec} с</span> <span>Время: {report.durationSec} с</span>
</div> </div>
{report.missingFiles?.length > 0 && ( {report.missingFiles?.length > 0 && (
<details> <details>
<summary>Не найдены файлы ({report.missingFiles.length})</summary> <summary>Не найдены файлы ({report.missingFiles.length})</summary>
<ul>{report.missingFiles.map((f: string) => <li key={f}>{f}</li>)}</ul> <ul>{report.missingFiles.map((f: string) => <li key={f}>{f}</li>)}</ul>
</details> </details>
)} )}
{report.warnings?.length > 0 && ( {report.warnings?.length > 0 && (
<details> <details>
<summary>Предупреждения ({report.warnings.reduce((s: number, w: any) => s + w.count, 0)})</summary> <summary>Предупреждения ({report.warnings.reduce((s: number, w: any) => s + w.count, 0)})</summary>
<table> <table>
<tbody> <tbody>
{report.warnings.map((w: any) => ( {report.warnings.map((w: any) => (
<tr key={w.message}> <tr key={w.message}>
<td>{w.count}×</td> <td>{w.count}×</td>
<td>{w.message}</td> <td>{w.message}</td>
<td className="muted small">{w.example}</td> <td className="muted small">{w.example}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</details> </details>
)} )}
</div> </div>
); );
} }
function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) { function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) {
const [title, setTitle] = useState(game.title); const [title, setTitle] = useState(game.title);
const [description, setDescription] = useState(game.description); const [description, setDescription] = useState(game.description);
const [tags, setTags] = useState(game.tags.join(", ")); const [tags, setTags] = useState(game.tags.join(", "));
const [error, setError] = useState(""); const [error, setError] = useState("");
const save = async () => { const save = async () => {
try { try {
await api.patch(`/api/games/${game.id}`, { await api.patch(`/api/games/${game.id}`, {
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean), title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
}); });
onDone(); onDone();
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} }
}; };
return ( return (
<div className="editor"> <div className="editor">
<input value={title} onChange={(e) => setTitle(e.target.value)} /> <input value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /> <textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} />
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" /> <input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" />
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
<div className="actions"> <div className="actions">
<button className="primary" onClick={() => void save()}>Сохранить</button> <button className="primary" onClick={() => void save()}>Сохранить</button>
<button onClick={onDone}>Отмена</button> <button onClick={onDone}>Отмена</button>
</div> </div>
</div> </div>
); );
} }
+126 -126
View File
@@ -1,126 +1,126 @@
// Audio channels on top of HTMLAudioElement: music loops with fades, sound/voice one-shots. // Audio channels on top of HTMLAudioElement: music loops with fades, sound/voice one-shots.
export interface Volumes { export interface Volumes {
music: number; music: number;
sound: number; sound: number;
voice: number; voice: number;
} }
interface Channel { interface Channel {
el: HTMLAudioElement | null; el: HTMLAudioElement | null;
file: string | null; file: string | null;
loop: boolean; loop: boolean;
queue: string[]; queue: string[];
} }
function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) { function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) {
if (seconds <= 0) { if (seconds <= 0) {
el.volume = to; el.volume = to;
done?.(); done?.();
return; return;
} }
const start = performance.now(); const start = performance.now();
const step = () => { const step = () => {
const t = Math.min(1, (performance.now() - start) / (seconds * 1000)); const t = Math.min(1, (performance.now() - start) / (seconds * 1000));
el.volume = Math.max(0, Math.min(1, from + (to - from) * t)); el.volume = Math.max(0, Math.min(1, from + (to - from) * t));
if (t < 1) requestAnimationFrame(step); if (t < 1) requestAnimationFrame(step);
else done?.(); else done?.();
}; };
requestAnimationFrame(step); requestAnimationFrame(step);
} }
export class AudioSystem { export class AudioSystem {
channels = new Map<string, Channel>(); channels = new Map<string, Channel>();
constructor(public volumes: Volumes, private url: (file: string) => string | null, constructor(public volumes: Volumes, private url: (file: string) => string | null,
private registered: Record<string, { mixer: string; loop: boolean }> = {}) {} private registered: Record<string, { mixer: string; loop: boolean }> = {}) {}
private group(ch: string): keyof Volumes { private group(ch: string): keyof Volumes {
const mixer = this.registered[ch]?.mixer ?? ch; const mixer = this.registered[ch]?.mixer ?? ch;
if (mixer === "music") return "music"; if (mixer === "music") return "music";
if (mixer === "voice") return "voice"; if (mixer === "voice") return "voice";
return "sound"; return "sound";
} }
private channel(name: string): Channel { private channel(name: string): Channel {
let c = this.channels.get(name); let c = this.channels.get(name);
if (!c) { if (!c) {
c = { el: null, file: null, loop: false, queue: [] }; c = { el: null, file: null, loop: false, queue: [] };
this.channels.set(name, c); this.channels.set(name, c);
} }
return c; return c;
} }
play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number; play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number;
ifChanged?: boolean }) { ifChanged?: boolean }) {
const c = this.channel(ch); const c = this.channel(ch);
const loop = opts.loop ?? this.registered[ch]?.loop ?? ch === "music"; const loop = opts.loop ?? this.registered[ch]?.loop ?? ch === "music";
if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return; if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return;
this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0)); this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0));
c.queue = files.slice(1); c.queue = files.slice(1);
c.loop = loop; c.loop = loop;
this.start(ch, c, files[0], opts.fadein ?? 0); this.start(ch, c, files[0], opts.fadein ?? 0);
} }
queue(ch: string, files: string[], loop?: boolean | null) { queue(ch: string, files: string[], loop?: boolean | null) {
const c = this.channel(ch); const c = this.channel(ch);
if (!c.el || c.el.paused) { if (!c.el || c.el.paused) {
this.play(ch, files, { loop }); this.play(ch, files, { loop });
return; return;
} }
c.queue.push(...files); c.queue.push(...files);
if (loop != null) c.loop = loop; if (loop != null) c.loop = loop;
} }
private start(ch: string, c: Channel, file: string | undefined, fadein: number) { private start(ch: string, c: Channel, file: string | undefined, fadein: number) {
if (!file) return; if (!file) return;
const src = this.url(file); const src = this.url(file);
if (!src) { if (!src) {
console.warn("[pgvn] нет аудиофайла", file); console.warn("[pgvn] нет аудиофайла", file);
return; return;
} }
const el = new Audio(src); const el = new Audio(src);
const target = this.volumes[this.group(ch)]; const target = this.volumes[this.group(ch)];
el.volume = fadein > 0 ? 0 : target; el.volume = fadein > 0 ? 0 : target;
el.loop = c.loop && c.queue.length === 0; el.loop = c.loop && c.queue.length === 0;
el.onended = () => { el.onended = () => {
if (c.el !== el) return; if (c.el !== el) return;
const next = c.queue.shift(); const next = c.queue.shift();
if (next) { if (next) {
if (c.loop) c.queue.push(next); if (c.loop) c.queue.push(next);
this.start(ch, c, next, 0); this.start(ch, c, next, 0);
} else { } else {
c.el = null; c.el = null;
c.file = null; c.file = null;
} }
}; };
c.el = el; c.el = el;
c.file = file; c.file = file;
el.play().catch(() => console.warn("[pgvn] браузер заблокировал автозапуск звука")); el.play().catch(() => console.warn("[pgvn] браузер заблокировал автозапуск звука"));
if (fadein > 0) fade(el, 0, target, fadein); if (fadein > 0) fade(el, 0, target, fadein);
} }
stop(ch: string, fadeout = 0) { stop(ch: string, fadeout = 0) {
const c = this.channels.get(ch); const c = this.channels.get(ch);
if (!c?.el) return; if (!c?.el) return;
const el = c.el; const el = c.el;
c.el = null; c.el = null;
c.file = null; c.file = null;
c.queue = []; c.queue = [];
fade(el, el.volume, 0, fadeout, () => el.pause()); fade(el, el.volume, 0, fadeout, () => el.pause());
} }
stopAll() { stopAll() {
for (const ch of this.channels.keys()) this.stop(ch); for (const ch of this.channels.keys()) this.stop(ch);
} }
setVolumes(v: Volumes) { setVolumes(v: Volumes) {
this.volumes = v; this.volumes = v;
for (const [name, c] of this.channels) if (c.el) c.el.volume = v[this.group(name)]; for (const [name, c] of this.channels) if (c.el) c.el.volume = v[this.group(name)];
} }
current(ch: string): { file: string; loop: boolean } | null { current(ch: string): { file: string; loop: boolean } | null {
const c = this.channels.get(ch); const c = this.channels.get(ch);
return c?.file ? { file: c.file, loop: c.loop } : null; return c?.file ? { file: c.file, loop: c.loop } : null;
} }
} }