diff --git a/.dockerignore b/.dockerignore
index aedde1af9..ae51a2bb8 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -70,6 +70,7 @@ docs/_private/
# Operator BIOS staging from scripts/import_bios.py (never bake dumps into the image)
.bios-install-out/
+.bios-install-out-*/
bios/
**/bios/*.bin
**/bios/*.rom
diff --git a/.env.docker.example b/.env.docker.example
index 5cc235d68..2e4d12d25 100644
--- a/.env.docker.example
+++ b/.env.docker.example
@@ -62,6 +62,7 @@ REMEMBER_COOKIE_SECURE=true
CHAT_ATTACHMENT_STORAGE_MAX_BYTES=0
CHAT_ATTACHMENT_STORAGE_MAX_FILES=0
TRUSTED_PROXIES=0
+TRUSTED_PROXY_CIDRS=
# Hosts allowed to become the origin of an emailed link (password reset, invite).
# Empty trusts private/LAN hosts only, so http://192.168.x.x links keep working
diff --git a/.env.example b/.env.example
index fc6a570ba..5dc09a43b 100644
--- a/.env.example
+++ b/.env.example
@@ -117,6 +117,9 @@ OIDC_DISPLAY_NAME=Sign in with SSO
SESSION_COOKIE_SECURE=true
REMEMBER_COOKIE_SECURE=true
TRUSTED_PROXIES=0
+# Only peers in these addresses/CIDRs may supply forwarded headers. Example:
+# TRUSTED_PROXY_CIDRS=172.20.0.5/32,172.20.0.0/24
+TRUSTED_PROXY_CIDRS=
# Hosts allowed to become the origin of an emailed link (password reset, invite).
# Leave empty on a LAN install: private/loopback hosts are trusted by default, so
diff --git a/.env.nas.example b/.env.nas.example
index 887ba0cb0..e3cf481f3 100644
--- a/.env.nas.example
+++ b/.env.nas.example
@@ -60,6 +60,7 @@ REMEMBER_COOKIE_SECURE=true
CHAT_ATTACHMENT_STORAGE_MAX_BYTES=0
CHAT_ATTACHMENT_STORAGE_MAX_FILES=0
TRUSTED_PROXIES=0
+TRUSTED_PROXY_CIDRS=
# Hosts allowed to become the origin of an emailed link (password reset, invite).
# Empty trusts private/LAN hosts only, so http://192.168.x.x links keep working
diff --git a/.env.single.example b/.env.single.example
index 25f364840..0fecc6c5b 100644
--- a/.env.single.example
+++ b/.env.single.example
@@ -19,3 +19,7 @@ TZ=UTC
# Use an external Postgres instead of the embedded one:
# set DATABASE_URL=postgresql://user:pass@host:5432/oneirodex
# Force the embedded one even if DATABASE_URL is set: ONEIRODEX_EMBEDDED_DB=true
+# Optional reverse-proxy trust. Forwarded headers are honored only when the
+# immediate proxy source also matches TRUSTED_PROXY_CIDRS.
+TRUSTED_PROXIES=0
+TRUSTED_PROXY_CIDRS=
diff --git a/.env.unraid.example b/.env.unraid.example
index 98378551a..991d582db 100644
--- a/.env.unraid.example
+++ b/.env.unraid.example
@@ -99,6 +99,7 @@ REMEMBER_COOKIE_SECURE=true
CHAT_ATTACHMENT_STORAGE_MAX_BYTES=0
CHAT_ATTACHMENT_STORAGE_MAX_FILES=0
TRUSTED_PROXIES=0
+TRUSTED_PROXY_CIDRS=
# Hosts allowed to become the origin of an emailed link (password reset, invite).
# Empty trusts private/LAN hosts only, so http://192.168.x.x links keep working
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 35575e372..76aaeca4e 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -1,4 +1,4 @@
-# Oneirodex CI — PR gate with a *core* pytest subset (+ member-app / desktop vitest).
+# Oneirodex CI — PR gate with the pytest marker set (+ member-app / desktop vitest).
#
# Full pytest suite remains local / release-time (needs Unraid-like fixtures,
# library paths, and heavier integrations). Do not treat this workflow as
@@ -31,7 +31,7 @@ jobs:
pytest-core:
name: Pytest (core)
runs-on: ubuntu-latest
- timeout-minutes: 30
+ timeout-minutes: 35
services:
postgres:
@@ -73,6 +73,28 @@ jobs:
# branch red with no repo change behind it.
pip install -r requirements-dev.txt
+ - name: Set up Node.js 22
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: package-lock.json
+
+ - name: Install frontend dependencies
+ run: npm ci
+
+ - name: Build React shells required by Python route tests
+ run: |
+ npm run build --workspace=member-app
+ npm run build:auth-pilot --workspace=member-app
+ npm run build --workspace=admin-app
+ npm run build:pilot --workspace=admin-app
+
+ - name: Verify store sync transaction invariants
+ # Keep this SQLite compatibility slice ahead of the 20+ minute core
+ # suite so transaction regressions fail quickly and with a focused log.
+ run: python -m pytest tests/test_store_connections.py -q
+
- name: Run pytest (marker set, deselect by exception)
# Gate = every test not marked `integration`, minus a named exception
# list. Flipped from a hand-enumerated file list on 2026-09-17 after
@@ -81,8 +103,8 @@ jobs:
# default; the only way to keep one out is a line below, with its ID.
#
# The --deselect list is docs/dev/archive/test-suite-failures-2026-09-16.md's
- # known failures as they reproduce on the runner (three are runner-
- # only: the SPA dist is not built in this job, a Windows drive-letter
+ # known failures as they reproduce on the runner (two are runner-
+ # only: a Windows drive-letter
# case, the font catalogue). Remove a line when its test is fixed;
# never add one to get a red PR green.
#
@@ -92,14 +114,12 @@ jobs:
python -m pytest tests/ -q -m "not integration"
--cov=oneirodex --cov-report=term-missing
--cov-fail-under=65
- --deselect tests/test_admin_spa_assets.py::test_admin_spa_dist_built
--deselect tests/test_checkout_paths.py::test_operator_docs_checkout_is_oneirodex
--deselect tests/test_collections_api_wiring.py::test_newsletter_ckeditor_targets_content_field
--deselect tests/test_collections_api_wiring.py::test_spa_collections_client_covers_new_actions
--deselect tests/test_library_roots.py::TestParseLibraryRoots::test_windows_drive_letter_is_not_mistaken_for_a_label
--deselect tests/test_member_chrome_css.py::test_tile_hover_has_no_accent_glow_and_grows_from_center
--deselect tests/test_member_chrome_css.py::test_topbar_cluster_is_not_a_shared_outline
- --deselect tests/test_member_spa_assets.py::test_member_spa_links_member_app_css
--deselect tests/test_routes_admin_ext_settings_shell.py::TestSettingsShellSections::test_every_section_deep_link_is_reachable_from_the_react_hub
--deselect tests/test_routes_apis_download.py::TestDeleteDownloadRequest::test_delete_invalid_request_id_zero
--deselect tests/test_routes_apis_download.py::TestDeleteDownloadRequest::test_delete_request_database_error
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c87b1410..77583df0a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.1.9] — 2026-10-15
+
+### Improved
+- Unify member and admin navigation, controls, tables, filters and shared icon/theme primitives across the React applications.
+- Complete the static React and native FastAPI runtime transition, removing Jinja-rendered application pages while retaining existing session compatibility.
+- Scrub Docker build context of user BIOS staging output so firmware and key files cannot enter shipped image layers.
+
+### Security
+- Authenticate forwarded client and origin headers against `TRUSTED_PROXY_CIDRS` before applying `TRUSTED_PROXIES` hop parsing. Supported launchers disable Uvicorn's implicit rewriting. Reverse-proxy deployments must configure the immediate proxy source IP/CIDR.
+- Bind desktop path reveals to an accessible game folder; require admin authority for unmatched and unbound scan-job paths.
+- Revalidate qBittorrent hardlink pipeline redirects and outbound destinations through the shared safe-request policy.
+
## [1.1.8] — 2026-10-08
### Improved
diff --git a/Dockerfile b/Dockerfile
index 748f885ff..4b759af0e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -39,9 +39,11 @@ COPY oneirodex/setup/default_theme/js/scanJobsDom.js oneirodex/setup/default_the
# vite writes each bundle to oneirodex/static/dist// (outDir is resolved
# from the app dir in its vite.config); `npm run build` per workspace runs the
# app's `tsc --noEmit && vite build`.
-RUN mkdir -p oneirodex/static/dist/member-app oneirodex/static/dist/admin-app oneirodex/static/dist/ops-glance \
+RUN mkdir -p oneirodex/static/dist/member-app oneirodex/static/dist/admin-app oneirodex/static/dist/admin-pilot oneirodex/static/dist/ops-glance \
&& npm run build --workspace=member-app \
+ && npm run build:auth-pilot --workspace=member-app \
&& npm run build --workspace=admin-app \
+ && npm run build:pilot --workspace=admin-app \
&& npm run build --workspace=ops-glance
FROM python:3.12-slim
@@ -89,7 +91,9 @@ RUN mkdir -p /etc/postgresql-common \
COPY . .
COPY --from=frontend-build /build/oneirodex/static/dist/member-app /app/oneirodex/static/dist/member-app
+COPY --from=frontend-build /build/oneirodex/static/dist/member-auth /app/oneirodex/static/dist/member-auth
COPY --from=frontend-build /build/oneirodex/static/dist/admin-app /app/oneirodex/static/dist/admin-app
+COPY --from=frontend-build /build/oneirodex/static/dist/admin-pilot /app/oneirodex/static/dist/admin-pilot
COPY --from=frontend-build /build/oneirodex/static/dist/ops-glance /app/oneirodex/static/dist/ops-glance
RUN pip install -r requirements.txt
diff --git a/README.md b/README.md
index 5f9322e54..61c683b38 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -39,14 +39,14 @@
-Oneirodex is a **Flask + React** server you run at home or on a NAS. Point it at folders of DRM-free games and ROMs, let it identify them against IGDB / Steam / GOG / RAWG, then give the household a modern browser app to **browse, download, play in the browser where a system supports it, and hang out** — with a desktop companion for PC titles and a ten-foot mode for the TV.
+Oneirodex is a **FastAPI + React** server you run at home or on a NAS. Point it at folders of DRM-free games and ROMs, let it identify them against IGDB / Steam / GOG / RAWG, then give the household a modern browser app to **browse, download, play in the browser where a system supports it, and hang out** — with a desktop companion for PC titles and a ten-foot mode for the TV.
Every screenshot and clip on this page is **live UI** from a stock install seeded with five legal free ROMs: what you see is what ships.
-| 🏷️ Release | 1.1.8 · VERSION | 🌐 Default URL | http://localhost:5006 |
-| 🐳 Containers | oneirodex-app · oneirodex-db · optional oneirodex-livekit | 🖼️ Image | local Compose build oneirodex:1.1.8 |
-| 📦 Package | oneirodex/ — Flask under ASGI (uvicorn) | 🧩 Front ends | member · admin · ops-glance SPAs, Tauri desktop, Quest PWA |
+| 🏷️ Release | 1.1.9 · VERSION | 🌐 Default URL | http://localhost:5006 |
+| 🐳 Containers | oneirodex-app · oneirodex-db · optional oneirodex-livekit | 🖼️ Image | local Compose build oneirodex:1.1.9 |
+| 📦 Package | oneirodex/ — FastAPI under uvicorn | 🧩 Front ends | member · admin · ops-glance SPAs, Tauri desktop, Quest PWA |
> **Legal.** Use Oneirodex only with software you are authorised to share. It ships **no** Discord bots, pirate marketplaces or store download queues. **Authentik / OIDC is optional** — local username and password is the default for a home install.
@@ -364,12 +364,12 @@ Full lists: [`.env.example`](.env.example) · [`.env.docker.example`](.env.docke
```text
┌─────────────────┐ ┌──────────────────┐ ┌────────────┐ ┌────────────┐
│ Member SPA │ │ Admin SPA │ │ Desktop │ │ Quest PWA │
-│ (React) │ │ (React + Jinja) │ │ companion │ │ (/vr) │
+│ (React) │ │ (React) │ │ companion │ │ (/vr) │
└────────┬────────┘ └────────┬─────────┘ └─────┬──────┘ └─────┬──────┘
└─────────────┬───────┴───────────────────┴────────────────┘
▼
┌─────────────────┐
- │ Flask on ASGI │ ← oneirodex/ :5006
+ │ FastAPI / ASGI │ ← oneirodex/ :5006
│ APIs · auth │
└───────┬─────────┘
┌───────────┼────────────┐
@@ -391,13 +391,18 @@ Full lists: [`.env.example`](.env.example) · [`.env.docker.example`](.env.docke
| Area | What Oneirodex uses |
|---|---|
-| Language / server | **Python 3.12**, **Flask** under ASGI (uvicorn), SQLAlchemy + Alembic migrations |
-| Front ends | **React 19 + Vite + TypeScript** single-page apps (member, admin, ops-glance); admin also uses Jinja templates |
+| Language / server | **Python 3.12**, **FastAPI** (uvicorn), SQLAlchemy + Alembic; Flask compatibility tools are development-only |
+| Front ends | **React 19 + Vite + TypeScript** single-page apps (member, admin, ops-glance); no application-authored Jinja templates remain |
| Desktop / VR | **Tauri** desktop companion · Quest PWA |
| Database | **PostgreSQL 17** — the only required service |
| Node.js | Build-time only (the Docker image compiles the SPAs, then ships Python); not a runtime dependency |
| Not used | Next.js, PHP, Django, MySQL/MariaDB |
+The admin and member interfaces are React single-page apps. Application-authored
+Jinja templates and the WSGI bridge have been retired. FastAPI owns request
+dispatch; the runtime requirements exclude Flask, its extensions, and Jinja.
+Legacy Flask route modules remain available to development and parity tests.
+
**Default ports:** `5006` (web app) and `5432` (PostgreSQL, internal to Compose).
**Optional services** — off by default, enabled by env vars or Compose profiles; none are needed for a normal install:
diff --git a/VERSION b/VERSION
index 18efdb9ae..512a1faa6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.8
+1.1.9
diff --git a/alembic/env.py b/alembic/env.py
index 1b4b12c3e..4ce2c51c8 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -56,12 +56,14 @@
if config.config_file_name is not None:
fileConfig(config.config_file_name)
-# Importing the models module registers every table on db.metadata.
-from oneirodex import db # noqa: E402
+# Importing the framework-independent models module registers every table on
+# the shared declarative metadata. Alembic runs in the production image, where
+# the legacy Flask compatibility runtime (and its Jinja dependency) is absent.
from oneirodex import models # noqa: E402,F401 (import for side effect)
+from oneirodex.models.orm import ModelBase # noqa: E402
from config import Config # noqa: E402
-target_metadata = db.metadata
+target_metadata = ModelBase.metadata
# Indexes the pre-Alembic raw-DDL pass (oneirodex/updateschema.py) created that
# are NOT re-declared on models.py metadata. The baseline revision builds them
diff --git a/asgi.py b/asgi.py
index b3453ca9f..be8f96ef3 100644
--- a/asgi.py
+++ b/asgi.py
@@ -1,21 +1,25 @@
-"""
-ASGI config for Oneirodex production deployment.
-This file wraps the Flask app to be compatible with ASGI servers like uvicorn
-and provides async file streaming for downloads and static assets.
-
-The WSGI bridge is a2wsgi, not asgiref. asgiref's ``WsgiToAsgi`` submits the
-call into a ``CurrentThreadExecutor`` that can already be shut down when the
-client goes away mid-request, so a cancelled request died as a 500 the
-operator then had to explain (UID-052). a2wsgi runs the WSGI app on its own
-thread pool and treats ``http.disconnect`` as a disconnect rather than a
-broken executor.
-
-Static assets and SSE still bypass the bridge, but not for that reason: see
-``_handle_static`` and ``_handle_sse``.
+"""ASGI runtime for Oneirodex.
+
+FastAPI owns application route dispatch. Flask still initializes the shared
+configuration, extension registry, and mapped models during this migration;
+the WSGI fallback has been retired after the legacy method/path inventory
+reached full native coverage.
"""
from __future__ import annotations
+from oneirodex.runtime_native import (
+ is_admin_redirect_path,
+ is_email_confirmation_page_route,
+ is_game_edit_route,
+ is_game_image_refresh_route,
+ is_game_images_edit_route,
+ is_library_edit_route,
+ is_local_game_image_route,
+ is_single_image_upload_route,
+ is_numeric_admin_detail,
+)
+
import asyncio
import json
import mimetypes
@@ -25,31 +29,84 @@
import tempfile
import uuid
from pathlib import Path
+from importlib import import_module
import aiofiles
-from a2wsgi import WSGIMiddleware
from werkzeug.utils import secure_filename
-from oneirodex import create_app, db
-from oneirodex.async_streaming import (
- async_generate_zipstream_response,
- get_content_type_for_file,
+from sqlalchemy import select
+
+
+def create_app(config_object=None):
+ """Lazily expose the legacy app factory for compatibility startup/tests."""
+ from oneirodex import create_app as legacy_create_app
+
+ return legacy_create_app(config_object)
+
+
+_DEFAULT_CREATE_APP = create_app
+
+
+class _LazyDatabaseExport:
+ """Resolve Flask-SQLAlchemy only when a compatibility handler needs it."""
+
+ def __getattr__(self, name):
+ from oneirodex import db as legacy_db
+
+ return getattr(legacy_db, name)
+
+
+db = _LazyDatabaseExport()
+
+
+def _lazy_callable(module_name, attribute):
+ """Create a compatibility callable without importing Flask-owned helpers."""
+ def call(*args, **kwargs):
+ helper = getattr(import_module(module_name), attribute)
+ return helper(*args, **kwargs)
+
+ call.__name__ = attribute
+ return call
+
+
+async_generate_zipstream_response = _lazy_callable(
+ 'oneirodex.async_streaming', 'async_generate_zipstream_response'
)
-from oneirodex.models import DownloadRequest, Game, User
-from oneirodex.utils.api_tokens import forbidden_scopes_for_role
-from oneirodex.utils.event_logging import log_system_event
-from oneirodex.utils.library_acl import user_can_access_game
-from oneirodex.utils.play_url import library_platform_key
-from oneirodex.utils.rom_archive import (
- ArchiveRomError,
- bundle_playable_rom_zip,
- resolve_playable_rom_path,
+get_content_type_for_file = _lazy_callable(
+ 'oneirodex.async_streaming', 'get_content_type_for_file'
)
-from oneirodex.utils.security import get_allowed_base_directories, is_safe_path, open_plain_file_within
-from oneirodex.utils.security_headers import baseline_static_headers
-from oneirodex.utils.library_paths import library_dir
-from oneirodex.utils.static_files import resolve_served_static, static_access
-from sqlalchemy import select
+forbidden_scopes_for_role = _lazy_callable(
+ 'oneirodex.utils.api_tokens', 'forbidden_scopes_for_role'
+)
+log_system_event = _lazy_callable('oneirodex.utils.event_logging', 'log_system_event')
+user_can_access_game = _lazy_callable(
+ 'oneirodex.utils.library_acl', 'user_can_access_game'
+)
+library_platform_key = _lazy_callable('oneirodex.utils.play_url', 'library_platform_key')
+bundle_playable_rom_zip = _lazy_callable(
+ 'oneirodex.utils.rom_archive', 'bundle_playable_rom_zip'
+)
+resolve_playable_rom_path = _lazy_callable(
+ 'oneirodex.utils.rom_archive', 'resolve_playable_rom_path'
+)
+get_allowed_base_directories = _lazy_callable(
+ 'oneirodex.utils.security', 'get_allowed_base_directories'
+)
+is_safe_path = _lazy_callable('oneirodex.utils.security', 'is_safe_path')
+open_plain_file_within = _lazy_callable(
+ 'oneirodex.utils.security', 'open_plain_file_within'
+)
+baseline_static_headers = _lazy_callable(
+ 'oneirodex.utils.security_headers', 'baseline_static_headers'
+)
+library_dir = _lazy_callable('oneirodex.utils.library_paths', 'library_dir')
+resolve_served_static = _lazy_callable(
+ 'oneirodex.utils.static_files', 'resolve_served_static'
+)
+resolve_static_path = _lazy_callable(
+ 'oneirodex.utils.static_files', 'resolve_static_path'
+)
+static_access = _lazy_callable('oneirodex.utils.static_files', 'static_access')
#: How much of a served file each ``http.response.body`` message carries.
@@ -105,76 +162,4077 @@ def _rom_location(rom_path, cache_dir, real_game, game_is_dir):
return real_game, os.path.dirname(real_game), True
-# Proper ASGI application with lifespan protocol support
-#: A visitor cancelling a download, not a server fault.
-_CLIENT_GONE = (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)
+# Proper ASGI application with lifespan protocol support
+#: A visitor cancelling a download, not a server fault.
+_CLIENT_GONE = (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)
+
+
+def _install_native_api_error_handlers(native_app, config, logger) -> None:
+ """Keep native API failures in the shared envelope without Flask context."""
+ from fastapi import Request
+ from starlette.exceptions import HTTPException as StarletteHTTPException
+ from starlette.responses import JSONResponse, Response
+
+ from oneirodex.utils.api_contract import api_error_payload
+ from oneirodex.utils.error_envelope import http_error_code
+ from oneirodex.utils.security_headers import security_header_values
+
+ safe_messages = {
+ 400: 'Bad request',
+ 401: 'Unauthorized',
+ 403: 'Forbidden',
+ 404: 'Not found',
+ 405: 'Method not allowed',
+ 409: 'Conflict',
+ 413: 'Request payload is too large',
+ 422: 'Invalid request',
+ 429: 'Too many requests',
+ }
+
+ async def http_error(_request: Request, exc: StarletteHTTPException):
+ status = int(exc.status_code or 500)
+ payload, _ = api_error_payload(
+ safe_messages.get(status, 'Request failed'),
+ code=http_error_code(status),
+ status=status,
+ )
+ response = JSONResponse(payload, status_code=status)
+ for key, value in security_header_values(config).items():
+ response.headers.setdefault(key, value)
+ for key, value in (getattr(exc, 'headers', None) or {}).items():
+ response.headers.setdefault(key, value)
+ return response
+
+ async def unexpected_error(_request: Request, _exc: Exception):
+ logger.exception('Unhandled native API request failure')
+ payload, status = api_error_payload(
+ 'Something went wrong on our end. The error has been logged.',
+ code='internal',
+ )
+ response = JSONResponse(payload, status_code=status)
+ for key, value in security_header_values(config).items():
+ response.headers.setdefault(key, value)
+ return response
+
+ native_app.add_exception_handler(StarletteHTTPException, http_error)
+ native_app.add_exception_handler(Exception, unexpected_error)
+
+
+def _is_link_or_escape_refusal(exc: OSError) -> bool:
+ """True when ``open_plain_file_within`` refused the file itself (a link,
+ not a regular file, or outside its folder), as opposed to the file being
+ locked, unreadable or failing with an I/O error."""
+ import errno
+
+ if exc.errno in (errno.ELOOP, errno.EINVAL):
+ return True
+ return exc.errno == errno.EACCES and exc.strerror == 'file is outside its folder'
+
+
+def _is_native_library_api_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 3 and parts[:2] == ['api', 'library'] and bool(parts[2])
+ ) or (
+ len(parts) == 4 and parts[:2] == ['api', 'library']
+ and bool(parts[2]) and parts[3] == 'watch'
+ ) or (
+ len(parts) == 4 and parts[:3] == ['api', 'admin', 'libraries']
+ and bool(parts[3]) and parts[3] != 'batch'
+ )
+
+
+def _is_native_library_tools_path(path: str) -> bool:
+ return path in {
+ '/api/library_tools/manual_identify/preview',
+ '/api/library_tools/manual_identify/list',
+ '/api/library_tools/propose_leaf_libraries',
+ '/api/library_tools/import_leaf_libraries/preview',
+ '/api/library_tools/proposals',
+ '/api/library_tools/proposals/scan_roots',
+ '/api/library_tools/doctor/dry_run',
+ '/api/library_tools/rename/preview',
+ '/api/library_tools/proposals/approve',
+ '/api/library_tools/doctor/write_proposals',
+ '/api/library_tools/link_existing_game',
+ '/api/library_tools/rename/apply',
+ '/api/library_tools/doctor/apply_renames',
+ '/api/library_tools/backfill_steam_metadata',
+ '/api/library_tools/check_freshness',
+ }
+
+
+def _is_native_library_admin_mutation_path(path: str) -> bool:
+ return path in {
+ '/api/admin/libraries',
+ '/api/admin/libraries/scan',
+ '/api/admin/libraries/refresh_all',
+ '/api/admin/libraries/batch/scan',
+ '/api/admin/libraries/batch/delete',
+ }
+
+
+def _is_native_support_reads_path(path: str) -> bool:
+ if path == '/api/support/tickets':
+ return True
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 4 and parts[:3] == ['api', 'support', 'tickets']
+ and parts[3].isdigit()
+ )
+
+
+def _is_native_theme_fonts_read_path(path: str) -> bool:
+ return path == '/api/theme/fonts'
+
+
+def _is_native_admin_theme_font_path(path: str, method: str) -> bool:
+ return (
+ (path == '/admin/api/theme/fonts' and method == 'POST')
+ or (path.startswith('/admin/api/theme/fonts/') and method == 'DELETE')
+ )
+
+
+def _is_native_admin_art_studio_download_path(path: str, method: str) -> bool:
+ return path.startswith('/admin/api/art-studio/download/') and method == 'GET'
+
+
+def _is_native_image_refresh_progress_path(path: str, method: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 2 and parts[0] == 'check_image_refresh_progress'
+ and bool(parts[1]) and method in ('GET', 'HEAD')
+ )
+
+
+def _is_native_library_delete_progress_path(path: str, method: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 2 and parts[0] == 'check_deletion_progress'
+ and bool(parts[1]) and method in ('GET', 'HEAD')
+ )
+
+
+def _is_native_library_delete_sse_path(path: str, method: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 2 and parts[0] == 'delete_library_progress'
+ and bool(parts[1]) and method in ('GET', 'HEAD')
+ )
+
+
+def _is_native_scan_job_mutation_path(path: str, method: str) -> bool:
+ if method != 'POST':
+ return False
+ if path in {'/clear_all_scan_jobs', '/clear_scan_jobs'}:
+ return True
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 2 and parts[0] in {
+ 'cancel_scan_job', 'delete_scan_job', 'restart_scan_job',
+ }
+ and bool(parts[1])
+ )
+
+
+def _is_native_library_delete_form_path(path: str, method: str) -> bool:
+ parts = path.strip('/').split('/')
+ return method == 'POST' and len(parts) == 2 and parts[0] == 'delete_full_library' and bool(parts[1])
+
+
+def _is_native_game_delete_path(path: str, method: str) -> bool:
+ if path in {'/delete_folder', '/delete_full_game', '/delete_image'}:
+ return method == 'POST'
+ parts = path.strip('/').split('/')
+ return method == 'POST' and len(parts) == 2 and parts[0] == 'delete_game' and bool(parts[1])
+
+
+def _is_native_unmatched_compat_mutation_path(path: str, method: str) -> bool:
+ if method != 'POST':
+ return False
+ if path in {'/delete_all_unmatched_folders', '/update_unmatched_folder_status'}:
+ return True
+ parts = path.strip('/').split('/')
+ return len(parts) == 2 and parts[0] in {
+ 'clear_unmatched_entry', 'toggle_ignore_status',
+ } and bool(parts[1])
+
+
+def _is_native_admin_delete_image_path(path: str, method: str) -> bool:
+ value = path.removeprefix('/admin/api/delete_image/')
+ return method == 'DELETE' and bool(value) and value.isdigit()
+
+
+def _is_native_provider_catalog_path(path: str) -> bool:
+ return path in {
+ '/api/providers', '/api/patch-catalog/providers',
+ '/api/patch-catalog/search', '/api/patch-catalog/attach',
+ }
+
+
+def _is_native_integrations_inventory_path(path: str) -> bool:
+ return path == '/api/admin/integrations/inventory'
+
+
+def _is_native_integration_settings_path(path: str) -> bool:
+ return path in {'/api/admin/integrations/oidc', '/api/admin/integrations/community', '/admin/integrations/oidc/save', '/admin/integrations/community'}
+
+
+def _is_native_artwork_apply_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5 and parts[:2] == ['api', 'games']
+ and bool(parts[2]) and parts[3:] == ['artwork', 'steamgriddb']
+ )
+
+
+def _is_native_admin_scan_filters_path(path: str) -> bool:
+ if path == '/api/admin/scan-filters':
+ return True
+ parts = path.strip('/').split('/')
+ return len(parts) == 4 and parts[:3] == ['api', 'admin', 'scan-filters'] and parts[3].isdigit()
+
+
+def _is_native_admin_discovery_sections_path(path: str) -> bool:
+ if path in {
+ '/admin/api/discovery_sections',
+ '/admin/api/discovery_sections/order',
+ '/admin/api/discovery_sections/visibility',
+ }:
+ return True
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 4
+ and parts[:3] == ['admin', 'api', 'discovery_sections']
+ and parts[3].isdigit()
+ ) or (
+ len(parts) == 5
+ and parts[:3] == ['admin', 'api', 'discovery_sections']
+ and parts[3].isdigit()
+ and parts[4] in {'schedule', 'pin'}
+ )
+
+
+def _is_native_admin_invites_path(path: str) -> bool:
+ return path in {'/admin/api/invites', '/admin/api/invites/quota', '/admin/manage_invites'}
+
+
+def _is_native_admin_users_read_path(path: str) -> bool:
+ if path == '/admin/api/users':
+ return True
+ parts = path.strip('/').split('/')
+ return len(parts) == 4 and parts[:3] == ['admin', 'api', 'user'] and parts[3].isdigit()
+
+
+def _is_native_admin_user_create_path(path: str) -> bool:
+ return path == '/admin/api/user/0'
+
+
+def _is_native_admin_user_mutation_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 4 and parts[:3] == ['admin', 'api', 'user']
+ and parts[3].isdigit() and int(parts[3]) > 0
+ )
+
+
+def _is_native_admin_whitelist_path(path: str) -> bool:
+ if path in {'/admin/api/whitelist', '/admin/whitelist'}:
+ return True
+ parts = path.strip('/').split('/')
+ return len(parts) == 3 and parts[:2] == ['admin', 'whitelist'] and parts[2].isdigit()
+
+
+def _is_native_admin_plugin_path(path: str) -> bool:
+ if path == '/api/plugins':
+ return True
+ parts = path.strip('/').split('/')
+ return len(parts) == 3 and parts[:2] == ['api', 'plugins'] and bool(parts[2])
+
+
+def _is_native_admin_newsletter_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return path == '/api/admin/newsletters' or (
+ len(parts) == 4 and parts[:3] == ['api', 'admin', 'newsletters']
+ and parts[3].isdigit()
+ )
+
+
+def _is_native_auth_confirmation_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return len(parts) == 4 and parts[:3] == ['api', 'auth', 'confirm'] and bool(parts[3])
+
+
+def _is_native_auth_page_path(path: str) -> bool:
+ if path in {'/login', '/register', '/reset_password_request'}:
+ return True
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 2 and parts[0] == 'confirm' and bool(parts[1])
+ ) or (
+ len(parts) == 2 and parts[0] == 'reset_password' and bool(parts[1])
+ )
+
+
+def _is_native_setup_page_path(path: str) -> bool:
+ return path in {
+ '/setup', '/setup/smtp', '/setup/features', '/setup/igdb',
+ '/setup/integrations', '/setup/library',
+ }
+
+
+def _is_native_password_reset_confirm_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return len(parts) == 4 and parts[:3] == ['api', 'auth', 'password-reset'] and bool(parts[3])
+
+
+def _is_native_invite_delete_path(path: str) -> bool:
+ return re.fullmatch(r'/delete_invite/[^/]+', path) is not None
+
+
+def _is_native_download_request_reads_path(path: str) -> bool:
+ if path in {'/api/my_downloads', '/api/admin/download-requests'}:
+ return True
+ parts = path.strip('/').split('/')
+ return (
+ (len(parts) == 2 and parts[0] == 'check_download_status')
+ or (len(parts) == 2 and parts[0] == 'delete_download' and parts[1].isdigit())
+ or (len(parts) == 2 and parts[0] == 'delete_download_request' and parts[1].isdigit())
+ or (len(parts) == 3 and parts[:2] == ['api', 'delete_download'] and parts[2].isdigit())
+ )
+
+
+def _is_native_manual_identify_read_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ path == '/api/admin/manual-identify/options'
+ or (len(parts) == 4 and parts[:3] == ['api', 'admin', 'manual-identify'])
+ )
+
+
+def _is_native_manual_identify_mutation_path(path: str, method: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ path == '/api/admin/manual-identify' and method == 'POST'
+ ) or (
+ len(parts) == 4
+ and parts[:3] == ['api', 'admin', 'manual-identify']
+ and method == 'PUT'
+ )
+
+
+def _is_native_retroachievements_settings_path(path: str) -> bool:
+ if path in {
+ '/api/admin/integrations/retroachievements',
+ '/api/retroachievements/status',
+ '/api/retroachievements/match',
+ '/api/me/retroachievements',
+ }:
+ return True
+ parts = path.strip('/').split('/')
+ return len(parts) == 4 and parts[:2] == ['api', 'games'] and parts[3] == 'achievements'
+
+
+def _is_native_admin_policy_settings_path(path: str) -> bool:
+ return path in {'/api/admin/remote-play/config', '/api/admin/scan-match/config'}
+
+
+def _is_native_launcher_import_read_path(path: str) -> bool:
+ return path == '/api/imports/launchers'
+
+
+def _is_native_download_mutation_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (path.startswith('/download_game/') and len(parts) == 2 and bool(parts[1])) or (
+ path.startswith('/download_other/') and len(parts) == 4 and all(parts[1:])
+ ) or (
+ len(parts) == 5 and parts[:2] == ['api', 'games']
+ and parts[2] and parts[3:] == ['versions', 'cleanup_orphans']
+ ) or (
+ len(parts) == 4 and parts[:3] == ['api', 'downloads', 'games'] and parts[3]
+ )
+
+
+def _is_native_support_mutation_path(path: str) -> bool:
+ if path == '/api/support/tickets':
+ return True
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5 and parts[:3] == ['api', 'support', 'tickets']
+ and parts[3].isdigit() and parts[4] == 'resolve'
+ )
+
+
+def _is_native_launcher_import_mutation_path(path: str) -> bool:
+ return path in {'/api/imports/playnite', '/api/imports/launcher'}
+
+
+def _is_native_chat_messages_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'chat', 'channels']
+ and parts[3].isdigit()
+ and parts[4] == 'messages'
+ )
+
+
+def _is_native_chat_attachment_upload_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'chat', 'channels']
+ and parts[3].isdigit()
+ and parts[4] == 'attachments'
+ )
+
+
+def _is_native_layout_preset_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:4] == ['api', 'layouts', 'detail', 'presets']
+ and parts[4].isdigit()
+ )
+
+
+def _is_native_chat_mute_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'chat', 'channels']
+ and parts[3].isdigit()
+ and parts[4] in ('mute', 'archive', 'leave')
+ )
+
+
+def _is_native_chat_reaction_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'chat', 'messages']
+ and parts[3].isdigit()
+ and parts[4] == 'reactions'
+ )
+
+
+def _is_native_chat_space_read_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'chat', 'spaces']
+ and parts[3].isdigit()
+ and parts[4] in ('members', 'invites')
+ )
+
+
+def _is_native_chat_space_member_delete_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 6
+ and parts[:3] == ['api', 'chat', 'spaces']
+ and parts[3].isdigit()
+ and parts[4] == 'members'
+ and parts[5].isdigit()
+ )
+
+
+def _is_native_chat_space_channel_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'chat', 'spaces']
+ and parts[3].isdigit()
+ and parts[4] == 'channels'
+ )
+
+
+def _is_native_chat_space_invite_revoke_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 6
+ and parts[:4] == ['api', 'chat', 'spaces', 'invites']
+ and parts[4].isdigit()
+ and parts[5] == 'revoke'
+ )
+
+
+def _is_native_chat_emoji_delete_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return len(parts) == 4 and parts[:3] == ['api', 'chat', 'emoji'] and bool(parts[3])
+
+
+def _is_native_chat_voice_room_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 7
+ and parts[:3] == ['api', 'chat', 'spaces']
+ and parts[3].isdigit()
+ and parts[4] == 'voice'
+ and parts[5].isdigit()
+ and parts[6] == 'room'
+ )
+
+
+def _is_native_game_read_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 4
+ and parts[0:2] == ['api', 'games']
+ and bool(parts[2])
+ and parts[3] in {
+ 'details', 'more_from', 'editions', 'versions', 'freshness', 'assists',
+ 'input_family',
+ }
+ ) or (
+ len(parts) == 5
+ and parts[0:2] == ['api', 'games']
+ and bool(parts[2])
+ and parts[3:] == ['freshness', 'check']
+ )
+
+
+def _is_native_playtime_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ if parts in (['api', 'playtime', 'me'], ['api', 'playtime', 'sessions']):
+ return True
+ if (
+ len(parts) == 4 and parts[:3] == ['api', 'playtime', 'share']
+ and parts[3].endswith('.svg') and len(parts[3]) > 4
+ ):
+ return True
+ if len(parts) == 4 and parts[:3] == ['api', 'playtime', 'games'] and parts[3]:
+ return True
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'playtime', 'sessions']
+ and parts[3].isdigit()
+ and parts[4] in {'heartbeat', 'stop'}
+ )
+
+
+def _is_native_related_media_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 4
+ and parts[:2] == ['api', 'games']
+ and bool(parts[2])
+ and parts[3] == 'related_media'
+ ) or (
+ len(parts) == 5
+ and parts[:2] == ['api', 'games']
+ and bool(parts[2])
+ and parts[3] == 'related_media'
+ and parts[4].isdigit()
+ )
+
+
+def _is_native_game_asset_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 3
+ and parts[0] == 'api'
+ and parts[1] in {'game_screenshots', 'game_images'}
+ and bool(parts[2])
+ )
+
+
+def _is_native_member_game_state_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 3
+ and parts[0] == 'api'
+ and parts[1] in {
+ 'check_favorite', 'toggle_favorite', 'get_game_status', 'set_game_status',
+ }
+ and bool(parts[2])
+ )
+
+
+def _is_native_member_profile_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ if len(parts) == 4:
+ return parts[:2] == ['api', 'users'] and parts[2].isdigit() and parts[3] == 'profile'
+ return (
+ len(parts) == 5
+ and parts[:2] == ['api', 'users']
+ and parts[2].isdigit()
+ and parts[3] == 'compare'
+ and parts[4].isdigit()
+ )
+
+
+def _is_native_vr_game_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return len(parts) == 4 and parts[:3] == ['api', 'vr', 'games'] and bool(parts[3])
+
+
+def _is_native_vr_profile_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 4
+ and parts[:2] == ['api', 'games']
+ and bool(parts[2])
+ and parts[3] in {'vr_profiles', 'vr_compat'}
+ ) or (
+ len(parts) == 5
+ and parts[:2] == ['api', 'games']
+ and bool(parts[2])
+ and parts[3] == 'vr_profiles'
+ and bool(parts[4])
+ )
+
+
+def _is_native_saved_filter_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 4
+ and parts[:3] == ['api', 'filters', 'saved']
+ and parts[3].isdigit()
+ )
+
+
+def _is_native_ownership_candidate_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'ownership', 'titles']
+ and parts[3].isdigit()
+ and parts[4] == 'candidates'
+ )
+
+
+def _is_native_ownership_decision_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'ownership', 'titles']
+ and parts[3].isdigit()
+ and parts[4] == 'match'
+ ) or (
+ len(parts) == 6
+ and parts[:3] == ['api', 'ownership', 'titles']
+ and parts[3].isdigit()
+ and parts[4:] == ['match', 'undo']
+ )
+
+
+def _is_native_ownership_cancel_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:2] == ['api', 'ownership']
+ and bool(parts[2])
+ and parts[3:] == ['sync', 'cancel']
+ )
+
+
+def _is_native_ownership_action_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 3
+ and parts[:2] == ['api', 'ownership']
+ and bool(parts[2])
+ and parts[2] != 'sync-all'
+ ) or (
+ len(parts) == 4
+ and parts[:2] == ['api', 'ownership']
+ and bool(parts[2])
+ and parts[3] in {'sync', 'csv'}
+ ) or path == '/api/ownership/sync-all'
+
+
+def _is_native_game_server_path(path: str) -> bool:
+ if path in ('/api/game-servers', '/api/game-servers/acl-check'):
+ return True
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 3
+ and parts[:2] == ['api', 'game-servers']
+ and bool(parts[2])
+ ) or (
+ len(parts) == 4
+ and parts[:2] == ['api', 'game-servers']
+ and bool(parts[2])
+ and parts[3] == 'status'
+ )
+
+
+def _is_native_admin_ownership_sync_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 7
+ and parts[:3] == ['api', 'admin', 'ownership']
+ and parts[3] == 'connections'
+ and parts[4].isdigit()
+ and bool(parts[5])
+ and parts[6] in {'sync', 'cancel'}
+ )
+
+
+def _is_native_emulator_mutation_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ if len(parts) == 3 and parts[:2] == ['api', 'emulator-bios']:
+ return parts[2] in {'scan', 'install'}
+ if path == '/api/emulator-bios':
+ return True
+ if len(parts) < 4 or parts[:2] != ['api', 'games'] or not parts[2]:
+ return False
+ if len(parts) == 4 and parts[3] in {'cheats', 'saves', 'pc_cheats'}:
+ return True
+ return (
+ len(parts) >= 5
+ and parts[3] in {'cheats', 'saves', 'pc_cheats'}
+ and bool('/'.join(parts[4:]))
+ and (parts[3] != 'pc_cheats' or len(parts) == 5 and parts[4].isdigit())
+ )
+
+
+def _is_native_game_mods_path(path: str) -> bool:
+ if path == '/api/mods/summary':
+ return True
+ parts = path.strip('/').split('/')
+ if len(parts) < 4 or parts[:2] != ['api', 'games'] or not parts[2] or parts[3] != 'mods':
+ return False
+ if len(parts) == 4:
+ return True
+ if len(parts) == 5:
+ return parts[4] in {'catalog', 'profiles', 'pack'} or bool(parts[4])
+ if len(parts) == 6:
+ return parts[4] == 'profiles' and bool(parts[5])
+ return len(parts) == 7 and parts[4] == 'profiles' and bool(parts[5]) and parts[6] in {'activate', 'export'}
+
+
+def _is_native_unmatched_mutation_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ if len(parts) != 4 or parts[:2] != ['api', 'unmatched_folders']:
+ return False
+ if parts[2] == 'batch':
+ return parts[3] in {'clear', 'mark_kind', 'fix', 'amend'}
+ return bool(parts[2]) and parts[3] in {
+ 'name', 'fix', 'mark_kind', 'retry_match', 'place_update',
+ }
+
+
+def _is_native_unmatched_feedback_path(path: str) -> bool:
+ if path in {
+ '/api/unmatched_folders/reclassify_duplicates',
+ '/api/unmatched_folders/backfill_suggested_kind',
+ '/api/unmatched/bad_match_reasons',
+ }:
+ return True
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 4 and parts[:2] == ['api', 'unmatched']
+ and bool(parts[2]) and parts[3] == 'bad_match'
+ )
+
+
+def _is_native_emulator_platform_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return len(parts) == 3 and parts[:2] == ['api', 'emulators'] and bool(parts[2])
+
+
+def _is_native_file_type_path(path: str) -> bool:
+ return path in ('/api/file_types/allowed', '/api/file_types/ignored')
+
+
+def _is_native_free_games_claim_path(path: str) -> bool:
+ parts = path.strip('/').split('/')
+ return (
+ len(parts) == 5
+ and parts[:3] == ['api', 'news', 'free-games']
+ and parts[3].isdigit()
+ and parts[4] == 'claim-assist'
+ )
+
+
+class LazyASGIApp:
+ def __init__(self):
+ self._app = None
+ self._flask_app = None
+ self._native_sqlalchemy_engine = None
+ self._runtime_config = None
+ self._init_lock = asyncio.Lock()
+ self._static_root: Path | None = (
+ Path(__file__).resolve().parent / 'oneirodex' / 'static'
+ )
+ self._native_admin_pilot = None
+ self._native_admin_routes = frozenset()
+ self._native_read_api_app = None
+ self._native_member_shell_app = None
+ self._native_read_api_routes = frozenset({
+ '/',
+ '/index',
+ '/restricted',
+ '/api/trailers/filters',
+ '/api/trailers/random',
+ '/api/acquire/status',
+ '/api/acquire/search',
+ '/api/acquire/download',
+ '/api/social/status',
+ '/api/social/friends',
+ '/api/activity',
+ '/api/search',
+ '/api/get_company_role',
+ '/api/search_igdb_by_id',
+ '/api/search_igdb_by_name',
+ '/api/storage/hardlink/preview',
+ '/api/storage/hardlink/apply',
+ '/api/get_next_custom_igdb_id',
+ '/api/move_game_to_library',
+ '/api/favorites',
+ '/api/current_user_role',
+ '/api/icon-packs',
+ '/api/check_username',
+ '/api/vr/catalog',
+ '/api/filters/bundle',
+ '/api/library_platforms',
+ '/api/get_libraries',
+ '/api/scan_jobs_status',
+ '/api/path/open',
+ '/logout',
+ '/demo',
+ '/api/auth/login',
+ '/api/auth/register',
+ '/api/admin/newsletters',
+ '/api/admin/server-settings',
+ '/api/check_igdb_id',
+ '/api/get_cover_thumbnail',
+ '/api/reorder_libraries',
+ '/api/admin/libraries/batch/edit',
+ '/api/library/preview-cropped-image',
+ '/api/genres',
+ '/api/themes',
+ '/api/game_modes',
+ '/api/player_perspectives',
+ '/api/igdb_platforms',
+ '/api/filters/fields',
+ '/api/filters/saved',
+ '/api/filters/preview',
+ '/api/games/batch/favorite',
+ '/api/games/batch/status',
+ '/api/games/batch/wishlist',
+ '/api/games/batch/freshness/check',
+ '/api/admin/freshness/refresh',
+ '/api/games/batch/refresh_images',
+ '/api/playtime/me',
+ '/api/oidc/status',
+ '/api/remote-play/status',
+ '/api/settings/module-status',
+ '/api/health/library',
+ '/api/quality-profiles',
+ '/api/storage/status',
+ '/api/admin/malware-scan/status',
+ '/api/admin/malware-scan',
+ '/api/admin/challenge-solver/status',
+ '/api/admin/challenge-solver/config',
+ '/api/admin/challenge-solver/test',
+ '/api/admin/ambient-lighting/status',
+ '/api/admin/ambient-lighting/config',
+ '/api/admin/ambient-lighting/test',
+ '/api/rom-translate/capabilities',
+ '/api/attract-mode/settings',
+ '/api/attract-mode/user-override',
+ '/api/setup/state',
+ '/api/setup/step',
+ '/api/admin/statistics',
+ '/admin/statistics/data',
+ '/api/hltb/stats',
+ '/api/hltb/bulk-refresh',
+ '/api/admin/games_search',
+ '/api/palette/suggest',
+ '/api/admin/integrations/metadata-providers',
+ '/api/licensed-catalog',
+ '/api/licensed-catalog/status',
+ '/api/licensed-catalog/refresh',
+ '/api/search_metadata/sources',
+ '/api/search_metadata',
+ '/api/ownership/providers',
+ '/api/ownership/connections',
+ '/api/ownership',
+ '/api/ownership/titles',
+ '/api/emulators',
+ '/api/emulator-profiles',
+ '/api/emulator/health',
+ '/api/openapi.json',
+ '/api/openapi.yaml',
+ '/api/export/esde',
+ '/api/export/pegasus',
+ '/api/browser-player-settings',
+ '/api/check_path_availability',
+ '/api/library_roots',
+ '/api/browse_folders_ss',
+ '/api/unmatched_folders',
+ '/api/unmatched_folders/duplicates',
+ '/api/unmatched_folders/fix_logs',
+ '/api/unmatched_folders/export',
+ '/api/admin/themes',
+ '/admin/api/art-studio/stock',
+ '/admin/api/art-studio/system-marks',
+ '/admin/api/art-studio/system-marks/lab',
+ '/admin/api/theme/fonts',
+ '/admin/api/system/reset',
+ '/admin/api/art-studio/preview',
+ '/admin/api/art-studio/generate',
+ '/admin/api/art-studio/stock/generate',
+ '/admin/api/art-studio/system-marks/generate',
+ '/admin/api/art-studio/apply',
+ '/admin/api/art-studio/apply-batch',
+ '/admin/api/art-studio/batch-generate',
+ '/admin/api/artwork/generate',
+ '/admin/api/artwork/generate/batch',
+ '/admin/api/image_queue_list',
+ '/admin/api/download_images',
+ '/admin/api/images/batch_upload',
+ '/admin/api/covers/search',
+ '/admin/api/covers/batch/search',
+ '/admin/api/covers/apply',
+ '/admin/api/covers/batch/apply',
+ '/admin/api/artwork/auto-pick',
+ '/api/admin/chat-emoji',
+ '/api/chat/emoji',
+ '/api/chat/channels',
+ '/api/chat/spaces',
+ '/api/chat/search',
+ '/api/chat/dm',
+ '/api/chat/spaces/join',
+ '/api/layouts/detail',
+ '/api/layouts/detail/mine',
+ '/api/layouts/detail/presets',
+ '/api/admin/smtp/settings',
+ '/api/admin/smtp/test',
+ '/admin/smtp_settings',
+ '/admin/smtp_test',
+ '/api/admin/igdb/settings',
+ '/admin/igdb_settings',
+ '/admin/test_igdb',
+ '/api/admin/reference-sets',
+ '/api/reference-sets',
+ '/api/reference-sets/rehash',
+ '/api/reference-sets/repair-preview',
+ '/api/set-completion',
+ '/api/admin/igdb/test',
+ '/api/theme/fonts.css',
+ '/api/admin/loading-icon/config',
+ '/api/admin/themes/loading-icons',
+ '/api/loading-icon',
+ '/api/ai/status',
+ '/api/ai/config',
+ '/api/ai/triage',
+ '/api/ai/doctor-notes',
+ '/api/ai/apply-triage',
+ '/api/admin/library_platforms',
+ '/api/arr/status',
+ '/api/arr/config',
+ '/api/arr/indexers',
+ '/api/arr/module',
+ '/api/arr/search',
+ '/api/arr/download',
+ '/api/arr/hardlink/preview',
+ '/api/arr/hardlink/apply',
+ '/api/discover/sections',
+ '/api/discover/surprise',
+ '/api/discover/pins',
+ '/api/account/summary',
+ '/api/account/invites',
+ '/api/account/preferences',
+ '/settings_panel',
+ '/api/locale',
+ '/api/account/avatar/stock',
+ '/api/account/avatar',
+ '/settings_profile_edit',
+ '/api/account/password',
+ '/settings_password',
+ '/api/calendar',
+ '/api/tokens',
+ '/api/client/capabilities',
+ '/api/client/lifecycle',
+ '/api/client/heartbeat',
+ '/api/client/commands',
+ '/api/announcements',
+ '/api/collections',
+ '/api/requests',
+ '/api/notifications',
+ '/api/notifications/read',
+ '/api/notifications/preferences',
+ '/api/admin/notify-bus/test',
+ '/api/events/publish_test',
+ '/api/updates/wanted',
+ '/api/updates/wanted/fulfill',
+ '/api/updates/inbox',
+ '/api/updates/store_search',
+ '/api/updates/scan',
+ '/api/news/gaming',
+ '/api/news/free-games',
+ '/api/rtc/status',
+ '/api/rtc/token',
+ '/browse_games',
+ })
+
+ async def _ensure_flask(self):
+ """Initialize native configuration and routers once.
+
+ Flask app injection remains available to compatibility tests which
+ replace ``create_app`` explicitly. Normal ASGI startup builds only the
+ framework-independent runtime context.
+ """
+ if self._app is not None:
+ return
+ async with self._init_lock:
+ if self._app is not None:
+ return
+ from oneirodex.runtime_native.app_config import build_runtime_config
+ from oneirodex.runtime_native.db_session import create_native_engine
+ from oneirodex.runtime_native.runtime_context import NativeRuntimeContext
+
+ compatibility_app_injected = create_app is not _DEFAULT_CREATE_APP
+ if compatibility_app_injected:
+ # Existing route-parity tests can inject an in-memory Flask
+ # host without making it part of the normal ASGI runtime.
+ self._flask_app = create_app()
+ self._runtime_config = dict(self._flask_app.config)
+ else:
+ self._runtime_config = build_runtime_config()
+ package_root = Path(__file__).resolve().parent / 'oneirodex'
+ self._flask_app = NativeRuntimeContext(
+ self._runtime_config, package_root=package_root,
+ )
+ database_url = self._runtime_config.get('SQLALCHEMY_DATABASE_URI')
+ if self._native_sqlalchemy_engine is None and database_url:
+ self._native_sqlalchemy_engine = create_native_engine(self._runtime_config)
+ if isinstance(self._flask_app, NativeRuntimeContext):
+ self._flask_app.native_engine = self._native_sqlalchemy_engine
+ self._static_root = Path(self._flask_app.static_folder or '').resolve()
+ # Native API handlers share a FastAPI app. Compatibility route
+ # adapters receive config/logger/url helpers, not a Flask runtime.
+ from fastapi import FastAPI
+ from oneirodex.runtime_native.admin_pilot import _native_auth_backend
+ from oneirodex.runtime_native.trailers import (
+ TrailerAuthCallbacks,
+ mount_trailers_routes,
+ )
+ from oneirodex.runtime_native.browse import mount_browse_routes
+ from oneirodex.runtime_native.browse_locations import mount_browse_locations_routes
+ from oneirodex.runtime_native.acquire_status import mount_acquire_status_routes
+ from oneirodex.runtime_native.acquire_actions import mount_acquire_actions_routes
+ from oneirodex.runtime_native.social_reads import mount_social_reads_routes
+ from oneirodex.runtime_native.game_reads import mount_game_reads_routes
+ from oneirodex.runtime_native.game_delete_mutations import mount_game_delete_mutation_routes
+ from oneirodex.runtime_native.filter_reads import mount_filter_reads_routes
+ from oneirodex.runtime_native.playtime import mount_playtime_routes
+ from oneirodex.runtime_native.related_media import mount_related_media_routes
+ from oneirodex.runtime_native.saved_filters import mount_saved_filter_routes
+ from oneirodex.runtime_native.batch_favorite import mount_batch_favorite_routes
+ from oneirodex.runtime_native.batch_status import mount_batch_status_routes
+ from oneirodex.runtime_native.batch_wishlist import mount_batch_wishlist_routes
+ from oneirodex.runtime_native.batch_refresh import mount_batch_refresh_routes
+ from oneirodex.runtime_native.member_game_state import mount_member_game_state_routes
+ from oneirodex.runtime_native.db_session import native_engine_session_scope
+ from oneirodex.runtime_native.favorites import mount_favorites_routes
+ from oneirodex.runtime_native.member_meta import mount_member_meta_routes
+ from oneirodex.runtime_native.vr_reads import mount_vr_read_routes
+ from oneirodex.runtime_native.social_writes import mount_social_writes_routes
+ from oneirodex.runtime_native.oidc_status import mount_oidc_status_routes
+ from oneirodex.runtime_native.remote_play_status import mount_remote_play_status_routes
+ from oneirodex.runtime_native.settings_module_status import mount_settings_module_status_routes
+ from oneirodex.runtime_native.library_health import mount_library_health_routes
+ from oneirodex.runtime_native.game_health import mount_game_health_routes
+ from oneirodex.runtime_native.quality_profiles import mount_quality_profiles_routes
+ from oneirodex.runtime_native.status_reads import mount_status_reads_routes
+ from oneirodex.runtime_native.device_status import mount_device_status_routes
+ from oneirodex.runtime_native.rom_translate import mount_rom_translate_routes
+ from oneirodex.runtime_native.loading_icon_admin import mount_loading_icon_admin_routes
+ from oneirodex.runtime_native.attract_mode_reads import mount_attract_mode_routes
+ from oneirodex.runtime_native.theme_font_css import mount_theme_font_routes
+ from oneirodex.runtime_native.ai_status import mount_ai_status_routes
+ from oneirodex.runtime_native.library_platforms import mount_library_platforms_routes
+ from oneirodex.runtime_native.library_reads import mount_library_reads_routes
+ from oneirodex.runtime_native.scan_job_reads import mount_scan_job_reads_routes
+ from oneirodex.runtime_native.scan_job_mutations import mount_scan_job_mutation_routes
+ from oneirodex.runtime_native.arr_status import mount_arr_status_routes
+ from oneirodex.runtime_native.discover_reads import mount_discover_reads_routes
+ from oneirodex.runtime_native.account_summary import mount_account_summary_routes
+ from oneirodex.runtime_native.account_preferences import mount_account_preferences_routes
+ from oneirodex.runtime_native.locale import mount_locale_routes
+ from oneirodex.runtime_native.account_stock_avatar import mount_account_stock_avatar_routes
+ from oneirodex.runtime_native.account_avatar_upload import mount_account_avatar_upload_routes
+ from oneirodex.runtime_native.account_password import mount_account_password_routes
+ from oneirodex.runtime_native.release_calendar import mount_release_calendar_routes
+ from oneirodex.runtime_native.account_tokens import mount_account_tokens_routes
+ from oneirodex.runtime_native.client_reads import mount_client_reads_routes
+ from oneirodex.runtime_native.announcements import mount_announcements_routes
+ from oneirodex.runtime_native.news_feeds import mount_news_feed_routes
+ from oneirodex.runtime_native.collection_reads import mount_collection_reads_routes
+ from oneirodex.runtime_native.wishlist import mount_wishlist_routes
+ from oneirodex.runtime_native.notifications import mount_notifications_routes
+ from oneirodex.runtime_native.wanted_updates import mount_wanted_updates_routes
+ from oneirodex.runtime_native.updates_reads import mount_update_reads_routes
+ from oneirodex.runtime_native.updates_scan import mount_update_scan_routes
+ from oneirodex.runtime_native.setup_state import mount_setup_state_routes
+ from oneirodex.runtime_native.admin_statistics import mount_admin_statistics_routes
+ from oneirodex.runtime_native.admin_search import mount_admin_search_routes
+ from oneirodex.runtime_native.palette_suggest import mount_palette_suggest_routes
+ from oneirodex.runtime_native.metadata_providers_read import mount_metadata_providers_read_routes
+ from oneirodex.runtime_native.licensed_catalog_reads import mount_licensed_catalog_reads_routes
+ from oneirodex.runtime_native.metadata_search_sources import mount_metadata_search_sources_routes
+ from oneirodex.runtime_native.metadata_search import mount_metadata_search_routes
+ from oneirodex.runtime_native.admin_themes_read import mount_admin_themes_read_routes
+ from oneirodex.runtime_native.ownership_providers import mount_ownership_provider_routes
+ from oneirodex.runtime_native.ownership_actions import mount_ownership_actions_routes
+ from oneirodex.runtime_native.ownership_admin_reads import mount_ownership_admin_read_routes
+ from oneirodex.runtime_native.game_servers import mount_game_servers_routes
+ from oneirodex.runtime_native.emulator_mutations import mount_emulator_mutation_routes
+ from oneirodex.runtime_native.game_mods import mount_game_mods_routes
+ from oneirodex.runtime_native.scan_unmatched_reads import mount_scan_unmatched_read_routes
+ from oneirodex.runtime_native.scan_unmatched_mutations import mount_scan_unmatched_mutation_routes
+ from oneirodex.runtime_native.scan_unmatched_feedback import mount_scan_unmatched_feedback_routes
+ from oneirodex.runtime_native.emulator_reads import mount_emulator_read_routes
+ from oneirodex.runtime_native.system_path_check import mount_system_path_check_routes
+ from oneirodex.runtime_native.system_file_types import mount_system_file_type_routes
+ from oneirodex.runtime_native.artwork_provider_search import mount_artwork_provider_search_routes
+ from oneirodex.runtime_native.artwork_apply_mutation import mount_artwork_apply_routes
+ from oneirodex.runtime_native.patch_catalog_routes import mount_patch_catalog_routes
+ from oneirodex.runtime_native.download_request_reads import mount_download_request_read_routes
+ from oneirodex.runtime_native.manual_identify_reads import mount_manual_identify_read_routes
+ from oneirodex.runtime_native.manual_identify_mutations import mount_manual_identify_mutation_routes
+ from oneirodex.runtime_native.legacy_manual_identify_forms import mount_legacy_manual_identify_routes
+ from oneirodex.runtime_native.retroachievements_settings import mount_retroachievements_settings_routes
+ from oneirodex.runtime_native.admin_policy_settings import mount_admin_policy_settings_routes
+ from oneirodex.runtime_native.admin_attract_mode import mount_admin_attract_mode_routes
+ from oneirodex.runtime_native.admin_features import mount_admin_features_routes
+ from oneirodex.runtime_native.admin_scan_filters import mount_admin_scan_filters_routes
+ from oneirodex.runtime_native.legacy_scan_forms import mount_legacy_scan_form_routes
+ from oneirodex.runtime_native.legacy_scan_management import mount_legacy_scan_management_routes
+ from oneirodex.runtime_native.admin_discovery_sections import mount_admin_discovery_sections_routes
+ from oneirodex.runtime_native.admin_invites import mount_admin_invites_routes
+ from oneirodex.runtime_native.admin_users_read import mount_admin_users_read_routes
+ from oneirodex.runtime_native.admin_user_create import mount_admin_user_create_routes
+ from oneirodex.runtime_native.admin_user_mutations import mount_admin_user_mutation_routes
+ from oneirodex.runtime_native.admin_art_studio_reads import mount_admin_art_studio_read_routes
+ from oneirodex.runtime_native.emulator_health import mount_emulator_health_routes
+ from oneirodex.runtime_native.openapi_documents import mount_openapi_documents_routes
+ from oneirodex.runtime_native.library_exports import mount_library_export_routes
+ from oneirodex.runtime_native.admin_theme_fonts import mount_admin_theme_font_routes
+ from oneirodex.runtime_native.admin_system_reset import mount_admin_system_reset_routes
+ from oneirodex.runtime_native.admin_art_studio_preview import mount_admin_art_studio_preview_routes
+ from oneirodex.runtime_native.admin_art_studio_actions import mount_admin_art_studio_action_routes
+ from oneirodex.runtime_native.admin_artwork_generate import mount_admin_artwork_generation_routes
+ from oneirodex.runtime_native.admin_artwork_batch import mount_admin_artwork_batch_routes
+ from oneirodex.runtime_native.admin_image_queue_read import mount_admin_image_queue_read_routes
+ from oneirodex.runtime_native.admin_cover_search import mount_admin_cover_search_routes
+ from oneirodex.runtime_native.admin_cover_apply import mount_admin_cover_apply_routes
+ from oneirodex.runtime_native.admin_cover_batch import mount_admin_cover_batch_routes
+ from oneirodex.runtime_native.admin_image_mutations import mount_admin_image_mutation_routes
+ from oneirodex.runtime_native.admin_whitelist import mount_admin_whitelist_routes
+ from oneirodex.runtime_native.admin_plugins_read import mount_admin_plugins_routes
+ from oneirodex.runtime_native.launcher_import_reads import mount_launcher_import_read_routes
+ from oneirodex.runtime_native.download_mutations import mount_download_mutation_routes
+ from oneirodex.runtime_native.support_mutations import mount_support_mutation_routes
+ from oneirodex.runtime_native.launcher_import_mutations import mount_launcher_import_mutation_routes
+ from oneirodex.runtime_native.library_tools import mount_library_tools_routes
+ from oneirodex.runtime_native.library_tools_mutations import mount_library_tools_mutations_routes
+ from oneirodex.runtime_native.library_tools_manual_identify import mount_manual_identify_list_routes
+ from oneirodex.runtime_native.library_admin_mutations import mount_library_admin_mutations_routes
+ from oneirodex.runtime_native.support_reads import mount_support_reads_routes
+ from oneirodex.runtime_native.theme_fonts_read import mount_theme_fonts_read_routes
+ from oneirodex.runtime_native.provider_catalog_reads import mount_provider_catalog_reads_routes
+ from oneirodex.runtime_native.integrations_inventory import mount_integrations_inventory_routes
+ from oneirodex.runtime_native.integration_settings import mount_integration_settings_routes
+ from oneirodex.runtime_native.admin_newsletters import mount_admin_newsletters_routes
+ from oneirodex.runtime_native.igdb_availability import mount_igdb_availability_routes
+ from oneirodex.runtime_native.igdb_company_role import mount_igdb_company_role_routes
+ from oneirodex.runtime_native.storage_hardlinks import mount_storage_hardlinks_routes
+ from oneirodex.runtime_native.malware_scan_mutation import mount_malware_scan_mutation_routes
+ from oneirodex.runtime_native.auth_confirmation import mount_auth_confirmation_routes
+ from oneirodex.runtime_native.auth_login import mount_auth_login_routes
+ from oneirodex.runtime_native.auth_logout import mount_auth_logout_routes
+ from oneirodex.runtime_native.auth_pages import mount_auth_pages_routes
+ from oneirodex.runtime_native.auth_registration import mount_auth_registration_routes
+ from oneirodex.runtime_native.password_reset_confirm import mount_password_reset_confirm_routes
+ from oneirodex.runtime_native.auth_form_posts import mount_auth_form_post_routes
+ from oneirodex.runtime_native.oidc_auth import mount_oidc_auth_routes
+ from oneirodex.runtime_native.setup_pages import mount_setup_pages_routes
+ from oneirodex.runtime_native.setup_transition import mount_setup_transition_routes
+ from oneirodex.runtime_native.site_redirects import mount_site_redirect_routes
+ from oneirodex.runtime_native.play_redirect import mount_play_redirect_routes
+ from oneirodex.runtime_native.styleguide import mount_styleguide_routes
+ from oneirodex.runtime_native.admin_server_settings import mount_admin_server_settings_routes
+ from oneirodex.runtime_native.admin_ops_reads import mount_admin_ops_reads_routes
+ from oneirodex.runtime_native.admin_redirects import mount_admin_redirect_routes
+
+ find_user, find_token, commit_token_touch = _native_auth_backend(self._flask_app)
+ manager = getattr(self._flask_app, 'login_manager', None)
+ # Native requests resolve the signed identity through the shared
+ # fingerprint/active-user resolver and `find_user`; consulting
+ # Flask-Login's callback here would keep native authentication
+ # coupled to the legacy request extension.
+ load_user_id = None
+ self._native_read_api_app = FastAPI(
+ docs_url=None,
+ redoc_url=None,
+ openapi_url=None,
+ )
+ self._native_read_api_app.router.redirect_slashes = False
+ mount_site_redirect_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_play_redirect_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_styleguide_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.member_shell import create_member_shell_router
+
+ self._native_member_shell_app = FastAPI(
+ docs_url=None, redoc_url=None, openapi_url=None,
+ )
+ self._native_member_shell_app.router.redirect_slashes = False
+ self._native_member_shell_app.include_router(create_member_shell_router(
+ self._flask_app,
+ database_scope=lambda app: self._native_database_scope(app),
+ ))
+ _install_native_api_error_handlers(
+ self._native_read_api_app,
+ self._flask_app.config,
+ self._flask_app.logger,
+ )
+ mount_trailers_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ auth=TrailerAuthCallbacks(
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ ),
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_browse_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_browse_locations_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_acquire_status_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_acquire_actions_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_social_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_member_game_state_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_favorites_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_member_meta_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_vr_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ # Register the static /games/batch/... routes before the dynamic
+ # /games/{game_uuid}/... detail routes.
+ mount_batch_refresh_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_game_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_game_delete_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_filter_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_playtime_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_related_media_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_saved_filter_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_batch_favorite_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_batch_status_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_batch_wishlist_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_social_writes_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_oidc_status_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_remote_play_status_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_settings_module_status_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_library_health_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_game_health_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_quality_profiles_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_status_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_device_status_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_rom_translate_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_loading_icon_admin_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_attract_mode_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_theme_font_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_ai_status_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_library_platforms_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_library_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_scan_job_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_scan_job_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_newsletters_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_igdb_availability_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_igdb_company_role_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_storage_hardlinks_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_malware_scan_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_auth_confirmation_routes(
+ self._native_read_api_app,
+ app_config=self._flask_app.config,
+ database_scope=lambda: self._native_database_scope(self._flask_app),
+ )
+ mount_auth_login_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_auth_logout_routes(self._native_read_api_app, self._flask_app)
+ mount_auth_pages_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_oidc_auth_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_setup_pages_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_setup_transition_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_auth_registration_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_password_reset_confirm_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_auth_form_post_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.auth_demo import mount_public_demo_routes
+ mount_public_demo_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_server_settings_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_ops_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_redirect_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_arr_status_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.arr_config_read import mount_arr_config_routes
+ mount_arr_config_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.arr_indexers_read import mount_arr_indexers_routes
+ mount_arr_indexers_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_discover_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_account_summary_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_account_preferences_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_locale_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_account_stock_avatar_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_account_avatar_upload_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_account_password_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_release_calendar_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_account_tokens_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_client_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_announcements_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_news_feed_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_collection_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_wishlist_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_notifications_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_wanted_updates_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_update_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_update_scan_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_setup_state_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_statistics_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.hltb_stats import mount_hltb_stats_routes
+ mount_hltb_stats_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.hltb_refresh import mount_hltb_refresh_routes
+ mount_hltb_refresh_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_search_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_palette_suggest_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_metadata_providers_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_licensed_catalog_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_metadata_search_sources_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_metadata_search_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_themes_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_ownership_provider_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_ownership_actions_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_ownership_admin_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_artwork_provider_search_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_artwork_apply_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_patch_catalog_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_download_request_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_manual_identify_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_manual_identify_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_legacy_manual_identify_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_retroachievements_settings_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_policy_settings_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_attract_mode_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_features_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_scan_filters_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_legacy_scan_form_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_legacy_scan_management_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_discovery_sections_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_invites_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_users_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_user_create_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_user_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_art_studio_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_openapi_documents_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ )
+ mount_library_export_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_theme_font_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_system_reset_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_art_studio_preview_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_art_studio_action_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_artwork_generation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_artwork_batch_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_image_queue_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_cover_search_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_cover_apply_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_cover_batch_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_image_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_emulator_health_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_whitelist_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ load_user_id=load_user_id,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_admin_plugins_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_launcher_import_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_download_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_support_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_launcher_import_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_game_servers_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_emulator_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_game_mods_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_scan_unmatched_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_scan_unmatched_mutation_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_scan_unmatched_feedback_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_library_tools_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_library_tools_mutations_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_manual_identify_list_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_library_admin_mutations_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_support_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_theme_fonts_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_provider_catalog_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_integrations_inventory_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_integration_settings_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_emulator_read_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_system_path_check_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_system_file_type_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.chat_emoji_read import mount_chat_emoji_routes
+ from oneirodex.runtime_native.chat_reads import mount_chat_reads_routes
+ from oneirodex.runtime_native.layouts import mount_layout_routes
+ mount_chat_emoji_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_chat_reads_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ mount_layout_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.smtp_settings_read import mount_smtp_settings_routes
+ mount_smtp_settings_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.igdb_settings_read import mount_igdb_settings_routes
+ mount_igdb_settings_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ from oneirodex.runtime_native.reference_sets_read import mount_reference_sets_routes
+ mount_reference_sets_routes(
+ self._native_read_api_app,
+ self._flask_app,
+ find_user=find_user,
+ find_token=find_token,
+ commit_token_touch=commit_token_touch,
+ login_manager=manager,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ admin_shell_path = self._static_root / 'dist' / 'admin-pilot' / 'index.html'
+ if not admin_shell_path.is_file() and not compatibility_app_injected:
+ raise RuntimeError('Required built admin React shell is missing from the application static tree.')
+ from oneirodex.runtime_native import (
+ ADMIN_STATIC_ROUTES,
+ ADMIN_BOOTSTRAP_ROUTE,
+ )
+ from oneirodex.runtime_native.admin_pilot import create_admin_pilot, legacy_chrome_fields
+ self._native_admin_routes = frozenset((*ADMIN_STATIC_ROUTES, ADMIN_BOOTSTRAP_ROUTE))
+ self._native_admin_pilot = (
+ create_admin_pilot(
+ self._flask_app,
+ shell_path=admin_shell_path,
+ chrome_fields=legacy_chrome_fields,
+ database_scope=lambda app: self._native_database_scope(app),
+ )
+ if admin_shell_path.is_file() else None
+ )
+ # Keep the initialized sentinel tied to the native router. Every
+ # registered legacy method/path is covered by FastAPI or an
+ # explicit ASGI handler; unknown paths now get native 404/405s.
+ self._app = self._native_read_api_app
+
+ async def _ensure_native_database(self):
+ """Prepare framework-free runtime config and DB engine for probes."""
+ if self._runtime_config is not None and self._native_sqlalchemy_engine is not None:
+ return
+ async with self._init_lock:
+ if self._runtime_config is None:
+ from oneirodex.runtime_native.app_config import build_runtime_config
+
+ self._runtime_config = build_runtime_config()
+ if self._native_sqlalchemy_engine is None:
+ from oneirodex.runtime_native.db_session import create_native_engine
+
+ if self._runtime_config.get('SQLALCHEMY_DATABASE_URI'):
+ self._native_sqlalchemy_engine = create_native_engine(self._runtime_config)
+
+ def _native_database_scope(self, app=None):
+ """Return a native Session scope; retain a test/legacy fallback only."""
+ from oneirodex.runtime_native.db_session import (
+ native_engine_session_scope,
+ native_session_scope,
+ )
+
+ if self._native_sqlalchemy_engine is not None:
+ from oneirodex.runtime_native.runtime_context import NativeRuntimeContext
+
+ if isinstance(self._flask_app, NativeRuntimeContext):
+ return self._flask_app.app_context()
+ return native_engine_session_scope(self._native_sqlalchemy_engine, db)
+ return native_session_scope(app or self._flask_app, db)
+
+ async def __call__(self, scope, receive, send):
+ if scope["type"] == "lifespan":
+ await self._handle_lifespan(receive, send)
+ elif scope["type"] == "http":
+ path = scope["path"]
+
+ # Liveness has no DB, session, or app-context dependency. Keep it
+ # available to the container probe without constructing Flask on
+ # the first HTTP request. Normal lifespan startup may still eager
+ # initialize Flask for the remaining compatibility routes.
+ if path == '/pulse' and scope.get('method') in ('GET', 'HEAD'):
+ await self._handle_liveness(scope, send)
+ return
+
+ if path == '/awake' and scope.get('method') in ('GET', 'HEAD'):
+ await self._ensure_native_database()
+ await self._handle_readiness(scope, send)
+ return
+
+ if path == '/manifest.webmanifest' and scope.get('method') in ('GET', 'HEAD'):
+ await self._handle_app_manifest(scope, send)
+ return
+
+ if path == '/offline' and scope.get('method') in ('GET', 'HEAD'):
+ await self._handle_offline(scope, send)
+ return
+
+ if path in ('/sw.js', '/vr/sw.js') and scope.get('method') in ('GET', 'HEAD'):
+ await self._handle_service_worker(scope, send, path)
+ return
+
+ if path == '/favicon.ico' and scope.get('method') in ('GET', 'HEAD'):
+ await self._handle_favicon(scope, send)
+ return
+
+ if path == '/api/emulator/installed-cores.js' and scope.get('method') in ('GET', 'HEAD'):
+ await self._handle_installed_cores(scope, send)
+ return
+
+ if (
+ is_email_confirmation_page_route(path)
+ and scope.get('method') in ('GET', 'HEAD')
+ ):
+ headers = dict(scope.get('headers', []))
+ # This document route has no token side effect. Preserve queued
+ # Flask flashes and request-loader auth whenever credentials
+ # are present; a first-time credential-free visit can use the
+ # shipped React shell directly.
+ if not headers.get(b'cookie') and not headers.get(b'authorization'):
+ await self._handle_confirmation_shell(scope, send)
+ return
+
+ if (
+ path in {'/login', '/reset_password_request'}
+ and scope.get('method') in ('GET', 'HEAD')
+ ):
+ headers = dict(scope.get('headers', []))
+ # Existing sessions must retain the Flask-Login redirect and
+ # request-loader credentials must retain their auth behavior.
+ if not headers.get(b'cookie') and not headers.get(b'authorization'):
+ await self._handle_confirmation_shell(scope, send)
+ return
+
+ if path.startswith('/download_zip/') or path.startswith('/api/downloadrom/'):
+ await self._handle_download(scope, receive, send)
+ return
+
+ # Long-lived SSE must not run through request router middleware — a single
+ # sync stream holds a bridge thread for the life of the connection
+ # and freezes Discover/Admin/API fetches. True of any bridge.
+ sse_key = path.rstrip('/') or '/'
+ if sse_key in self._SSE_ROUTES:
+ cfg = self._SSE_ROUTES[sse_key]
+ await self._handle_sse(
+ scope,
+ receive,
+ send,
+ channel=cfg['channel'],
+ event_types=cfg['event_types'],
+ restrict_child=cfg['restrict_child'],
+ )
+ return
+
+ # Serve /static/* natively. This began as a workaround for the
+ # bridge failing under concurrent CSS/JS — it stays because the native path streams with
+ # aiofiles and owns its own cache-control and security headers.
+ if path.startswith('/static/'):
+ await self._handle_static(scope, receive, send, path)
+ return
+
+ await self._ensure_flask()
+ from oneirodex.runtime_native.member_shell import member_shell_route
+
+ if (
+ self._native_member_shell_app is not None
+ and member_shell_route(path)
+ and scope.get('method') in ('GET', 'HEAD')
+ ):
+ await self._native_member_shell_app(scope, receive, send)
+ return
+ if (
+ self._native_read_api_app is not None
+ and path in {'/admin/integrations/igdb/save', '/admin/integrations/igdb/test'}
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and path in {'/admin/settings', '/admin/new_server_settings'}
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and path == '/admin/attract_mode_settings'
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and path == '/admin/edit_filters'
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and path == '/admin/reference_sets'
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and path == '/admin/chat_emoji'
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and is_local_game_image_route(path)
+ and scope.get('method') in ('GET', 'HEAD')
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and is_game_image_refresh_route(path)
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and is_single_image_upload_route(path)
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and scope.get('method') == 'POST'
+ and (path == '/admin/library/add' or is_library_edit_route(path))
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and path == '/link_existing_game'
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and path == '/scan_manual_folder'
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and path == '/scan_management'
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and (path == '/add_game_manual' or is_game_edit_route(path))
+ and scope.get('method') == 'POST'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and path in {'/login/oidc', '/login/oidc/callback'}
+ and scope.get('method') == 'GET'
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+
+ if (
+ self._native_read_api_app is not None
+ and (
+ path in self._native_read_api_routes
+ or (path.startswith('/play_game/') and scope.get('method') in ('GET', 'HEAD'))
+ or (path.startswith('/delete_filter/') and scope.get('method') in ('GET', 'HEAD', 'POST'))
+ or (path == '/dev/styleguide' and scope.get('method') in ('GET', 'HEAD'))
+ or (
+ path in {'/settings_profile_view', '/settings_password', '/settings_panel'}
+ and scope.get('method') in ('GET', 'HEAD')
+ )
+ or _is_native_library_api_path(path)
+ or _is_native_admin_newsletter_path(path)
+ or _is_native_auth_confirmation_path(path)
+ or (
+ _is_native_auth_page_path(path)
+ )
+ or (
+ _is_native_setup_page_path(path)
+ )
+ or path == '/user/invites'
+ or _is_native_invite_delete_path(path)
+ or (
+ path in {
+ '/setup/submit', '/setup/smtp', '/setup/features',
+ '/setup/igdb', '/setup/integrations', '/setup/library',
+ }
+ and (
+ scope.get('method') == 'POST'
+ or (
+ scope.get('method') in ('GET', 'HEAD')
+ )
+ )
+ )
+ or (is_admin_redirect_path(path)
+ and scope.get('method') in ('GET', 'HEAD'))
+ or _is_native_password_reset_confirm_path(path)
+ or _is_native_chat_messages_path(path)
+ or _is_native_chat_attachment_upload_path(path)
+ or _is_native_chat_mute_path(path)
+ or _is_native_chat_reaction_path(path)
+ or _is_native_chat_space_read_path(path)
+ or _is_native_chat_space_member_delete_path(path)
+ or _is_native_chat_space_channel_path(path)
+ or _is_native_chat_space_invite_revoke_path(path)
+ or _is_native_chat_emoji_delete_path(path)
+ or _is_native_chat_voice_room_path(path)
+ or _is_native_layout_preset_path(path)
+ or _is_native_game_read_path(path)
+ or _is_native_playtime_path(path)
+ or _is_native_related_media_path(path)
+ or _is_native_game_asset_path(path)
+ or _is_native_member_game_state_path(path)
+ or _is_native_member_profile_path(path)
+ or _is_native_vr_game_path(path)
+ or _is_native_vr_profile_path(path)
+ or _is_native_saved_filter_path(path)
+ or _is_native_ownership_candidate_path(path)
+ or _is_native_ownership_decision_path(path)
+ or _is_native_ownership_cancel_path(path)
+ or _is_native_ownership_action_path(path)
+ or _is_native_game_server_path(path)
+ or _is_native_emulator_mutation_path(path)
+ or _is_native_game_mods_path(path)
+ or _is_native_unmatched_mutation_path(path)
+ or _is_native_unmatched_feedback_path(path)
+ or _is_native_emulator_platform_path(path)
+ or _is_native_file_type_path(path)
+ or _is_native_library_tools_path(path)
+ or _is_native_library_admin_mutation_path(path)
+ or _is_native_support_reads_path(path)
+ or _is_native_theme_fonts_read_path(path)
+ or _is_native_provider_catalog_path(path)
+ or _is_native_integrations_inventory_path(path)
+ or _is_native_integration_settings_path(path)
+ or (
+ path in {'/admin/integrations/igdb/save', '/admin/integrations/igdb/test'}
+ and scope.get('method') == 'POST'
+ )
+ or _is_native_download_request_reads_path(path)
+ or _is_native_manual_identify_read_path(path)
+ or _is_native_manual_identify_mutation_path(path, scope.get('method', ''))
+ or _is_native_retroachievements_settings_path(path)
+ or _is_native_admin_policy_settings_path(path)
+ or path == '/api/admin/attract-mode/settings'
+ or _is_native_admin_scan_filters_path(path)
+ or _is_native_admin_discovery_sections_path(path)
+ or (
+ _is_native_admin_invites_path(path)
+ and (path != '/admin/manage_invites' or scope.get('method') == 'POST')
+ )
+ or (
+ _is_native_admin_whitelist_path(path)
+ and (
+ (path == '/admin/api/whitelist' and scope.get('method') in ('GET', 'HEAD', 'POST'))
+ or (path == '/admin/whitelist' and scope.get('method') == 'POST')
+ or (path != '/admin/api/whitelist' and scope.get('method') == 'DELETE')
+ )
+ )
+ or (
+ _is_native_admin_users_read_path(path)
+ and scope.get('method') in ('GET', 'HEAD')
+ )
+ or (
+ _is_native_admin_user_create_path(path)
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ _is_native_admin_user_mutation_path(path)
+ and scope.get('method') in ('PUT', 'DELETE')
+ )
+ or _is_native_admin_plugin_path(path)
+ or (
+ path == '/admin/api/system_logs/clear'
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ path == '/admin/clear_permission_errors'
+ and scope.get('method') == 'POST'
+ )
+ or _is_native_admin_theme_font_path(path, scope.get('method', ''))
+ or _is_native_admin_art_studio_download_path(path, scope.get('method', ''))
+ or _is_native_image_refresh_progress_path(path, scope.get('method', ''))
+ or _is_native_library_delete_progress_path(path, scope.get('method', ''))
+ or _is_native_library_delete_sse_path(path, scope.get('method', ''))
+ or _is_native_scan_job_mutation_path(path, scope.get('method', ''))
+ or _is_native_library_delete_form_path(path, scope.get('method', ''))
+ or _is_native_game_delete_path(path, scope.get('method', ''))
+ or _is_native_unmatched_compat_mutation_path(path, scope.get('method', ''))
+ or _is_native_admin_delete_image_path(path, scope.get('method', ''))
+ or path in {
+ '/api/admin/features/settings',
+ '/api/admin/features/challenge',
+ '/api/admin/features/challenge/test',
+ }
+ or (path == '/admin/features' and scope.get('method') == 'POST')
+ or (path in {'/admin/settings', '/admin/new_server_settings'} and scope.get('method') == 'POST')
+ or (path in {'/admin/igdb_settings', '/admin/test_igdb', '/admin/integrations/igdb/save', '/admin/integrations/igdb/test'} and scope.get('method') == 'POST')
+ or _is_native_launcher_import_read_path(path)
+ or _is_native_download_mutation_path(path)
+ or _is_native_support_mutation_path(path)
+ or _is_native_launcher_import_mutation_path(path)
+ or _is_native_artwork_apply_path(path)
+ or path == '/api/admin/ownership/connections'
+ or _is_native_admin_ownership_sync_path(path)
+ or path in {
+ '/api/providers/steamgriddb/search',
+ '/api/providers/igdb/search',
+ '/api/providers/giantbomb/search',
+ '/api/providers/pcgamingwiki/search',
+ '/api/providers/meta_quest/search',
+ '/admin/api/ops/logs',
+ '/admin/api/ops/system',
+ '/admin/api/ops/summary',
+ '/admin/api/ops/devices',
+ '/admin/api/library/health',
+ }
+ or path.startswith('/api/health/games/')
+ or path.startswith('/api/discover/rows/')
+ or path.startswith('/api/discover/zones/')
+ or path.startswith('/api/discover/hubs/genre/')
+ or path.startswith('/api/account/invites/')
+ or path.startswith('/api/tokens/')
+ or path.startswith('/api/client/commands/')
+ or path.startswith('/api/collections/')
+ or path.startswith('/api/social/friends/')
+ or path.startswith('/api/requests/')
+ or path.startswith('/api/admin/chat-emoji/')
+ or path.startswith('/api/chat/attachments/')
+ or path.startswith('/api/admin/themes/')
+ or path == '/admin/themes/reset'
+ or (
+ path == '/admin/themes'
+ and scope.get('method') == 'POST'
+ )
+ or path.startswith('/admin/themes/delete/')
+ or path.startswith('/api/admin/reference-sets/')
+ or (
+ path.startswith('/api/reference-sets/')
+ and path.rsplit('/', 1)[-1].isdigit()
+ )
+ or path.startswith('/api/arr/indexers/')
+ or path.startswith('/api/quality-profiles/')
+ or path.startswith('/api/hltb/refresh/')
+ or _is_native_free_games_claim_path(path)
+ )
+ and (
+ scope.get('method') in ('GET', 'HEAD')
+ or (
+ _is_native_library_api_path(path)
+ and path.endswith('/watch')
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ _is_native_library_api_path(path)
+ and path.startswith('/api/admin/libraries/')
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/reorder_libraries'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path in (
+ '/api/storage/hardlink/preview',
+ '/api/storage/hardlink/apply',
+ )
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/admin/malware-scan'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/library/preview-cropped-image'
+ and scope.get('method') == 'POST'
+ )
+ or (path == '/api/rtc/token' and scope.get('method') == 'POST')
+ or (
+ _is_native_free_games_claim_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_password_reset_confirm_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (path == '/api/auth/login' and scope.get('method') == 'POST')
+ or (path == '/api/auth/register' and scope.get('method') == 'POST')
+ or (path == '/user/invites' and scope.get('method') == 'POST')
+ or (_is_native_invite_delete_path(path) and scope.get('method') == 'POST')
+ or path in {'/login', '/register', '/reset_password_request'}
+ and scope.get('method') == 'POST'
+ or (
+ _is_native_auth_page_path(path)
+ and path.startswith('/reset_password/')
+ and scope.get('method') == 'POST'
+ )
+ or (path == '/api/setup/step' and scope.get('method') == 'POST')
+ or (
+ path in {
+ '/setup/submit', '/setup/smtp', '/setup/features',
+ '/setup/igdb', '/setup/integrations', '/setup/library',
+ }
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/admin/libraries/batch/edit'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/account/invites'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/account/preferences'
+ and scope.get('method') == 'PUT'
+ )
+ or (path == '/api/locale' and scope.get('method') == 'POST')
+ or (
+ path == '/api/admin/loading-icon/config'
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/attract-mode/user-override'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/admin/integrations/metadata-providers'
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ _is_native_integration_settings_path(path)
+ and scope.get('method') in ('PUT', 'POST')
+ )
+ or (
+ _is_native_download_request_reads_path(path)
+ and path.startswith('/api/delete_download/')
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ _is_native_retroachievements_settings_path(path)
+ and (
+ (path in {
+ '/api/admin/integrations/retroachievements',
+ '/api/me/retroachievements',
+ } and scope.get('method') == 'PUT')
+ or (path == '/api/retroachievements/match' and scope.get('method') == 'POST')
+ )
+ )
+ or (
+ _is_native_admin_policy_settings_path(path)
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/admin/attract-mode/settings'
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ _is_native_admin_scan_filters_path(path)
+ and scope.get('method') in ('POST', 'DELETE')
+ )
+ or _is_native_admin_discovery_sections_path(path)
+ or (
+ _is_native_admin_invites_path(path)
+ and (path != '/admin/manage_invites' or scope.get('method') == 'POST')
+ )
+ or (
+ _is_native_admin_whitelist_path(path)
+ and (
+ (path == '/admin/api/whitelist' and scope.get('method') in ('GET', 'HEAD', 'POST'))
+ or (path == '/admin/whitelist' and scope.get('method') == 'POST')
+ or (path != '/admin/api/whitelist' and scope.get('method') == 'DELETE')
+ )
+ )
+ or (
+ path == '/admin/api/system_logs/clear'
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ path == '/admin/clear_permission_errors'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_admin_users_read_path(path)
+ and scope.get('method') in ('GET', 'HEAD')
+ )
+ or (
+ _is_native_admin_user_create_path(path)
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ _is_native_admin_user_mutation_path(path)
+ and scope.get('method') in ('PUT', 'DELETE')
+ )
+ or (
+ path in {
+ '/api/admin/features/settings',
+ '/api/admin/features/challenge',
+ }
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/admin/features/challenge/test'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_admin_ownership_sync_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_download_mutation_path(path)
+ and scope.get('method') in ('GET', 'HEAD', 'POST')
+ )
+ or (
+ _is_native_support_mutation_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_launcher_import_mutation_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_artwork_apply_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/patch-catalog/attach'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/admin/igdb/settings'
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/arr/config'
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/discover/pins'
+ and scope.get('method') == 'PUT'
+ )
+ or (path == '/api/admin/themes' and scope.get('method') == 'POST')
+ or (
+ path in {'/admin/themes', '/admin/themes/reset'}
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/admin/themes/delete/')
+ and scope.get('method') == 'POST'
+ )
+ or (path == '/api/admin/notify-bus/test' and scope.get('method') == 'POST')
+ or (path == '/api/chat/channels' and scope.get('method') == 'POST')
+ or (
+ path == '/api/emulator-profiles'
+ and scope.get('method') in ('POST', 'PUT')
+ )
+ or (
+ path == '/api/browser-player-settings'
+ and scope.get('method') in ('POST', 'PUT')
+ )
+ or (
+ _is_native_file_type_path(path)
+ and scope.get('method') in ('POST', 'PUT', 'DELETE')
+ )
+ or (path == '/api/chat/dm' and scope.get('method') == 'POST')
+ or (path == '/api/chat/emoji' and scope.get('method') == 'POST')
+ or (path == '/api/admin/chat-emoji' and scope.get('method') == 'POST')
+ or (
+ _is_native_chat_emoji_delete_path(path)
+ and scope.get('method') == 'DELETE'
+ )
+ or (path == '/api/chat/spaces' and scope.get('method') == 'POST')
+ or (
+ path in ('/api/layouts/detail', '/api/layouts/detail/mine')
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/layouts/detail/mine'
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ path == '/api/layouts/detail/presets'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_layout_preset_path(path)
+ and scope.get('method') == 'DELETE'
+ )
+ or (path == '/api/chat/spaces/join' and scope.get('method') == 'POST')
+ or (
+ _is_native_chat_space_read_path(path)
+ and path.endswith('/invites')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_chat_space_invite_revoke_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_chat_space_channel_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (_is_native_chat_mute_path(path) and scope.get('method') == 'POST')
+ or (_is_native_chat_messages_path(path) and scope.get('method') == 'POST')
+ or (
+ _is_native_chat_attachment_upload_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (_is_native_chat_reaction_path(path) and scope.get('method') == 'POST')
+ or (
+ _is_native_ownership_decision_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_ownership_cancel_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_ownership_action_path(path)
+ and scope.get('method') in {'POST', 'DELETE'}
+ )
+ or (
+ path == '/api/game-servers'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_game_server_path(path)
+ and path != '/api/game-servers'
+ and scope.get('method') in {'PUT', 'PATCH', 'DELETE'}
+ )
+ or (
+ _is_native_emulator_mutation_path(path)
+ and scope.get('method') in {'POST', 'DELETE'}
+ )
+ or (
+ _is_native_game_mods_path(path)
+ and scope.get('method') in {
+ 'POST', 'PUT', 'PATCH', 'DELETE',
+ }
+ )
+ or (
+ _is_native_unmatched_mutation_path(path)
+ and scope.get('method') in {'POST', 'PATCH'}
+ )
+ or (
+ _is_native_unmatched_feedback_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path in {
+ '/api/library_tools/manual_identify/list',
+ '/api/library_tools/propose_leaf_libraries',
+ '/api/library_tools/import_leaf_libraries/preview',
+ '/api/library_tools/proposals/scan_roots',
+ '/api/library_tools/doctor/dry_run',
+ '/api/library_tools/rename/preview',
+ '/api/library_tools/proposals/approve',
+ '/api/library_tools/doctor/write_proposals',
+ '/api/library_tools/link_existing_game',
+ '/api/library_tools/rename/apply',
+ '/api/library_tools/doctor/apply_renames',
+ '/api/library_tools/backfill_steam_metadata',
+ '/api/library_tools/check_freshness',
+ }
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_library_admin_mutation_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/admin/newsletters'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/admin/newsletter'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.endswith('/members')
+ and _is_native_chat_space_read_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_chat_space_member_delete_path(path)
+ and scope.get('method') == 'DELETE'
+ )
+ or (path == '/api/ai/config' and scope.get('method') == 'PUT')
+ or (
+ path in ('/api/ai/triage', '/api/ai/doctor-notes', '/api/ai/apply-triage')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path in (
+ '/api/admin/challenge-solver/config',
+ '/api/admin/ambient-lighting/config',
+ )
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path in (
+ '/api/admin/challenge-solver/test',
+ '/api/admin/ambient-lighting/test',
+ )
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/api/admin/themes/')
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ path == '/api/arr/module'
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/quality-profiles'
+ and scope.get('method') in ('POST', 'PUT')
+ )
+ or (
+ path == '/api/quality-profiles/active'
+ and scope.get('method') in ('POST', 'PUT')
+ )
+ or (
+ path == '/api/quality-profiles/score'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/api/quality-profiles/')
+ and path.count('/') == 3
+ and path.rsplit('/', 1)[-1] not in ('active', 'score')
+ and scope.get('method') in ('PUT', 'PATCH', 'DELETE')
+ )
+ or (
+ path == '/api/admin/igdb/test'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/admin/smtp/settings'
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/admin/server-settings'
+ and scope.get('method') == 'PUT'
+ )
+ or (
+ path == '/api/admin/smtp/test'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path in {'/admin/smtp_settings', '/admin/smtp_test'}
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/admin/reference-sets'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/reference-sets'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path in ('/api/reference-sets/rehash', '/api/reference-sets/repair-preview')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/api/reference-sets/')
+ and path.rsplit('/', 1)[-1].isdigit()
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ path.startswith('/api/admin/reference-sets/')
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ path == '/api/arr/indexers'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/arr/download'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path in ('/api/arr/hardlink/preview', '/api/arr/hardlink/apply')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/api/hltb/refresh/')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/hltb/bulk-refresh'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/api/arr/indexers/')
+ and scope.get('method') in ('PUT', 'PATCH', 'DELETE')
+ )
+ or (
+ path in ('/api/arr/indexers/bulk', '/api/arr/indexers/enable-presets')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/licensed-catalog/refresh'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/account/avatar/stock'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/account/avatar'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/settings_profile_edit'
+ and scope.get('method') in ('GET', 'HEAD', 'POST')
+ )
+ or (
+ path == '/api/account/password'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/settings_panel'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/settings_password'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/client/lifecycle'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/client/heartbeat'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/client/commands'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/announcements'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/collections'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/api/collections/')
+ and scope.get('method') in ('PATCH', 'DELETE', 'POST', 'PUT')
+ )
+ or (
+ path == '/api/requests'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/api/requests/')
+ and scope.get('method') in ('PATCH', 'DELETE')
+ )
+ or (path == '/api/social/friends' and scope.get('method') == 'POST')
+ or (
+ path == '/api/notifications/read'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/notifications/preferences'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path in ('/api/updates/wanted', '/api/updates/wanted/fulfill')
+ and scope.get('method') == 'POST'
+ )
+ or (path == '/api/updates/scan' and scope.get('method') == 'POST')
+ or (path == '/api/acquire/download' and scope.get('method') == 'POST')
+ or (
+ path == '/api/events/publish_test'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/move_game_to_library'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/admin/freshness/refresh'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/api/social/friends/')
+ and scope.get('method') in ('POST', 'DELETE')
+ )
+ or (
+ path.startswith('/api/client/commands/')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path.startswith('/api/account/invites/')
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ path == '/api/tokens'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_game_read_path(path)
+ and path.endswith('/input_family')
+ and scope.get('method') == 'PATCH'
+ )
+ or (
+ _is_native_game_read_path(path)
+ and path.endswith('/freshness/check')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_playtime_path(path)
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_related_media_path(path)
+ and len(path.strip('/').split('/')) == 4
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_game_read_path(path)
+ and path.endswith('/assists')
+ and scope.get('method') in ('POST', 'PUT')
+ )
+ or _is_native_manual_identify_mutation_path(path, scope.get('method', ''))
+ or (
+ _is_native_related_media_path(path)
+ and len(path.strip('/').split('/')) == 5
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ path in ('/api/filters/saved', '/api/filters/preview')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/games/batch/favorite'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/games/batch/status'
+ and scope.get('method') == 'POST'
+ )
+ or (
+ path == '/api/games/batch/wishlist'
+ and scope.get('method') == 'POST'
+ )
+ or (path == '/api/check_username' and scope.get('method') == 'POST')
+ or (
+ path.startswith('/api/admin/chat-emoji/')
+ and scope.get('method') == 'DELETE'
+ )
+ or (
+ _is_native_member_game_state_path(path)
+ and (
+ scope.get('method') in ('GET', 'HEAD')
+ or (
+ scope.get('method') == 'POST'
+ and path.split('/')[2] in {'toggle_favorite', 'set_game_status'}
+ )
+ )
+ )
+ or (
+ _is_native_vr_profile_path(path)
+ and scope.get('method') in ('PUT', 'DELETE', 'PATCH')
+ )
+ or (
+ path in ('/api/games/batch/freshness/check', '/api/games/batch/refresh_images')
+ and scope.get('method') == 'POST'
+ )
+ or (
+ _is_native_saved_filter_path(path)
+ and scope.get('method') in ('PUT', 'DELETE')
+ )
+ or (
+ path.startswith('/api/tokens/')
+ and scope.get('method') == 'DELETE'
+ )
+ or _is_native_image_refresh_progress_path(path, scope.get('method', ''))
+ or _is_native_library_delete_progress_path(path, scope.get('method', ''))
+ or _is_native_library_delete_sse_path(path, scope.get('method', ''))
+ or _is_native_scan_job_mutation_path(path, scope.get('method', ''))
+ or _is_native_library_delete_form_path(path, scope.get('method', ''))
+ or _is_native_game_delete_path(path, scope.get('method', ''))
+ or _is_native_unmatched_compat_mutation_path(path, scope.get('method', ''))
+ )
+ ):
+ await self._native_read_api_app(scope, receive, send)
+ return
+ if path == '/api/auth/runtime-bootstrap' and scope['method'] in ('GET', 'HEAD'):
+ from starlette.requests import Request
+ from oneirodex.runtime_native.auth_runtime_bootstrap import member_auth_runtime_bootstrap
+
+ response = member_auth_runtime_bootstrap(
+ self._flask_app,
+ Request(scope, receive),
+ database_scope=self._native_database_scope,
+ runtime='fastapi-session-compat',
+ )
+ if scope['method'] == 'HEAD':
+ response.body = b''
+ await response(scope, receive, send)
+ return
+ if (self._native_admin_pilot is not None
+ and (
+ scope['method'] in ('GET', 'HEAD')
+ or (path == '/admin/manage-downloads' and scope['method'] == 'POST')
+ )
+ and (path in self._native_admin_routes
+ or is_game_edit_route(path)
+ or is_game_images_edit_route(path)
+ or is_numeric_admin_detail(path)
+ or is_library_edit_route(path))):
+ await self._native_admin_pilot(scope, receive, send)
+ return
+ await self._native_read_api_app(scope, receive, send)
+
+ @staticmethod
+ def _configured_security_headers():
+ """Return the same configured security policy used by Flask responses."""
+ from config import Config
+ from oneirodex.utils.security_headers import security_header_values
+
+ return security_header_values({
+ key: getattr(Config, key, None)
+ for key in (
+ 'CSP_ENABLED', 'CSP_ENFORCE', 'HSTS_SECONDS',
+ 'SESSION_COOKIE_SECURE',
+ )
+ })
+
+ async def _handle_liveness(self, scope, send):
+ """Serve the existing GET/HEAD liveness contract as a native ASGI route."""
+ from oneirodex import app_version
+ from oneirodex.runtime_native.health import liveness_payload
+
+ payload = json.dumps(
+ liveness_payload(app_version), separators=(',', ':'), ensure_ascii=False,
+ ).encode('utf-8') + b'\n'
+ headers = {
+ 'Content-Type': 'application/json',
+ 'Content-Length': str(len(payload)),
+ **self._configured_security_headers(),
+ }
+ await send({
+ 'type': 'http.response.start',
+ 'status': 200,
+ 'headers': [(key.lower().encode('ascii'), value.encode('latin-1'))
+ for key, value in headers.items()],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'' if scope.get('method') == 'HEAD' else payload,
+ 'more_body': False,
+ })
+
+ async def _handle_readiness(self, scope, send):
+ """Run readiness with request-local DB scope without Flask dispatch."""
+ from oneirodex.runtime_native.db_session import native_db
+ from oneirodex.utils.health_probes import build_readiness
+ from oneirodex.utils.security_headers import security_header_values
+ config = self._runtime_config or {}
+ with self._native_database_scope():
+ body, status = build_readiness(
+ config=config, database=native_db,
+ )
+ payload = json.dumps(body, separators=(',', ':'), ensure_ascii=False).encode('utf-8') + b'\n'
+ header_values = {
+ 'Content-Type': 'application/json',
+ 'Content-Length': str(len(payload)),
+ **security_header_values(config),
+ }
+ headers = [(key.lower().encode('ascii'), value.encode('latin-1'))
+ for key, value in header_values.items()]
+ await send({'type': 'http.response.start', 'status': status, 'headers': headers})
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'' if scope.get('method') == 'HEAD' else payload,
+ 'more_body': False,
+ })
-def _is_link_or_escape_refusal(exc: OSError) -> bool:
- """True when ``open_plain_file_within`` refused the file itself (a link,
- not a regular file, or outside its folder), as opposed to the file being
- locked, unreadable or failing with an I/O error."""
- import errno
+ async def _handle_installed_cores(self, scope, send):
+ """Serve the public WebRetro core allowlist without Flask startup."""
+ from oneirodex.utils.webretro_cores import get_effective_installed_cores
+
+ cores = sorted(get_effective_installed_cores())
+ payload = ('var OD_INSTALLED_CORES = ' + json.dumps(cores) + ';\n').encode('utf-8')
+ headers = {
+ 'Content-Type': 'application/javascript; charset=utf-8',
+ 'Content-Length': str(len(payload)),
+ 'Cache-Control': 'private, max-age=300',
+ **self._configured_security_headers(),
+ }
+ await send({
+ 'type': 'http.response.start',
+ 'status': 200,
+ 'headers': [(key.lower().encode('ascii'), value.encode('latin-1'))
+ for key, value in headers.items()],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'' if scope.get('method') == 'HEAD' else payload,
+ 'more_body': False,
+ })
- if exc.errno in (errno.ELOOP, errno.EINVAL):
- return True
- return exc.errno == errno.EACCES and exc.strerror == 'file is outside its folder'
+ async def _handle_app_manifest(self, scope, send):
+ """Build the install manifest in a request-local DB scope."""
+ from oneirodex.utils.pwa_manifest import build_app_manifest
+ from oneirodex.utils.processors import get_global_settings
+
+ await self._ensure_native_database()
+ with self._native_database_scope():
+ settings = get_global_settings() or {}
+ manifest = build_app_manifest(settings.get('site_name'))
+ payload = json.dumps(
+ manifest, separators=(',', ':'), ensure_ascii=False, sort_keys=True,
+ ).encode('utf-8')
+ headers = {
+ 'Content-Type': 'application/manifest+json',
+ 'Content-Length': str(len(payload)),
+ 'Cache-Control': 'public, max-age=3600',
+ **self._configured_security_headers(),
+ }
+ await send({
+ 'type': 'http.response.start',
+ 'status': 200,
+ 'headers': [(key.lower().encode('ascii'), value.encode('latin-1'))
+ for key, value in headers.items()],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'' if scope.get('method') == 'HEAD' else payload,
+ 'more_body': False,
+ })
+ async def _handle_offline(self, scope, send):
+ """Serve the self-contained offline fallback without Flask request state."""
+ from oneirodex.utils.asset_version import asset_version
+ from oneirodex.utils.offline_page import render_offline_page
-class LazyASGIApp:
- def __init__(self):
- self._app = None
- self._flask_app = None
- self._init_lock = asyncio.Lock()
- self._static_root: Path | None = None
+ static_root = self._static_root or (
+ Path(__file__).resolve().parent / 'oneirodex' / 'static'
+ )
+ retry_script = static_root / 'js' / 'od_offline_retry.js'
+ retry_url = f'/static/js/od_offline_retry.js?v={asset_version(retry_script)}'
+ payload = render_offline_page(retry_url).encode('utf-8')
+ headers = {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Content-Length': str(len(payload)),
+ 'Cache-Control': 'no-cache',
+ **self._configured_security_headers(),
+ }
+ await send({
+ 'type': 'http.response.start',
+ 'status': 200,
+ 'headers': [(key.lower().encode('ascii'), value.encode('latin-1'))
+ for key, value in headers.items()],
+ })
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'' if scope.get('method') == 'HEAD' else payload,
+ 'more_body': False,
+ })
- async def _ensure_flask(self):
- """Create Flask + the WSGI bridge once, safely under concurrent first hits."""
- if self._app is not None:
+ async def _handle_service_worker(self, scope, send, path: str):
+ """Serve origin-scoped PWA workers with the same no-cache policy."""
+ root = self._static_root or (
+ Path(__file__).resolve().parent / 'oneirodex' / 'static'
+ )
+ filename, allowed_scope = (
+ ('app-sw.js', '/') if path == '/sw.js'
+ else ('vr-sw.js', '/vr')
+ )
+ # Service workers have origin-wide authority. Do not let mutable
+ # library static overrides replace the shipped worker script.
+ candidate = resolve_static_path(root, f'/static/{filename}')
+ if candidate is None or not candidate.is_file():
+ await self._send_error(send, 404, 'Not Found')
+ return
+ try:
+ payload = await asyncio.to_thread(candidate.read_bytes)
+ except OSError:
+ await self._send_error(send, 404, 'Not Found')
return
- async with self._init_lock:
- if self._app is not None:
- return
- self._flask_app = create_app()
- self._static_root = Path(self._flask_app.static_folder or '').resolve()
- self._app = WSGIMiddleware(self._flask_app)
- async def __call__(self, scope, receive, send):
- if scope["type"] == "lifespan":
- await self._handle_lifespan(receive, send)
- elif scope["type"] == "http":
- path = scope["path"]
+ header_values = {
+ 'Content-Type': 'application/javascript; charset=utf-8',
+ 'Content-Length': str(len(payload)),
+ 'Cache-Control': 'no-cache',
+ 'Service-Worker-Allowed': allowed_scope,
+ **self._configured_security_headers(),
+ }
+ headers = [(key.lower().encode('ascii'), value.encode('latin-1'))
+ for key, value in header_values.items()]
+ await send({'type': 'http.response.start', 'status': 200, 'headers': headers})
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'' if scope.get('method') == 'HEAD' else payload,
+ 'more_body': False,
+ })
- if path.startswith('/download_zip/') or path.startswith('/api/downloadrom/'):
- await self._handle_download(scope, receive, send)
- return
+ async def _handle_favicon(self, scope, send):
+ """Serve the browser fallback icon with its existing short cache TTL."""
+ root = self._static_root or (
+ Path(__file__).resolve().parent / 'oneirodex' / 'static'
+ )
+ candidate = resolve_static_path(root, '/static/icons/favicon.ico')
+ if candidate is None or not candidate.is_file():
+ await self._send_error(send, 404, 'Not Found')
+ return
+ try:
+ payload = await asyncio.to_thread(candidate.read_bytes)
+ except OSError:
+ await self._send_error(send, 404, 'Not Found')
+ return
- # Long-lived SSE must not run through the WSGI bridge — a single
- # sync stream holds a bridge thread for the life of the connection
- # and freezes Discover/Admin/API fetches. True of any bridge.
- sse_key = path.rstrip('/') or '/'
- if sse_key in self._SSE_ROUTES:
- cfg = self._SSE_ROUTES[sse_key]
- await self._handle_sse(
- scope,
- receive,
- send,
- channel=cfg['channel'],
- event_types=cfg['event_types'],
- restrict_child=cfg['restrict_child'],
- )
- return
+ header_values = {
+ 'Content-Type': 'image/vnd.microsoft.icon',
+ 'Content-Length': str(len(payload)),
+ 'Cache-Control': 'public, max-age=3600',
+ **self._configured_security_headers(),
+ }
+ headers = [(key.lower().encode('ascii'), value.encode('latin-1'))
+ for key, value in header_values.items()]
+ await send({'type': 'http.response.start', 'status': 200, 'headers': headers})
+ await send({
+ 'type': 'http.response.body',
+ 'body': b'' if scope.get('method') == 'HEAD' else payload,
+ 'more_body': False,
+ })
- # Serve /static/* natively. This began as a workaround for the
- # asgiref bridge failing under concurrent CSS/JS, which a2wsgi has
- # now retired — it stays because the native path streams with
- # aiofiles and owns its own cache-control and security headers.
- if path.startswith('/static/'):
- await self._handle_static(scope, receive, send, path)
- return
+ async def _handle_confirmation_shell(self, scope, send):
+ """Serve the read-only confirmation document for a fresh visitor."""
+ root = self._static_root or (
+ Path(__file__).resolve().parent / 'oneirodex' / 'static'
+ )
+ candidate = resolve_static_path(root, '/static/dist/member-auth/auth.html')
+ if candidate is None:
+ await self._send_error(send, 503, 'Service Unavailable')
+ return
+ try:
+ payload = await asyncio.to_thread(candidate.read_bytes)
+ except OSError:
+ await self._send_error(send, 503, 'Service Unavailable')
+ return
+ if payload.count(b'id="member-auth-root"') != 1 or b'