chore: add CI, lint config, login rate limit and split large modules

- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After)
- Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build)
- Apply ruff autofixes (import order, pyupgrade)
- Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and
  translation-types.ts from oversized modules
- Add dependency-free React UI tests
- Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 11:23:22 +03:00
1 parent 57a4edcbfd
commit bbbcc6a5d8
42 files changed
+678 -391

No files matched your search

+2 -2
View File
@@ -12,12 +12,12 @@ import sys
import tempfile
import time
from collections import Counter
from dataclasses import asdict
from pathlib import Path
from app.converter import assets, locales
from app.converter.compatibility import Issue, audit_ir
from dataclasses import asdict
from app.converter.archive import ArchiveError, find_game_dir
from app.converter.compatibility import Issue, audit_ir
from app.converter.pipeline import _auto_images, _is_engine_file, _resolve_image, anchors
from app.converter.rpa import RpaError, extract_rpa, read_index
from app.converter.rpy_lexer import ParseError
+1 -1
View File
@@ -1,7 +1,7 @@
"""Engine detection and fail-closed import diagnostics. Never runs game executables."""
import os
from dataclasses import asdict, dataclass
from pathlib import Path, PurePosixPath
import os
@dataclass(frozen=True)
+1 -1
View File
@@ -8,11 +8,11 @@
import hashlib
import re
from uuid import uuid4
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from uuid import uuid4
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
from app.converter.source import read_source
+1 -1
View File
@@ -1,7 +1,7 @@
"""Compile the supported Naninovel command subset to the shared PGVN player."""
from collections import Counter
import hashlib
import re
from collections import Counter
from app.converter import nani_expr
from app.converter.compatibility import CompatibilityError, Issue
+1 -1
View File
@@ -1,6 +1,6 @@
"""Parse Naninovel expressions into data, never Python/JavaScript source code."""
import re
import math
import re
TOKEN = re.compile(r'''\s*(?:(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+)|([A-Za-z_]\w*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^]))''')
PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5,
+4 -4
View File
@@ -1,9 +1,8 @@
"""Recover Naninovel data from Unity builds without executing their managed code."""
from dataclasses import dataclass, field
from pathlib import Path
import hashlib
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.unity_bundle import unpack
@@ -133,8 +132,9 @@ def read_package(root: Path, work: Path, *, media: bool = False) -> Package:
def _read_package(root: Path, work: Path, package: Package, *, media: bool) -> Package:
import UnityPy
from app.converter.tomie_profile import extract_tables, identify
from app.converter.unity_scripts import ScriptCodec, script_objects
from app.converter.tomie_profile import identify, extract_tables
package.profile = identify(root)
if package.profile:
+2 -3
View File
@@ -1,8 +1,7 @@
"""Naninovel importer: compiled Unity data -> validated PGVN package."""
from pathlib import Path, PurePosixPath
import json
import re
import tempfile
from pathlib import Path, PurePosixPath
from app.blobs import put_bytes, put_file
from app.converter import assets, locales
@@ -158,8 +157,8 @@ def _convert_package(root: Path, work: Path, progress, package):
def _convert_tomie(root, work, progress, package):
from app.converter.pipeline import Result, anchors
from app.converter.tomie_format import RUNTIME, modules, prepare
from app.converter.tomie_assets import export_media as export_tomie_media
from app.converter.tomie_format import RUNTIME, modules, prepare
native = prepare(package) # all script checks precede media decoding/blob writes
scripts = modules(native, package.texts)
+7 -1
View File
@@ -15,12 +15,18 @@ from app.blobs import put_bytes, put_file
from app.config import get_settings
from app.converter import assets, locales
from app.converter.archive import extract, find_game_dir
from app.converter.compatibility import (
CompatibilityError,
Issue,
audit_ir,
detect,
require_supported_scripts,
)
from app.converter.rpa import RpaError, extract_rpa
from app.converter.rpy_lexer import ParseError
from app.converter.rpy_parser import RpyParser, ScriptData
from app.converter.rpyc import decompile_missing
from app.converter.source import read_source
from app.converter.compatibility import CompatibilityError, Issue, audit_ir, detect, require_supported_scripts
log = logging.getLogger(__name__)
FORMAT = "pgvn/1"
+3 -3
View File
@@ -1,9 +1,9 @@
"""Read-only compatibility check, shared by uploads, CLI and the converter."""
from pathlib import Path
import tempfile
import zipfile
import shutil
import subprocess
import tempfile
import zipfile
from pathlib import Path
from app.converter.archive import extract
from app.converter.compatibility import CompatibilityError, Issue, detect
+130
View File
@@ -0,0 +1,130 @@
"""Module-level helpers of the Ren'Py parser (Python snippets, ATL props, constants)."""
import ast
import re
import warnings
from typing import Any
from app.converter.pyexpr import Expr
Node = dict[str, Any]
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
"align", "pos", "anchor", "xysize", "size"}
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
"ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip",
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
def _dedent(source: str) -> str:
import textwrap
return textwrap.dedent(source)
def _parse_py(source: str, mode: str = "exec") -> ast.AST:
# decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return ast.parse(_dedent(source), mode=mode)
def _dotted_name(e: Expr) -> str:
if e["k"] == "name":
return e["id"]
if e["k"] == "attr":
return f"{_dotted_name(e['o'])}.{e['a']}"
return ""
def _assign_name(t: ast.AST) -> str | None:
if isinstance(t, ast.Name):
return t.id
if isinstance(t, ast.Attribute):
inner = _assign_name(t.value)
return f"{inner}.{t.attr}" if inner else None
return None
def _const(e: Expr | None) -> Any:
if e is None:
return None
if e.get("k") == "const":
return e["v"]
if e.get("k") in ("tuple", "list"):
return [_const(x) for x in e["v"]]
if e.get("k") == "unary" and e["op"] == "-":
v = _const(e["v"])
return -v if isinstance(v, (int, float)) else None
return None
def _num(s: str | None) -> float | None:
try:
return float(s) if s is not None else None
except ValueError:
return None
def _static_props(kw: dict[str, Expr]) -> dict[str, Any]:
props = {}
for k, v in kw.items():
if k in POSITION_PROPS:
val = _const(v)
if val is not None:
props[k] = val
return props
def _atl_props(text: str) -> dict[str, Any]:
"""Extract static `prop value` pairs from one ATL line, ignoring timing."""
tokens = text.replace(",", " ").split()
props: dict[str, Any] = {}
i = 0
while i < len(tokens) - 1:
key = tokens[i]
if key in POSITION_PROPS:
raw = tokens[i + 1]
try:
props[key] = ast.literal_eval(raw)
except (ValueError, SyntaxError):
if raw.startswith("(") and i + 2 < len(tokens):
try:
props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2]))
i += 1
except (ValueError, SyntaxError):
pass
i += 2
else:
i += 1
return props
def _split_top(source: str) -> list[str]:
parts, depth, cur = [], 0, []
for ch in source:
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
if ch == "," and depth == 0:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
if "".join(cur).strip():
parts.append("".join(cur))
return [p.strip() for p in parts if p.strip()]
def _jump_or_call(op: str, target: Expr) -> Node:
if target.get("k") == "const":
return {"op": op, "target": target["v"]}
return {"op": op, "expr": target}
+19 -122
View File
@@ -1,14 +1,30 @@
"""Ren'Py script (.rpy) → PGVN IR."""
import ast
import re
import warnings
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
from app.converter import pyexpr
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
from app.converter.rpy_helpers import (
_CALL_LINE,
_DISPLAYABLE_CALLS,
_MEDIA_EXTS,
_NUMBER_LINE,
_SHOW_KEYWORDS,
POSITION_PROPS,
_assign_name,
_atl_props,
_const,
_dedent,
_dotted_name,
_jump_or_call,
_num,
_parse_py,
_split_top,
_static_props,
)
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
Node = dict[str, Any]
@@ -41,9 +57,6 @@ TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pix
POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top",
"offscreenleft", "offscreenright", "default", "reset"}
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
"align", "pos", "anchor", "xysize", "size"}
CHANNELS = {"music", "sound", "voice", "audio", "movie"}
SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
@@ -1100,7 +1113,7 @@ class RpyParser:
if c.string_raw() is not None:
c.pos = save
break
p = c.image_part() or (("@" if c.char("@") else None))
p = c.image_part() or ("@" if c.char("@") else None)
if p is None:
c.pos = save
break
@@ -1130,119 +1143,3 @@ class RpyParser:
c.rest()
self.d.stats["say"] += 1
return [node]
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
"ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip",
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
def _dedent(source: str) -> str:
import textwrap
return textwrap.dedent(source)
def _parse_py(source: str, mode: str = "exec") -> ast.AST:
# decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return ast.parse(_dedent(source), mode=mode)
def _dotted_name(e: Expr) -> str:
if e["k"] == "name":
return e["id"]
if e["k"] == "attr":
return f"{_dotted_name(e['o'])}.{e['a']}"
return ""
def _assign_name(t: ast.AST) -> str | None:
if isinstance(t, ast.Name):
return t.id
if isinstance(t, ast.Attribute):
inner = _assign_name(t.value)
return f"{inner}.{t.attr}" if inner else None
return None
def _const(e: Expr | None) -> Any:
if e is None:
return None
if e.get("k") == "const":
return e["v"]
if e.get("k") in ("tuple", "list"):
return [_const(x) for x in e["v"]]
if e.get("k") == "unary" and e["op"] == "-":
v = _const(e["v"])
return -v if isinstance(v, (int, float)) else None
return None
def _num(s: str | None) -> float | None:
try:
return float(s) if s is not None else None
except ValueError:
return None
def _static_props(kw: dict[str, Expr]) -> dict[str, Any]:
props = {}
for k, v in kw.items():
if k in POSITION_PROPS:
val = _const(v)
if val is not None:
props[k] = val
return props
def _atl_props(text: str) -> dict[str, Any]:
"""Extract static `prop value` pairs from one ATL line, ignoring timing."""
tokens = text.replace(",", " ").split()
props: dict[str, Any] = {}
i = 0
while i < len(tokens) - 1:
key = tokens[i]
if key in POSITION_PROPS:
raw = tokens[i + 1]
try:
props[key] = ast.literal_eval(raw)
except (ValueError, SyntaxError):
if raw.startswith("(") and i + 2 < len(tokens):
try:
props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2]))
i += 1
except (ValueError, SyntaxError):
pass
i += 2
else:
i += 1
return props
def _split_top(source: str) -> list[str]:
parts, depth, cur = [], 0, []
for ch in source:
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
if ch == "," and depth == 0:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
if "".join(cur).strip():
parts.append("".join(cur))
return [p.strip() for p in parts if p.strip()]
def _jump_or_call(op: str, target: Expr) -> Node:
if target.get("k") == "const":
return {"op": op, "target": target["v"]}
return {"op": op, "expr": target}
+1 -1
View File
@@ -1,7 +1,7 @@
"""Decode source text without silently replacing characters in localisations."""
import io
from pathlib import Path
import tokenize
from pathlib import Path
from app.converter.rpy_lexer import ParseError
+1 -1
View File
@@ -1,6 +1,6 @@
"""Recover the pinned Tomie build's media and ResourceManager actor bindings."""
from pathlib import Path
import hashlib
from pathlib import Path
from app.blobs import put_file
from app.converter import assets
+3 -2
View File
@@ -1,7 +1,7 @@
"""Version-pinned Tomie metadata. Reads IL constants; never executes managed code."""
import hashlib
from pathlib import Path
import re
from pathlib import Path
PROFILE = "tomie/expansion-1.3801"
ASSEMBLY_HASH = "1c6aec12894362f1b9c62858a5a4c286eb93aee175ed1ef339218dc5a7b2729b"
@@ -110,8 +110,9 @@ def runtime_data(package) -> dict:
def validate_expressions(scripts: dict) -> dict:
"""Check the full recovered expression corpus, including dynamic templates."""
from app.converter.nani_expr import assignment, parse
from collections import Counter
from app.converter.nani_expr import assignment, parse
expressions, errors = {}, Counter()
examples = {}
@@ -9,7 +9,6 @@ from app.converter.compatibility import Issue
from app.converter.nani_expr import assignment, parse
from app.converter.tomie_profile import FUNCTIONS, validate_expressions
ARGUMENTS = {
"ModifyBackground": "AppearanceAndTransition Duration Scale TintColor",
"ModifyTextPrinter": "Duration HideOther IdAndAppearance MakeDefault ScenePosition",
+1 -1
View File
@@ -3,8 +3,8 @@ from pathlib import Path
def unpack(source: Path, target: Path) -> list[Path]:
from UnityPy.streams import EndianBinaryReader
from UnityPy.helpers.CompressionHelper import DECOMPRESSION_MAP
from UnityPy.streams import EndianBinaryReader
target.mkdir(parents=True, exist_ok=True)
with source.open("rb") as stream:
+1 -1
View File
@@ -98,8 +98,8 @@ class ScriptCodec:
helper.read_typetree_boost, helper.get_ref_type_node = old_boost, old_resolve
def read(self, obj):
from UnityPy.streams import EndianBinaryWriter
from UnityPy.helpers import TypeTreeHelper as helper
from UnityPy.streams import EndianBinaryWriter
# Bundles can retain exact schemas which differ from generated metadata
# (notably editor-compiled Init). Prefer those schemas when available.
self.templates = {(ref.m_AssemblyName.removesuffix(".dll"), ref.m_NameSpace, ref.m_ClassName): ref.node
+1 -1
View File
@@ -25,8 +25,8 @@ async def main() -> None:
if not args.path.is_dir():
raise SystemExit(f"{args.path} is not a directory")
from app.converter.preflight import inspect_directory
from app.converter.compatibility import CompatibilityError
from app.converter.preflight import inspect_directory
try:
report = await asyncio.to_thread(inspect_directory, args.path)
except CompatibilityError as exc:
+2 -3
View File
@@ -6,11 +6,9 @@ python -m app.import_library /games --import --owner admin
import argparse
import asyncio
import json
from pathlib import Path
import tempfile
import sys
from pathlib import Path
from app.converter.archive import ArchiveError, extract
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.preflight import inspect_archive, inspect_directory
@@ -52,6 +50,7 @@ def check(source: Path) -> dict:
async def import_one(source: Path, report: dict, username: str | None) -> dict:
from sqlalchemy import select
from app.db import SessionLocal
from app.models import Game, GameVersion, Role, User
from app.worker import convert
+2 -2
View File
@@ -2,12 +2,12 @@
import copy
import difflib
import json
from uuid import uuid4, uuid5, NAMESPACE_URL
from uuid import NAMESPACE_URL, uuid4, uuid5
from app.blobs import BLOB_ID_RE
from app.pgvn import expression, statements
from app.converter.locales import walk
from app.converter.pipeline import anchors
from app.pgvn import expression, statements
from app.translation import file_json, put_json
OPS = set("label camera say scene show hide with play stop queue voice pause menu if while jump call return exec window noop nvl".split())
+55
View File
@@ -0,0 +1,55 @@
import time
from collections import defaultdict, deque
from fastapi import Request
class FailureLimiter:
"""Sliding-window counter of failed attempts, kept in process memory.
Several uvicorn workers would each count separately, which only loosens the
limit; the service runs a single backend process.
"""
def __init__(self, max_failures: int, window_seconds: float) -> None:
self.max_failures = max_failures
self.window = window_seconds
self._hits: dict[str, deque[float]] = defaultdict(deque)
def _prune(self, key: str, now: float) -> deque[float]:
hits = self._hits[key]
while hits and now - hits[0] > self.window:
hits.popleft()
if not hits:
self._hits.pop(key, None)
return hits
def retry_after(self, *keys: str) -> int:
"""Seconds until a blocked key is allowed again, 0 if none is blocked."""
now = time.monotonic()
wait = 0.0
for key in keys:
hits = self._prune(key, now)
if len(hits) >= self.max_failures:
wait = max(wait, hits[0] + self.window - now)
return int(wait) + 1 if wait > 0 else 0
def fail(self, *keys: str) -> None:
now = time.monotonic()
for key in keys:
self._prune(key, now)
self._hits[key].append(now)
def reset(self, *keys: str) -> None:
for key in keys:
self._hits.pop(key, None)
def client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
login_limiter = FailureLimiter(max_failures=8, window_seconds=15 * 60)
+12 -2
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter, HTTPException, Response, status
from fastapi import APIRouter, HTTPException, Request, Response, status
from sqlalchemy import select
from app.config import get_settings
from app.deps import DB, SessionUser
from app.models import User
from app.ratelimit import client_ip, login_limiter
from app.schemas import ChangeCredentialsIn, LoginIn, MeOut
from app.security import (
COOKIE_NAME,
@@ -30,10 +31,19 @@ def _set_cookie(response: Response, user: User) -> None:
@router.post("/login", response_model=MeOut)
async def login(body: LoginIn, response: Response, db: DB) -> User:
async def login(body: LoginIn, request: Request, response: Response, db: DB) -> User:
keys = (f"ip:{client_ip(request)}", f"user:{body.username.lower()}")
if wait := login_limiter.retry_after(*keys):
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
f"Слишком много неудачных попыток входа. Повторите через {wait} с",
headers={"Retry-After": str(wait)},
)
user = await db.scalar(select(User).where(User.username == body.username))
if not user or not user.is_active or not verify_password(user.password_hash, body.password):
login_limiter.fail(*keys)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Неверный логин или пароль")
login_limiter.reset(*keys)
_set_cookie(response, user)
return user
+1 -1
View File
@@ -7,11 +7,11 @@ from sqlalchemy import and_, exists, func, or_, select, true
from starlette.concurrency import run_in_threadpool
from app.blobs import put_bytes
from app.uploads import remove_upload
from app.deps import DB, CurrentUser, has_perm, require
from app.models import Game, GameVersion, User
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut
from app.translation import version_languages
from app.uploads import remove_upload
router = APIRouter(prefix="/api/games", tags=["games"])
Player = Annotated[User, Depends(require("games.play"))]
+11 -4
View File
@@ -1,19 +1,26 @@
from typing import Annotated, Any
import asyncio
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect
from app import translation
from app.deps import DB, CurrentUser, has_perm, require
from app.models import Game, GameVersion, Revision, Save, User, VersionHistory
from app.routers.games import get_game
from app.schemas import (
ForkIn, HistoryOut, PublishIn, SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn,
ForkIn,
HistoryOut,
PublishIn,
SaveDetailOut,
SaveIn,
SaveOut,
VersionDetailOut,
VersionIn,
VersionOut,
)
from app import translation
from app.uploads import remove_upload, upload_path
router = APIRouter(prefix="/api", tags=["versions"])
@@ -79,9 +86,9 @@ async def upload_version(
await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл")
from app.converter.preflight import probe_archive
from app.converter.archive import ArchiveError
from app.converter.compatibility import CompatibilityError
from app.converter.preflight import probe_archive
try:
probe = await asyncio.to_thread(probe_archive, path, filename)
except (CompatibilityError, ArchiveError, OSError, ValueError) as exc:
+1 -1
View File
@@ -384,7 +384,7 @@ def export_po(manifest: dict[str, Any], lang: str, game_title: str) -> str:
f"msgstr {_po_quote(e.get('t', ''))}", ""]
for src, usage in idx.strings.items():
e = strings.get(src) or {}
out += [f"#. {usage}", f'msgctxt "string"', f"msgid {_po_quote(src)}",
out += [f"#. {usage}", 'msgctxt "string"', f"msgid {_po_quote(src)}",
f"msgstr {_po_quote(e.get('t', ''))}", ""]
return "\n".join(out)
+1 -1
View File
@@ -9,8 +9,8 @@ from sqlalchemy import select, update
from app.blobs import read_bytes
from app.config import get_settings
from app.converter.pipeline import convert_archive
from app.converter.compatibility import CompatibilityError
from app.converter.pipeline import convert_archive
from app.db import SessionLocal
from app.models import Game, GameVersion
from app.routers.games import make_cover