"""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 import os import re import shutil import tempfile import uuid from pathlib import Path from importlib import import_module import aiofiles from werkzeug.utils import secure_filename 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' ) get_content_type_for_file = _lazy_callable( 'oneirodex.async_streaming', 'get_content_type_for_file' ) 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. _FILE_CHUNK_SIZE = 2 * 1024 * 1024 def _file_headers(filename: str, size: int) -> list[tuple[bytes, bytes]]: """Response headers for a file download of *size* bytes.""" secure_name = secure_filename(filename) or "download.zip" return [ (b"content-type", get_content_type_for_file(filename, filename).encode()), (b"content-disposition", f'attachment; filename="{secure_name}"'.encode()), (b"content-length", str(size).encode()), (b"cache-control", b"no-cache"), ] async def _read_chunks(handle, size: int): """Yield exactly *size* bytes of an open file, reading off the event loop. *size* was announced in ``content-length``, so a file that shrank under us is an error (the response then ends short and the client can tell) and one that grew is cut off at the announced length instead of overrunning it. """ loop = asyncio.get_running_loop() remaining = size while remaining > 0: chunk = await loop.run_in_executor(None, handle.read, min(_FILE_CHUNK_SIZE, remaining)) if not chunk: raise OSError('file ended before the size announced for the download') remaining -= len(chunk) yield chunk def _rom_location(rom_path, cache_dir, real_game, game_is_dir): """Where a resolved ROM may be read from. Returns ``(path to open, folder it must stay inside, whether that folder is already resolved)``. A ROM extracted from an archive lives in the app's own cache folder. A ROM in a game folder is held to the folder resolved when the request began (*real_game*). A game that is a single file is the file resolved at that moment, held to the folder it was in: the operator's own link to a file keeps working, and a link swapped in later is refused. """ try: in_cache = Path(rom_path).is_relative_to(cache_dir) except (TypeError, ValueError): in_cache = False if in_cache: return rom_path, cache_dir, False if game_is_dir: return rom_path, real_game, True 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) 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, }) 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, }) 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 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 _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 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, }) 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 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, }) 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'' not in payload: await self._send_error(send, 503, 'Service Unavailable') return header_values = { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': str(len(payload)), 'Cache-Control': 'no-store', 'Vary': 'Cookie', **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, }) # path (no trailing slash) → native async SSE config _SSE_ROUTES = { '/api/activity/stream': { 'channel': 'activity', 'event_types': frozenset({'activity', 'presence', 'hello', 'test'}), 'restrict_child': True, }, '/api/events/stream': { 'channel': 'events', # scan/download/ops fan-out — emit all bus types 'event_types': None, 'restrict_child': False, }, } def _event_visible(self, event, user_id) -> bool: """Per-viewer filter for the live streams (utils/event_visibility.py).""" payload = getattr(event, 'payload', None) or {} if payload.get('user_id') is None and not payload.get('game_uuid'): return True # about no member and no game: no lookup needed try: from oneirodex.utils.event_visibility import event_visible_to with self._native_database_scope(self._flask_app): return event_visible_to(event, user_id) except Exception: # noqa: BLE001 — when in doubt, do not send it return False async def _authorize_sse_user(self, user_id, *, restrict_child: bool) -> int | None: """Return HTTP error status, or None if the user may open SSE.""" if not user_id: return 401 from oneirodex.models import User from oneirodex.runtime_native.db_session import native_db from oneirodex.utils.rbac import normalize_role with self._native_database_scope(self._flask_app): user = native_db.session.get(User, user_id) if not user: return 401 if restrict_child and normalize_role(getattr(user, 'role', None)) == 'child': return 403 return None async def _handle_sse( self, scope, receive, send, *, channel: str, event_types: frozenset[str] | None, restrict_child: bool, ): """Async SSE — keeps the uvicorn event loop free (not WsgiToAsgi).""" if scope.get("method") != "GET": await self._send_error(send, 405, "Method Not Allowed") return await self._ensure_flask() user_id = await self._get_user_from_session(scope) auth_status = await self._authorize_sse_user(user_id, restrict_child=restrict_child) if auth_status is not None: await self._send_error( send, auth_status, "Unauthorized" if auth_status == 401 else "Restricted", ) return from oneirodex.utils.event_bus import encode_sse, event_bus subscriber = event_bus.subscribe_async(asyncio.get_running_loop()) await send({ "type": "http.response.start", "status": 200, "headers": [ (b"content-type", b"text/event-stream"), (b"cache-control", b"no-cache"), (b"x-accel-buffering", b"no"), (b"connection", b"keep-alive"), ], }) disconnected = asyncio.Event() async def _watch_disconnect(): while True: message = await receive() if message.get("type") == "http.disconnect": disconnected.set() return hello = ( f'event: hello\ndata: {{"ok": true, "channel": "{channel}"}}\n\n' ).encode('utf-8') watcher = asyncio.create_task(_watch_disconnect()) try: await send({ "type": "http.response.body", "body": hello, "more_body": True, }) while not disconnected.is_set(): next_event = asyncio.create_task(subscriber.get()) done, _pending = await asyncio.wait( (next_event, watcher), timeout=1.0, return_when=asyncio.FIRST_COMPLETED, ) if watcher in done: next_event.cancel() await asyncio.gather(next_event, return_exceptions=True) break if next_event in done: event = next_event.result() else: next_event.cancel() await asyncio.gather(next_event, return_exceptions=True) event = None if event is None: await send({ "type": "http.response.body", "body": b": keepalive\n\n", "more_body": True, }) continue if (event_types is None or event.type in event_types) and await asyncio.to_thread( self._event_visible, event, user_id, ): await send({ "type": "http.response.body", "body": encode_sse(event), "more_body": True, }) except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError): pass except Exception as exc: print(f"Error in {channel} SSE: {exc}") finally: watcher.cancel() try: await watcher except asyncio.CancelledError: pass event_bus.unsubscribe(subscriber) try: await send({"type": "http.response.body", "body": b"", "more_body": False}) except Exception: pass async def _handle_static(self, scope, receive, send, path: str): """Stream static files with path-traversal protection.""" if scope.get("method") not in ("GET", "HEAD"): await self._send_error(send, 405, "Method Not Allowed") return root = self._static_root if root is None or not root.is_dir(): await self._send_error(send, 500, "Static root not configured") return # Members' private files under /static/library/ are never static; BIOS # needs a signed-in member. 404 either way, so nothing is confirmed. access = static_access(root, path) if access == 'private': await self._send_error(send, 404, "Not Found") return if access == 'member': # Only this protected static class needs the configured Flask # session boundary. Public files stay on the pure ASGI path. await self._ensure_flask() root = self._static_root if (root is None or not root.is_dir() or static_access(root, path) != 'member' or await self._get_user_from_session(scope) is None): await self._send_error(send, 404, "Not Found") return if root is None: await self._send_error(send, 404, "Not Found") return candidate = resolve_served_static(root, path) if candidate is None: await self._send_error(send, 404, "Not Found") return if not candidate.is_file(): await self._send_error(send, 404, "Not Found") return content_type, _ = mimetypes.guess_type(str(candidate)) if not content_type: content_type = 'application/octet-stream' # CSS/JS under themes often mis-detected on some platforms lower = candidate.name.lower() if lower.endswith('.css'): content_type = 'text/css; charset=utf-8' elif lower.endswith('.js'): content_type = 'application/javascript; charset=utf-8' elif lower.endswith('.map'): content_type = 'application/json' elif lower.endswith('.svg'): content_type = 'image/svg+xml' elif lower.endswith('.woff2'): content_type = 'font/woff2' elif lower.endswith('.woff'): content_type = 'font/woff' try: size = candidate.stat().st_size except OSError: await self._send_error(send, 404, "Not Found") return # Two families of file are rewritten in place at a stable path, so an # hour of blind caching hides a change that has already shipped. Images # and fonts are genuinely static and keep the hour. # # Theme files are rewritten by Reset Themes while every template keeps # pointing at the same path. `theme_asset` versions those URLs, and this # is the second half: even an unversioned reference revalidates instead # of being assumed fresh. normalized = path.replace('\\', '/') is_mutable_theme_asset = '/static/library/themes/' in normalized # The SPA *entry* bundle is unhashed on purpose so the page shell can link it, # and the `?v=` on that link makes the script tag fetch fresh. But the # lazy route chunks `import` the entry by its bare path, with no query, # and that unversioned URL was cached for an hour — so after a rebuild # every lazily-loaded route went on running the *previous* build's # shared code while the versioned script tag reported the new one. # Observed directly: a fixed component kept rendering its old markup, # and `performance` showed two fetches of member-app.js at two sizes. # `chunks/` keeps the hour — those names carry a content hash, so a new # build is a new URL and caching them hard is the point. is_spa_entry = '/static/dist/' in normalized and '/chunks/' not in normalized cache_control = ( b"no-cache" if (is_mutable_theme_asset or is_spa_entry) else b"public, max-age=3600" ) # Static is served directly by ASGI, so Flask's after_request never # sees it — these have to be stamped here instead. # Baseline only, no CSP: webretro.html is a static document whose # Emscripten cores would need 'unsafe-eval' anyway. headers = [ (b"content-type", content_type.encode("ascii", "ignore")), (b"content-length", str(size).encode()), (b"cache-control", cache_control), *baseline_static_headers(), ] await send({ "type": "http.response.start", "status": 200, "headers": headers, }) if scope.get("method") == "HEAD": await send({"type": "http.response.body", "body": b"", "more_body": False}) return try: async with aiofiles.open(candidate, "rb") as fh: while True: chunk = await fh.read(65536) if not chunk: break await send({ "type": "http.response.body", "body": chunk, "more_body": True, }) await send({"type": "http.response.body", "body": b"", "more_body": False}) except Exception as exc: print(f"Error streaming static {path}: {exc}") try: await send({"type": "http.response.body", "body": b"", "more_body": False}) except Exception: pass async def _handle_download(self, scope, receive, send): """Handle download routes with async file streaming""" path = scope["path"] method = scope["method"] if method != "GET": await self._send_error(send, 405, "Method Not Allowed") return response_started = False async def tracked_send(message): nonlocal response_started if message["type"] == "http.response.start": response_started = True await send(message) try: await self._ensure_flask() if path.startswith('/download_zip/'): await self._handle_zip_download(scope, receive, tracked_send, path) elif path.startswith('/api/downloadrom/'): await self._handle_rom_download(scope, receive, tracked_send, path) except Exception as e: print(f"Error in async download handler: {str(e)}") if response_started: # Status and part of the body are already out. A second response # cannot be sent, and ending the body cleanly would hand the # client a truncated download that looks complete: let the # server drop the connection so the transfer visibly fails. raise try: await self._send_error(tracked_send, 500, "Internal Server Error") except Exception as error_e: print(f"Could not send error response (response may have already started): {str(error_e)}") async def _handle_zip_download(self, scope, receive, send, path): """Handle ZIP file downloads""" from oneirodex.models import DownloadRequest, Game, User download_id_match = re.match(r'/download_zip/(\d+)', path) if not download_id_match: await self._send_error(send, 400, "Invalid download ID") return download_id = int(download_id_match.group(1)) user_id = await self._get_user_id(scope) if not user_id: await self._send_error(send, 401, "Unauthorized") return from oneirodex.runtime_native.db_session import native_db with self._native_database_scope(self._flask_app): user = native_db.session.get(User, user_id) if not user or 'write:download' in forbidden_scopes_for_role(user.role): await self._send_error(send, 403, "Access denied") return download_request = native_db.session.execute( select(DownloadRequest).filter_by(id=download_id, user_id=user_id) ).scalars().first() if not download_request: await self._send_error(send, 404, "Download not found") return game = native_db.session.execute( select(Game).filter_by(uuid=download_request.game_uuid) ).scalars().first() if not game or not user_can_access_game(user, game): await self._send_error(send, 403, "Access denied") return if download_request.status != 'available': await self._send_error(send, 400, "Download not ready") return file_path = download_request.zip_file_path if os.path.isdir(file_path): await self._handle_streaming_download(send, download_request, file_path) return allowed_bases = get_allowed_base_directories(self._flask_app) if not allowed_bases: await self._send_error(send, 500, "Server configuration error") return # Resolved once and vetted; the file is then opened pinned to that # location and streamed from the open descriptor, so a link swapped # in while the log rows below are written is refused, not followed. try: real_path = os.path.realpath(file_path) except (OSError, ValueError): real_path = file_path is_safe, error_message = is_safe_path(real_path, allowed_bases) if not is_safe: log_system_event( f"Security violation - game file outside allowed directories: {file_path[:100]}", event_type='security', event_level='warning', ) await self._send_error(send, 403, "Access denied") return try: handle = open_plain_file_within(os.path.dirname(real_path), real_path, base_is_resolved=True) except FileNotFoundError: await self._send_error(send, 404, "File not found") return except OSError as exc: if not _is_link_or_escape_refusal(exc): # Locked by another program (a Windows sharing violation), # unreadable, or an I/O error: not an attack, just unavailable. log_system_event( f"Download file could not be opened: {os.path.basename(file_path)} ({type(exc).__name__})", event_type='download', event_level='warning', ) await self._send_error(send, 503, "File is temporarily unavailable") return log_system_event( f"Security violation - download file is not a plain file in place: {file_path[:100]}", event_type='security', event_level='warning', ) await self._send_error(send, 403, "Access denied") return filename = os.path.basename(file_path) try: log_system_event( f"Async file download: {filename}", event_type='download', event_level='information', ) except BaseException: handle.close() raise await self._stream_file(send, handle, filename) async def _handle_rom_download(self, scope, receive, send, path): """Handle ROM file downloads for emulator""" from oneirodex.models import Game, User from oneirodex.utils.rom_archive import ArchiveRomError rom_match = re.match(r'/api/downloadrom/([a-f0-9-]+)', path) if not rom_match: await self._send_error(send, 400, "Invalid game UUID") return game_uuid = rom_match.group(1) try: uuid.UUID(game_uuid) except ValueError: log_system_event( f"Invalid UUID format attempted for ROM download: {game_uuid}", event_type='security', event_level='warning', ) await self._send_error(send, 400, "Invalid game identifier") return user_id = await self._get_user_id(scope) if not user_id: await self._send_error(send, 401, "Unauthorized") return from oneirodex.runtime_native.db_session import native_db with self._native_database_scope(self._flask_app): game = native_db.session.execute(select(Game).filter_by(uuid=game_uuid)).scalars().first() if not game: log_system_event( f"ROM download attempt for non-existent game UUID: {game_uuid}", event_type='security', event_level='warning', ) await self._send_error(send, 404, "Game not found") return user = native_db.session.get(User, user_id) if not user or not user_can_access_game(user, game): log_system_event( f"ROM download blocked by library ACL for user_id={user_id} game={game_uuid[:8]}...", event_type='security', event_level='warning', ) await self._send_error(send, 403, "Access denied") return if not os.path.exists(game.full_disk_path): log_system_event( f"ROM download attempt for missing file: {game.name} at {game.full_disk_path}", event_type='security', event_level='warning', ) await self._send_error(send, 404, "ROM file not found on disk") return allowed_bases = get_allowed_base_directories(self._flask_app) # Resolved once, here. Every later check and the open compare against # this string, so a game folder swapped for a link afterwards reads # as "outside" instead of moving the boundary with it. try: real_game = os.path.realpath(game.full_disk_path) except (OSError, ValueError): real_game = game.full_disk_path is_safe, error_message = is_safe_path(real_game, allowed_bases) if not is_safe: log_system_event( f"Path traversal attempt blocked for ROM download: {game.full_disk_path} - {error_message}", event_type='security', event_level='warning', ) await self._send_error(send, 403, "Access denied") return game_is_dir = os.path.isdir(real_game) cache_root = os.path.join(library_dir(self._flask_app.root_path), 'rom_cache') os.makedirs(cache_root, exist_ok=True) # Extraction output is request-scoped: retries and concurrent plays # cannot reuse partial files or accumulate unbounded cache contents. cache_dir = tempfile.mkdtemp(prefix=f'{game_uuid}-', dir=cache_root) try: platform_key = library_platform_key(game) try: rom_path, filename = resolve_playable_rom_path( game.full_disk_path, cache_dir=cache_dir, platform=platform_key, ) target, root, root_is_resolved = _rom_location(rom_path, cache_dir, real_game, game_is_dir) # Multi-track discs (.cue + .bin/.img/...) need every file in one # response — WebRetro never gets a second chance to fetch companions. bundled_path, bundled_name = bundle_playable_rom_zip( target, cache_dir, root=root if root_is_resolved else None ) if bundled_path != target: target, root, root_is_resolved = bundled_path, cache_dir, False filename = bundled_name except ArchiveRomError as exc: log_system_event( f"ROM resolve failed for {game.name}: {exc.message}", event_type='download', event_level='warning', ) await self._send_error( send, exc.status_code, exc.message, code=exc.code, hint=exc.hint, ) return # Keep temporary output alive until streaming closes the file. try: handle = open_plain_file_within(root, target, base_is_resolved=root_is_resolved) except FileNotFoundError: await self._send_error(send, 404, "ROM file not found on disk") return except OSError: log_system_event( f"ROM download refused, file is not a plain file in place: {game.name}", event_type='security', event_level='warning', ) await self._send_error(send, 403, "Access denied") return try: log_system_event( f"ROM file downloaded for WebRetro: {game.name}", event_type='download', event_level='information', ) except BaseException: handle.close() raise await self._stream_file(send, handle, filename) finally: shutil.rmtree(cache_dir, ignore_errors=True) async def _get_user_id(self, scope): """Resolve user id from Bearer token (API clients) or Flask session cookie (web).""" headers = dict(scope.get("headers", [])) auth_header = headers.get(b"authorization", b"").decode("utf-8") if auth_header.lower().startswith("bearer "): await self._ensure_flask() from starlette.requests import Request from oneirodex.runtime_native.admin_pilot import _native_auth_backend from oneirodex.runtime_native.request_auth import request_auth_context find_user, find_token, commit_token_touch = _native_auth_backend(self._flask_app) # The ASGI download adapter historically gives an explicit Bearer # credential precedence over cookies; don't let a parallel browser # session change which credential is evaluated. bearer_scope = { **scope, 'type': 'http', 'headers': [ (key, value) for key, value in scope.get('headers', []) if key.lower() != b'cookie' ], } with self._native_database_scope(self._flask_app): auth = request_auth_context( Request(bearer_scope), self._flask_app.config, find_user=find_user, find_token=find_token, commit_token_touch=commit_token_touch, login_manager=getattr(self._flask_app, 'login_manager', None), ) user = auth.user if ( user and auth.token and auth.has_scope('write:download') ): return user.id return None return await self._get_user_from_session(scope) async def _get_user_from_session(self, scope): """Extract user ID from Flask session cookie""" try: await self._ensure_flask() from starlette.requests import Request from oneirodex.runtime_native.admin_pilot import _native_auth_backend from oneirodex.runtime_native.auth_resolver import load_active_user_id from oneirodex.runtime_native.request_auth import _lifetime_seconds from oneirodex.runtime_native.session_compat import load_session_cookie request_scope = {**scope, 'type': 'http'} request = Request(request_scope) config = self._flask_app.config session_data = load_session_cookie( request.cookies.get(str(config.get('SESSION_COOKIE_NAME') or 'session')), config.get('SECRET_KEY'), max_age=_lifetime_seconds(config.get('PERMANENT_SESSION_LIFETIME', 31_536_000)), fallback_secret_keys=config.get('SECRET_KEY_FALLBACKS') or (), ) user_id = session_data.get('_user_id') if user_id is None: return None find_user, _find_token, _commit_token_touch = _native_auth_backend(self._flask_app) with self._native_database_scope(self._flask_app): user = load_active_user_id( str(user_id), find_user, allow_legacy_id=bool(config.get('TESTING')), ) return user.id if user is not None else None except Exception as e: log_system_event( f"Error parsing signed session for download authorization: {type(e).__name__}", event_type='security', event_level='warning', ) return None async def _stream_file(self, send, handle, filename): """Stream an open file that ``open_plain_file_within`` already vetted. The size is the descriptor's and so are the bytes, so what is announced is what is sent whatever happens to the path meanwhile. *handle* is closed here. """ started = False try: with handle: size = os.fstat(handle.fileno()).st_size started = True await send({ "type": "http.response.start", "status": 200, "headers": _file_headers(filename, size), }) async for chunk in _read_chunks(handle, size): await send({ "type": "http.response.body", "body": chunk, "more_body": True, }) await send({ "type": "http.response.body", "body": b"", "more_body": False, }) except Exception as e: log_system_event( f"Download of {filename} ended early: the client closed the connection" if isinstance(e, _CLIENT_GONE) else f"Error streaming file {filename}: {str(e)}", event_type='download', event_level='information' if isinstance(e, _CLIENT_GONE) else 'error', ) if started: # Past the status line the only honest ending is an aborted # transfer; see _handle_download. raise await self._send_error(send, 500, "Error streaming file") async def _handle_streaming_download(self, send, download_request, source_path): """Handle zipstream downloads for multi-file games""" started = False try: allowed_bases = get_allowed_base_directories(self._flask_app) if not allowed_bases: await self._send_error(send, 500, "Server configuration error") return # Resolved once, here, and the resolved path is what gets checked and # what the zip is built from. The generator compares every file # against this string and never resolves the folder again, so a game # folder swapped for a link after this point cannot widen "inside". try: real_root = os.path.realpath(source_path) except (OSError, ValueError): real_root = source_path is_safe, error_message = is_safe_path(real_root, allowed_bases) if not is_safe: print(f"Security violation - streaming source outside allowed directories: {source_path[:100]}") await self._send_error(send, 403, "Access denied") return if not os.path.exists(real_root): await self._send_error(send, 404, "Source path not found") return chunk_size = self._flask_app.config.get('ZIPSTREAM_CHUNK_SIZE', 65536) compression_level = self._flask_app.config.get('ZIPSTREAM_COMPRESSION_LEVEL', 0) enable_zip64 = self._flask_app.config.get('ZIPSTREAM_ENABLE_ZIP64', True) if download_request.file_location: base_name = os.path.basename(download_request.file_location) filename = f"{base_name}.zip" if not base_name.lower().endswith('.zip') else base_name else: game = download_request.game filename = f"{game.name}.zip" if game else "download.zip" print(f"Starting zipstream download: {filename}") async_generator, headers = async_generate_zipstream_response( real_root, filename, chunk_size, compression_level, enable_zip64, source_is_resolved=True, ) started = True await send({ "type": "http.response.start", "status": 200, "headers": [(k.encode(), v.encode()) for k, v in headers.items()], }) async for chunk in async_generator: await send({ "type": "http.response.body", "body": chunk, "more_body": True, }) await send({ "type": "http.response.body", "body": b"", "more_body": False, }) print(f"Completed zipstream download: {filename}") except Exception as e: error_filename = locals().get('filename', 'unknown') print(f"Error streaming ZIP {error_filename}: {str(e)}") if started: # A member failed (or vanished, or was swapped for a link) after # the archive was already on its way. Finishing the body here # would give the client a corrupt zip with a clean 200; raising # drops the connection, so the download shows as incomplete. log_system_event( f"ZIP download of {error_filename} ended early: the client closed the connection" if isinstance(e, _CLIENT_GONE) else f"ZIP download aborted mid-stream: {error_filename}", event_type='download', event_level='information' if isinstance(e, _CLIENT_GONE) else 'error', ) raise await self._send_error(send, 500, "Error streaming ZIP file") async def _send_error(self, send, status_code, message, *, code=None, hint=None): """Send an HTTP error response (JSON). Optional code/hint for ROM extract failures.""" payload = {"error": message} if code: payload["code"] = code if hint: payload["hint"] = hint response_body = json.dumps(payload).encode() await send({ "type": "http.response.start", "status": status_code, "headers": [ (b"content-type", b"application/json"), (b"content-length", str(len(response_body)).encode()), ], }) await send({ "type": "http.response.body", "body": response_body, "more_body": False, }) async def _handle_lifespan(self, receive, send): """Handle startup and shutdown until the ASGI server finishes its cycle.""" while True: message = await receive() if message["type"] == "lifespan.startup": try: # Eager-init Flask so the first browser burst does not race bridge setup. await self._ensure_flask() # Background schedulers live here now, not in create_app(). # Wrapped so a scheduler that throws on start can never wedge # lifespan and take the whole server down with it. try: from oneirodex.background import start_background_workers start_background_workers(self._flask_app) except Exception as e: print(f"Background workers failed to start: {e}") await send({"type": "lifespan.startup.complete"}) except Exception as e: print(f"Startup failed: {e}") await send({"type": "lifespan.startup.failed", "message": "Startup failed"}) return elif message["type"] == "lifespan.shutdown": try: try: from oneirodex.background import stop_background_workers stop_background_workers() except Exception as e: print(f"Background workers failed to stop cleanly: {e}") from oneirodex.utils.shutdown import request_shutdown try: request_shutdown() finally: if self._native_sqlalchemy_engine is not None: self._native_sqlalchemy_engine.dispose() self._native_sqlalchemy_engine = None print("🛑 ASGI lifespan shutdown initiated") await send({"type": "lifespan.shutdown.complete"}) except Exception as e: print(f"Shutdown failed: {e}") await send({"type": "lifespan.shutdown.failed", "message": "Shutdown failed"}) return asgi_app = LazyASGIApp()