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:
1 parent
e1d7b29f77
commit
51a7e77e56
14 files changed
+1461
-1447
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user