From a277ab2df31d03592fa47829f351a5b11f891e8f Mon Sep 17 00:00:00 2001 From: Zach Clendenen Date: Sun, 19 Jul 2026 23:42:02 -0500 Subject: [PATCH 01/75] feat: streaming library sync, memory cards, and session hardening Extend the streaming session layer with library-backed asset sync and per-user memory cards. State library sync: manual and autosave states are pulled from the container into the user's library, with PCSX2 screenshots extracted from the .p2s archive as state thumbnails. A session can be claimed with a state id to resume directly from a library state. Save file sync: save files are hydrated into the container at claim and pulled back on release, deduplicated by content hash. Memory cards: a new per-user, per-emulator whole-card model with version history (memory_cards, memory_card_versions). Opt-in per platform, currently PCSX2 only. Cards are hydrated at claim (wipe-then-replace) and evacuated before the session is released. Playtime: session start and end are ingested as play sessions, skipping runs shorter than five seconds. Session hardening: heartbeats mark liveness, and a session whose heartbeat has gone stale can be taken over after the previous container is torn down. Frontend: resume-from-state picker, memory card picker and manager, asset previews, and an admin streaming panel on the activity view. --- backend/alembic/env.py | 8 +- backend/alembic/versions/0099_memory_cards.py | 112 ++ backend/config/config_manager.py | 7 + backend/endpoints/memory_cards.py | 263 +++ backend/endpoints/responses/memory_cards.py | 49 + backend/endpoints/streaming.py | 1475 +++++++++++++- backend/handler/database/__init__.py | 2 + .../handler/database/memory_cards_handler.py | 177 ++ backend/handler/filesystem/assets_handler.py | 9 + backend/handler/scan_handler.py | 15 +- backend/main.py | 2 + backend/models/assets.py | 70 + backend/models/user.py | 5 +- backend/tests/conftest.py | 35 +- backend/tests/endpoints/test_memory_cards.py | 340 ++++ backend/tests/endpoints/test_streaming.py | 1450 +++++++++++++- backend/tools/import_memory_card.py | 99 + examples/config.example.yml | 5 + frontend/src/__generated__/index.ts | 13 + ...mory_cards_api_memory_cards_delete_post.ts | 11 + ...e_memory_card_api_memory_cards__id__put.ts | 8 + ...ty_api_memory_cards__id__visibility_put.ts | 8 + .../models/ClaimSessionRequest.ts | 10 + .../__generated__/models/LoadStateRequest.ts | 8 + .../models/MemoryCardCreatePayload.ts | 11 + .../__generated__/models/MemoryCardSchema.ts | 21 + .../models/MemoryCardVersionSchema.ts | 20 + .../src/__generated__/models/MuteRequest.ts | 8 + .../models/SaveAndExitRequest.ts | 9 + .../__generated__/models/SaveStateRequest.ts | 8 + .../models/UserMemoryCardSchema.ts | 23 + .../src/__generated__/models/VolumeRequest.ts | 8 + frontend/src/locales/bg_BG/activity.json | 9 +- frontend/src/locales/bg_BG/play.json | 52 +- frontend/src/locales/cs_CZ/activity.json | 9 +- frontend/src/locales/cs_CZ/play.json | 52 +- frontend/src/locales/de_DE/activity.json | 9 +- frontend/src/locales/de_DE/play.json | 52 +- frontend/src/locales/en_GB/activity.json | 9 +- frontend/src/locales/en_GB/play.json | 52 +- frontend/src/locales/en_US/activity.json | 9 +- frontend/src/locales/en_US/play.json | 52 +- frontend/src/locales/es_ES/activity.json | 9 +- frontend/src/locales/es_ES/play.json | 52 +- frontend/src/locales/fr_FR/activity.json | 9 +- frontend/src/locales/fr_FR/play.json | 52 +- frontend/src/locales/hu_HU/activity.json | 9 +- frontend/src/locales/hu_HU/play.json | 52 +- frontend/src/locales/it_IT/activity.json | 9 +- frontend/src/locales/it_IT/play.json | 52 +- frontend/src/locales/ja_JP/activity.json | 9 +- frontend/src/locales/ja_JP/play.json | 52 +- frontend/src/locales/ko_KR/activity.json | 9 +- frontend/src/locales/ko_KR/play.json | 52 +- frontend/src/locales/pl_PL/activity.json | 9 +- frontend/src/locales/pl_PL/play.json | 52 +- frontend/src/locales/pt_BR/activity.json | 9 +- frontend/src/locales/pt_BR/play.json | 52 +- frontend/src/locales/ro_RO/activity.json | 9 +- frontend/src/locales/ro_RO/play.json | 52 +- frontend/src/locales/ru_RU/activity.json | 9 +- frontend/src/locales/ru_RU/play.json | 52 +- frontend/src/locales/tr_TR/activity.json | 9 +- frontend/src/locales/tr_TR/play.json | 52 +- frontend/src/locales/zh_CN/activity.json | 9 +- frontend/src/locales/zh_CN/play.json | 52 +- frontend/src/locales/zh_TW/activity.json | 9 +- frontend/src/locales/zh_TW/play.json | 52 +- frontend/src/services/api/memory-card.ts | 65 + frontend/src/services/api/streaming.ts | 78 +- frontend/src/stores/streaming.ts | 115 +- .../src/v2/components/Player/AssetPreview.vue | 39 +- .../components/Player/MemoryCardManager.vue | 629 ++++++ .../v2/components/Player/MemoryCardPicker.vue | 277 +++ .../src/v2/composables/useCanPlay/index.ts | 24 +- .../v2/composables/useGameActions/index.ts | 16 +- .../src/v2/composables/useGamepad/index.ts | 39 +- frontend/src/v2/layouts/AppLayout.vue | 3 +- .../src/v2/lib/overlays/RDialog/RDialog.vue | 12 +- frontend/src/v2/views/Activity.vue | 167 +- frontend/src/v2/views/Gallery/Platform.vue | 42 +- frontend/src/v2/views/Player/Stream.vue | 1782 ++++++++++++----- 82 files changed, 7950 insertions(+), 725 deletions(-) create mode 100644 backend/alembic/versions/0099_memory_cards.py create mode 100644 backend/endpoints/memory_cards.py create mode 100644 backend/endpoints/responses/memory_cards.py create mode 100644 backend/handler/database/memory_cards_handler.py create mode 100644 backend/tests/endpoints/test_memory_cards.py create mode 100644 backend/tools/import_memory_card.py create mode 100644 frontend/src/__generated__/models/Body_delete_memory_cards_api_memory_cards_delete_post.ts create mode 100644 frontend/src/__generated__/models/Body_rename_memory_card_api_memory_cards__id__put.ts create mode 100644 frontend/src/__generated__/models/Body_update_memory_card_visibility_api_memory_cards__id__visibility_put.ts create mode 100644 frontend/src/__generated__/models/ClaimSessionRequest.ts create mode 100644 frontend/src/__generated__/models/LoadStateRequest.ts create mode 100644 frontend/src/__generated__/models/MemoryCardCreatePayload.ts create mode 100644 frontend/src/__generated__/models/MemoryCardSchema.ts create mode 100644 frontend/src/__generated__/models/MemoryCardVersionSchema.ts create mode 100644 frontend/src/__generated__/models/MuteRequest.ts create mode 100644 frontend/src/__generated__/models/SaveAndExitRequest.ts create mode 100644 frontend/src/__generated__/models/SaveStateRequest.ts create mode 100644 frontend/src/__generated__/models/UserMemoryCardSchema.ts create mode 100644 frontend/src/__generated__/models/VolumeRequest.ts create mode 100644 frontend/src/services/api/memory-card.ts create mode 100644 frontend/src/v2/components/Player/MemoryCardManager.vue create mode 100644 frontend/src/v2/components/Player/MemoryCardPicker.vue diff --git a/backend/alembic/env.py b/backend/alembic/env.py index c38db030f0..c88007fafb 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -6,7 +6,13 @@ from config.config_manager import ConfigManager from logger.logger import unify_logger -from models.assets import Save, Screenshot, State # noqa +from models.assets import ( # noqa + MemoryCard, + MemoryCardVersion, + Save, + Screenshot, + State, +) from models.base import BaseModel from models.collection import VirtualCollection from models.firmware import Firmware # noqa diff --git a/backend/alembic/versions/0099_memory_cards.py b/backend/alembic/versions/0099_memory_cards.py new file mode 100644 index 0000000000..41efb1cf06 --- /dev/null +++ b/backend/alembic/versions/0099_memory_cards.py @@ -0,0 +1,112 @@ +"""Add memory_cards and memory_card_versions tables + +Revision ID: 0099_memory_cards +Revises: 0098_generated_metadata_columns +Create Date: 2026-07-12 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0099_memory_cards" +down_revision = "0098_generated_metadata_columns" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "memory_cards", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("emulator", sa.String(length=50), nullable=False), + sa.Column("platform_id", sa.Integer(), nullable=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("slot", sa.Integer(), nullable=False, server_default="1"), + sa.Column( + "is_public", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["platform_id"], ["platforms.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("memory_cards") as batch_op: + batch_op.create_index( + batch_op.f("ix_memory_cards_user_emulator"), + ["user_id", "emulator"], + ) + batch_op.create_index( + batch_op.f("ix_memory_cards_public"), + ["is_public"], + ) + + op.create_table( + "memory_card_versions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("memory_card_id", sa.Integer(), nullable=False), + sa.Column("file_name", sa.String(length=450), nullable=False), + sa.Column("file_name_no_tags", sa.String(length=450), nullable=False), + sa.Column("file_name_no_ext", sa.String(length=450), nullable=False), + sa.Column("file_extension", sa.String(length=100), nullable=False), + sa.Column("file_path", sa.String(length=1000), nullable=False), + sa.Column( + "file_size_bytes", sa.BigInteger(), nullable=False, server_default="0" + ), + sa.Column( + "missing_from_fs", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column("content_hash", sa.String(length=32), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint( + ["memory_card_id"], ["memory_cards.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("memory_card_versions") as batch_op: + batch_op.create_index( + batch_op.f("ix_memory_card_versions_card"), + ["memory_card_id"], + ) + batch_op.create_index( + batch_op.f("ix_memory_card_versions_card_hash"), + ["memory_card_id", "content_hash"], + ) + + +def downgrade() -> None: + # drop_table removes the tables' indexes and foreign keys; dropping the + # FK-backing indexes explicitly first is both redundant and rejected by + # MariaDB/MySQL. + op.drop_table("memory_card_versions") + op.drop_table("memory_cards") diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 1dc80d2cd7..53080fba1f 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -139,6 +139,13 @@ class StreamingContainer(TypedDict): host: str broker_host: str label: str + library_path: NotRequired[str] + # Namespace for stored states/cards; defaults to label (or platform) + # lowercased when omitted. + emulator: NotRequired[str] + # Opt in to whole memory-card sync (broker /memory-card). When true, the + # legacy per-file /save-file in-game-save path is skipped for this container. + memory_card_sync: NotRequired[bool] class Config: diff --git a/backend/endpoints/memory_cards.py b/backend/endpoints/memory_cards.py new file mode 100644 index 0000000000..e065929de8 --- /dev/null +++ b/backend/endpoints/memory_cards.py @@ -0,0 +1,263 @@ +from typing import Annotated + +from fastapi import Body, HTTPException, Request, status +from fastapi.responses import FileResponse +from pydantic import BaseModel as PydanticBaseModel + +from decorators.auth import protected_route +from endpoints.responses.memory_cards import ( + MemoryCardSchema, + MemoryCardVersionSchema, + UserMemoryCardSchema, +) +from handler.auth.constants import Scope +from handler.database import db_memory_card_handler, db_platform_handler +from handler.filesystem import fs_asset_handler +from handler.filesystem.assets_handler import build_asset_file_response +from logger.formatter import highlight as hl +from logger.logger import log +from models.assets import MemoryCard +from utils.router import APIRouter + +router = APIRouter( + prefix="/memory-cards", + tags=["memory-cards"], +) + + +class MemoryCardCreatePayload(PydanticBaseModel): + name: str + emulator: str + # Loose display hint only; never scopes lookup (see MemoryCard model). + platform_id: int | None = None + is_public: bool = False + + +def _card_or_404(card_id: int, user_id: int) -> MemoryCard: + """Fetch a card the caller may read: their own, or another user's public + one. Everything else is a 404 (never reveal a private card exists).""" + card = db_memory_card_handler.get_card_by_id(card_id) + if not card or (card.user_id != user_id and not card.is_public): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Memory card with ID {card_id} not found", + ) + return card + + +def _owned_card_or_404(card_id: int, user_id: int) -> MemoryCard: + """Fetch a card the caller owns, for mutations. A card owned by someone + else is a 404, matching how states scope writes to the owner.""" + card = db_memory_card_handler.get_card(user_id=user_id, id=card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Memory card with ID {card_id} not found", + ) + return card + + +@protected_route(router.post, "", [Scope.ASSETS_WRITE]) +def add_memory_card( + request: Request, + payload: MemoryCardCreatePayload, +) -> MemoryCardSchema: + """Create an empty memory card. It hydrates onto a container at the next + streaming claim; its data accrues as versions on save-and-exit.""" + name = payload.name.strip() + emulator = payload.emulator.strip() + if not name or not emulator: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Both name and emulator are required", + ) + + if payload.platform_id is not None and not db_platform_handler.get_platform( + payload.platform_id + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Platform with ID {payload.platform_id} not found", + ) + + card = db_memory_card_handler.add_card( + MemoryCard( + user_id=request.user.id, + emulator=emulator, + platform_id=payload.platform_id, + name=name, + slot=1, + is_public=payload.is_public, + ) + ) + log.info(f"Created memory card {hl(name)} [{emulator}] for {request.user.username}") + return MemoryCardSchema.model_validate(card) + + +@protected_route(router.get, "", [Scope.ASSETS_READ]) +def get_memory_cards( + request: Request, emulator: str | None = None +) -> list[MemoryCardSchema]: + """The caller's own cards, newest-synced first, optionally one emulator.""" + cards = db_memory_card_handler.get_cards(request.user.id, emulator) + return [MemoryCardSchema.model_validate(card) for card in cards] + + +@protected_route(router.get, "/shared", [Scope.ASSETS_READ]) +def get_shared_memory_cards( + request: Request, emulator: str +) -> list[UserMemoryCardSchema]: + """Cards for an emulator visible to the caller: their own plus other users' + public ones. Used by the claim picker so a user can hydrate a shared card.""" + cards = db_memory_card_handler.get_shared_cards( + emulator=emulator, user_id=request.user.id + ) + return [ + UserMemoryCardSchema.model_validate( + { + **MemoryCardSchema.model_validate(card).model_dump(), + "username": card.user.username, + "user_avatar_path": card.user.avatar_path, + "user_updated_at": card.user.updated_at, + } + ) + for card in cards + ] + + +@protected_route( + router.get, + "/versions/{id}/content", + [Scope.ASSETS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +def download_memory_card_version(request: Request, id: int) -> FileResponse: + """Download a version's card archive. Readable if the caller owns the parent + card or it is public.""" + version = db_memory_card_handler.get_version_by_id(id) + if not version: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Memory card version with ID {id} not found", + ) + # Reuse the card visibility check on the parent. + _card_or_404(version.memory_card_id, request.user.id) + + try: + file_path = fs_asset_handler.validate_path(version.full_path) + except ValueError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memory card file not found", + ) from None + + if not file_path.exists() or not file_path.is_file(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memory card file not found on disk", + ) + + return build_asset_file_response(file_path, filename=version.file_name) + + +@protected_route(router.get, "/{id}", [Scope.ASSETS_READ]) +def get_memory_card(request: Request, id: int) -> MemoryCardSchema: + """A single card: the caller's own or another user's public one.""" + card = _card_or_404(id, request.user.id) + return MemoryCardSchema.model_validate(card) + + +@protected_route(router.get, "/{id}/versions", [Scope.ASSETS_READ]) +def get_memory_card_versions( + request: Request, id: int +) -> list[MemoryCardVersionSchema]: + """A card's snapshot history, newest first.""" + _card_or_404(id, request.user.id) + versions = db_memory_card_handler.get_versions(id) + return [MemoryCardVersionSchema.model_validate(v) for v in versions] + + +@protected_route( + router.put, + "/{id}", + [Scope.ASSETS_WRITE], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +def rename_memory_card( + request: Request, + id: int, + name: Annotated[str, Body(embed=True)], +) -> MemoryCardSchema: + """Rename a card (owner only).""" + _owned_card_or_404(id, request.user.id) + cleaned = name.strip() + if not cleaned: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Name cannot be empty", + ) + updated = db_memory_card_handler.update_card(id, {"name": cleaned}) + return MemoryCardSchema.model_validate(updated) + + +@protected_route( + router.put, + "/{id}/visibility", + [Scope.ASSETS_WRITE], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +def update_memory_card_visibility( + request: Request, + id: int, + is_public: Annotated[bool, Body(embed=True)], +) -> MemoryCardSchema: + """Toggle a card's public/private visibility (owner only). Sharing is + one-way: a recipient's writes go to their own card, never back to this one.""" + _owned_card_or_404(id, request.user.id) + updated = db_memory_card_handler.update_card(id, {"is_public": is_public}) + return MemoryCardSchema.model_validate(updated) + + +@protected_route( + router.post, + "/delete", + [Scope.ASSETS_WRITE], + responses={ + status.HTTP_400_BAD_REQUEST: {}, + status.HTTP_404_NOT_FOUND: {}, + }, +) +async def delete_memory_cards( + request: Request, + cards: Annotated[ + list[int], + Body( + description="List of memory card ids to delete.", + embed=True, + ), + ], +) -> list[int]: + """Delete cards the caller owns, with their version files. Versions cascade + in the database; their on-disk archives are removed here.""" + if not cards: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No memory cards were provided", + ) + + for card_id in cards: + card = _owned_card_or_404(card_id, request.user.id) + + # Remove each version's archive before the DB rows cascade away. + for version in db_memory_card_handler.get_versions(card_id): + try: + await fs_asset_handler.remove_file(file_path=version.full_path) + except FileNotFoundError: + log.warning( + f"Memory card file {hl(version.file_name)} already gone from disk" + ) + + db_memory_card_handler.delete_card(card_id) + log.info(f"Deleted memory card {hl(card.name)} [{card.emulator}]") + + return cards diff --git a/backend/endpoints/responses/memory_cards.py b/backend/endpoints/responses/memory_cards.py new file mode 100644 index 0000000000..52be116204 --- /dev/null +++ b/backend/endpoints/responses/memory_cards.py @@ -0,0 +1,49 @@ +from pydantic import ConfigDict + +from .base import BaseModel, UTCDatetime + + +class MemoryCardVersionSchema(BaseModel): + """A single snapshot in a card's history. Unlike the ROM-scoped assets it + has no rom_id/user_id, so it does not reuse the shared BaseAsset schema.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + memory_card_id: int + file_name: str + file_size_bytes: int + content_hash: str | None = None + download_path: str + missing_from_fs: bool + + created_at: UTCDatetime + updated_at: UTCDatetime + + +class MemoryCardSchema(BaseModel): + """A card's identity. Its data lives in `versions`; the list views return + the card without them (fetch history via the versions route) so the schema + never touches the lazy="raise" relationship.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + emulator: str + platform_id: int | None = None + name: str + slot: int + is_public: bool = False + + created_at: UTCDatetime + updated_at: UTCDatetime + + +class UserMemoryCardSchema(MemoryCardSchema): + """A card enriched with its owner's username, for the shared/community + picker. Mirrors UserStateSchema.""" + + username: str + user_avatar_path: str = "" + user_updated_at: UTCDatetime | None = None diff --git a/backend/endpoints/streaming.py b/backend/endpoints/streaming.py index e9e4bb4040..711d5f6179 100644 --- a/backend/endpoints/streaming.py +++ b/backend/endpoints/streaming.py @@ -1,11 +1,16 @@ import asyncio +import hashlib +import io import json import logging +import os +import re import urllib.error import urllib.request +import zipfile from datetime import datetime, timezone from typing import Annotated, Any, TypedDict -from urllib.parse import urlparse, urlunparse +from urllib.parse import quote, urlparse, urlunparse from fastapi import Body, HTTPException, Request from fastapi.responses import JSONResponse @@ -16,9 +21,27 @@ from decorators.auth import protected_route from handler.auth.constants import Scope from handler.auth.dependencies import assert_rom_visible -from handler.database import db_rom_handler +from handler.database import ( + db_memory_card_handler, + db_rom_handler, + db_save_handler, + db_screenshot_handler, + db_state_handler, + db_user_handler, +) +from handler.filesystem import fs_asset_handler +from handler.play_session_handler import ingest_play_sessions from handler.redis_handler import async_cache -from models.user import Role +from handler.scan_handler import ( + scan_memory_card_version, + scan_save, + scan_screenshot, + scan_state, +) +from models.assets import MemoryCard +from models.rom import Rom +from models.user import Role, User +from utils.filesystem import sanitize_filename from utils.router import APIRouter log = logging.getLogger("romm") @@ -35,8 +58,8 @@ # must not live forever: if the broker container dies or the backend # crashes mid-session, the TTL ensures the container is eventually # reclaimable instead of wedged until an admin force-releases. Control -# calls (save-state / volume / mute / save-and-exit) refresh the TTL so a -# session in active use never expires. +# calls (save-state / volume / mute / save-and-exit) and the heartbeat +# refresh the TTL so a session in active use never expires. SESSION_TTL_SECONDS = 6 * 60 * 60 # When save-and-exit runs with wait=false the broker is still killing the @@ -46,6 +69,14 @@ # expires on its own; no explicit DELETE. SESSION_DRAIN_SECONDS = 5 +# A live player refreshes `last_seen` roughly every 30s (frontend heartbeat, +# piggybacked on the activity interval). A session whose stamp is older than +# this is abandoned (tab closed, browser crashed, network gone) and the next +# claim may tear it down and take the container over. Generous enough to ride +# out background-tab timer throttling (browsers wake timers at least once a +# minute). +_SESSION_STALE_SECONDS = 180 + def _session_redis_key(session_key: str) -> str: return f"{_SESSION_KEY_PREFIX}{session_key}" @@ -70,6 +101,24 @@ async def _refresh_session(session_key: str) -> None: await async_cache.expire(_session_redis_key(session_key), SESSION_TTL_SECONDS) +def _session_is_stale(session: dict[str, Any]) -> bool: + """True when the owner's heartbeat stopped long enough ago that the session + counts as abandoned. Sessions written before heartbeats existed carry no + `last_seen`; their `claimed_at` stands in. An unparseable stamp counts as + stale so a corrupt record cannot wedge the container.""" + stamp = session.get("last_seen") or session.get("claimed_at") + if not isinstance(stamp, str): + return True + try: + seen = datetime.fromisoformat(stamp) + except ValueError: + return True + if seen.tzinfo is None: + seen = seen.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - seen).total_seconds() + return age > _SESSION_STALE_SECONDS + + def _assert_session_owner(session: dict[str, Any], request: Request) -> None: """Only the user who claimed a session (or an admin) may control it.""" if session.get("user_id") == request.user.id: @@ -176,6 +225,14 @@ def _assert_valid_slot(platform: str, slot: int, *, allow_autosave: bool) -> Non class ClaimSessionRequest(BaseModel): rom_id: Annotated[int, Field(ge=1)] + # Optional state to resume from: the backend pushes its file to the broker + # before launch and the broker loads its slot once the game is up. Must be + # the claiming user's own state or a public one shared by another user. + state_id: Annotated[int, Field(ge=1)] | None = None + # Optional memory card to mount (whole-card sync containers only). Omitted = + # the user's most-recently-used card for the emulator, or a fresh one on + # first play. Must be one the claiming user owns. + memory_card_id: Annotated[int, Field(ge=1)] | None = None class SaveAndExitRequest(BaseModel): @@ -210,9 +267,6 @@ def _get_streaming_config() -> dict[str, Any]: return {"enabled": cfg.STREAMING_ENABLED, "containers": cfg.STREAMING_CONTAINERS} -# ── Routes ──────────────────────────────────────────────────────────────────── - - def _container_for_platform(platform: str) -> dict[str, Any] | None: cfg = _get_streaming_config() if not cfg.get("enabled", False): @@ -263,7 +317,22 @@ async def _resolve_owned_session( return container, session_key, session -# ── Broker communication ──────────────────────────────────────────────────────────────────── +# ── Broker communication ────────────────────────────────────────────────────── + +# Broker HTTP deadlines, grouped by what the call actually waits on: +# ACK - the broker only acknowledges; the work runs async on its side +# LAUNCH - process spawn + config patch + window setup +# LOAD_STATE - worst case 9 slot cycles x ~5s xdotool timeout +# TRANSFER - state/save archive uploads and downloads (up to 256 MB) +# CARD_HYDRATE / CARD_TEARDOWN - whole-card push at claim / pull at exit; +# hydration may wait on a slow first-run card format, teardown must not +# hold a closing session hostage for two minutes +_BROKER_ACK_TIMEOUT = 5 +_BROKER_LAUNCH_TIMEOUT = 10 +_BROKER_LOAD_STATE_TIMEOUT = 60 +_BROKER_TRANSFER_TIMEOUT = 60 +_CARD_HYDRATE_TIMEOUT = 120 +_CARD_TEARDOWN_TIMEOUT = 30 def _broker_url(container: dict[str, Any], path: str) -> str: @@ -350,22 +419,29 @@ def _broker_request_safe( return None -def _call_broker(container: dict[str, Any], rom_path: str, rom_name: str) -> None: +def _call_broker( + container: dict[str, Any], + rom_path: str, + rom_name: str, + load_slot: int | None = None, +) -> None: """ POST to the broker's /launch endpoint to tell the emulator container to load a ROM. - Raises HTTPException if the broker is unreachable or returns an error. + With load_slot set the broker loads that save-state slot once the game + is up (resume-from-state). Raises HTTPException if the broker is + unreachable or returns an error. """ url = _broker_url(container, "/launch") + body: dict[str, Any] = {"rom_path": rom_path, "rom_name": rom_name} + if load_slot is not None: + body["load_slot"] = load_slot try: - body = _broker_request( - container, - "/launch", - body={"rom_path": rom_path, "rom_name": rom_name}, - timeout=10, + resp = _broker_request( + container, "/launch", body=body, timeout=_BROKER_LAUNCH_TIMEOUT ) - log.info("broker launched ROM, %s", body) + log.info("broker launched ROM, %s", resp) except urllib.error.HTTPError as exc: error_body = exc.read().decode(errors="replace") log.error("broker HTTP error %d: %s", exc.code, error_body) @@ -391,12 +467,14 @@ def _call_broker(container: dict[str, Any], rom_path: str, rom_name: str) -> Non def _save_and_exit_broker( container: dict[str, Any], slot: int = 0, wait: bool = True -) -> bool: +) -> tuple[bool, int]: """ POST /save-and-exit to the broker. Best-effort, logs but never raises. With wait=True the call blocks until save+kill completes (use for button press). With wait=False the broker fires save+kill in the background (use for navigation away). - Returns True if the broker reported a successful save. + Returns (saved, slot). Brokers resolve slot 0 to their default autosave + slot and echo the effective slot back, which the state sync needs to pull + the right file afterwards. """ # Waiting brokers can legitimately block for a while: rpcs3 polls the # savestate write for up to SAVE_WAIT (30s default) and xemu's QMP @@ -408,17 +486,26 @@ def _save_and_exit_broker( "/save-and-exit", "save-and-exit", body={"slot": slot, "wait": wait}, - timeout=STREAMING_SAVE_TIMEOUT if wait else 5, + timeout=STREAMING_SAVE_TIMEOUT if wait else _BROKER_ACK_TIMEOUT, ) saved = bool(body and body.get("saved", False)) - log.info("broker save-and-exit, saved=%s slot=%d wait=%s", saved, slot, wait) - return saved + effective_slot = slot + if body is not None and isinstance(body.get("slot"), int): + effective_slot = body["slot"] + log.info( + "broker save-and-exit, saved=%s slot=%d wait=%s", saved, effective_slot, wait + ) + return saved, effective_slot def _volume_broker(container: dict[str, Any], level: int) -> bool: """POST /volume to the broker. Best-effort, logs but never raises.""" body = _broker_request_safe( - container, "/volume", "volume", body={"level": level}, timeout=5 + container, + "/volume", + "volume", + body={"level": level}, + timeout=_BROKER_ACK_TIMEOUT, ) return bool(body and body.get("status") == "ok") @@ -430,7 +517,7 @@ def _mute_broker(container: dict[str, Any], mute: bool | None) -> bool | None: "/mute", "mute", body={} if mute is None else {"mute": mute}, - timeout=5, + timeout=_BROKER_ACK_TIMEOUT, ) return body.get("mute") if body is not None else None @@ -438,7 +525,11 @@ def _mute_broker(container: dict[str, Any], mute: bool | None) -> bool | None: def _save_state_broker(container: dict[str, Any], slot: int) -> bool: """POST /save-state to the broker. Returns True if the request was accepted.""" body = _broker_request_safe( - container, "/save-state", "save-state", body={"slot": slot}, timeout=5 + container, + "/save-state", + "save-state", + body={"slot": slot}, + timeout=_BROKER_ACK_TIMEOUT, ) return bool(body and body.get("status") == "saving") @@ -447,14 +538,1004 @@ def _load_state_broker(container: dict[str, Any], slot: int) -> bool: """POST /load-state to the broker. Returns True if broker confirmed success.""" # Timeout covers the worst case: 9 slot cycles x ~5s xdotool timeout. body = _broker_request_safe( - container, "/load-state", "load-state", body={"slot": slot}, timeout=60 + container, + "/load-state", + "load-state", + body={"slot": slot}, + timeout=_BROKER_LOAD_STATE_TIMEOUT, ) return bool(body and body.get("loaded", False)) def _stop_broker(container: dict[str, Any]) -> None: """Tell the broker to stop emulator. Best-effort, don't raise on failure.""" - _broker_request_safe(container, "/launch", "stop", method="DELETE", timeout=5) + _broker_request_safe( + container, "/launch", "stop", method="DELETE", timeout=_BROKER_ACK_TIMEOUT + ) + + +# ── Save-state sync ─────────────────────────────────────────────────────────── +# +# Emulator save states are centralized through RomM's states asset store so +# they survive container rebuilds and roam across containers. The backend is +# the only file mover: after a save it pulls the state file from the broker +# and stores it under the session user's assets; on claim it pushes the user's +# stored states back down so the container slots always reflect the central +# copy (last write wins, central copy is the source of truth). +# +# Broker file API (secret-protected, stdlib on the broker side): +# GET /state-file?slot=N - newest state file for slot N. Blocks while a +# save is in flight, so no clock coupling between +# hosts. Returns raw bytes + X-State-Filename. +# PUT /state-file?filename=NAME - write NAME into the emulator's state dir. + +# Maximum state file size accepted from a broker (PCSX2 states with a large +# VRAM snapshot run tens of MB; 256 MB leaves generous headroom). +_STATE_FILE_MAX_BYTES = 256 * 1024 * 1024 + +# Pull retries cover the window between the broker accepting a save and the +# emulator finishing the write (PINE/xdotool waits run up to ~15s per broker). +_STATE_PULL_ATTEMPTS = 5 +_STATE_PULL_RETRY_DELAY = 3.0 + +# Strong references to fire-and-forget sync tasks so the event loop does not +# garbage-collect them mid-flight. +_sync_tasks: set[asyncio.Task] = set() + + +def _spawn_sync_task(coro: Any) -> None: + task = asyncio.get_running_loop().create_task(coro) + _sync_tasks.add(task) + task.add_done_callback(_sync_tasks.discard) + + +def _emulator_for_container(container: dict[str, Any]) -> str: + """Namespace for stored states, e.g. 'pcsx2'. + + An explicit `emulator` key on the container config wins; otherwise the + label (or platform slug) lowercased. Keeps streaming states separate from + EmulatorJS states for the same ROM. + """ + emulator = ( + container.get("emulator") + or container.get("label") + or container.get("platform") + or "" + ) + return str(emulator).strip().lower() + + +def _fetch_state_file(container: dict[str, Any], slot: int) -> tuple[str, bytes] | None: + """GET /state-file from the broker. Returns (filename, content) or None. + + The broker blocks while a save is in flight, so a generous timeout stands + in for save-completion polling. 404 means no state exists for the slot. + """ + url = _broker_url(container, f"/state-file?slot={slot}") + secret = _broker_secret(container) + req = urllib.request.Request( + url, + method="GET", + headers={**({"X-Broker-Secret": secret} if secret else {})}, + ) + try: + with urllib.request.urlopen( # nosec B310 + req, timeout=_BROKER_TRANSFER_TIMEOUT + ) as resp: + filename = resp.headers.get("X-State-Filename", "") + content = resp.read(_STATE_FILE_MAX_BYTES + 1) + if not filename or not content: + log.warning("broker state-file response missing data") + return None + if len(content) > _STATE_FILE_MAX_BYTES: + log.warning("broker state file exceeds size limit") + return None + return filename, content + except urllib.error.HTTPError as exc: + if exc.code != 404: + log.warning("broker state-file GET failed, HTTP %d", exc.code) + return None + except Exception as exc: + log.warning("broker state-file GET failed, %s", exc) + return None + + +def _push_state_file(container: dict[str, Any], filename: str, content: bytes) -> bool: + """PUT /state-file to the broker. Best-effort, logs but never raises.""" + url = _broker_url(container, f"/state-file?filename={quote(filename, safe='')}") + secret = _broker_secret(container) + req = urllib.request.Request( + url, + data=content, + method="PUT", + headers={ + "Content-Type": "application/octet-stream", + "Content-Length": str(len(content)), + **({"X-Broker-Secret": secret} if secret else {}), + }, + ) + try: + with urllib.request.urlopen( # nosec B310 + req, timeout=_BROKER_TRANSFER_TIMEOUT + ) as resp: + body = json.loads(resp.read()) + return body.get("status") == "ok" + except Exception as exc: + log.warning("broker state-file PUT failed, %s", exc) + return False + + +# PCSX2 embeds a PNG of the moment of save inside every .p2s savestate zip +# under this entry name (pcsx2/SaveState.cpp: EntryFilename_Screenshot). +# Extracting it gives each pulled state a thumbnail with no broker round-trip, +# mirroring how in-browser EmulatorJS states carry a screenshot. +_STATE_SCREENSHOT_ZIP_ENTRY = "Screenshot.png" +_STATE_SCREENSHOT_MAX_BYTES = 16 * 1024 * 1024 + + +def _extract_state_screenshot(emulator: str, state_content: bytes) -> bytes | None: + """Pull the embedded frame PNG out of a savestate archive, or None when the + format carries no embedded screenshot. Only PCSX2 (.p2s zip) is mapped + today; #21 handles the other emulators when the model is generalized.""" + if emulator != "pcsx2": + return None + try: + with zipfile.ZipFile(io.BytesIO(state_content)) as zf: + with zf.open(_STATE_SCREENSHOT_ZIP_ENTRY) as entry: + data = entry.read(_STATE_SCREENSHOT_MAX_BYTES + 1) + except (KeyError, zipfile.BadZipFile, OSError) as exc: + # No screenshot entry, or the state is not a readable zip. Not fatal: + # the state still syncs, it just has no thumbnail. + log.warning("could not extract state screenshot, %s", exc) + return None + if not data or len(data) > _STATE_SCREENSHOT_MAX_BYTES: + return None + return data + + +async def _store_state_screenshot( + user: User, rom: Rom, state_filename: str, image: bytes +) -> None: + """Store an extracted state screenshot so it binds to the state as its + thumbnail. State.screenshot matches by filename stem, so the image reuses + the state's stem with a .png extension. is_gallery stays False (the default) + so it never shows in the user's screenshot gallery - it only helps the + resume picker show the right frame. Mirrors the POST /api/states thumbnail + path so streaming and in-browser states share one screenshots directory. + """ + filename = sanitize_filename(f"{os.path.splitext(state_filename)[0]}.png") + screenshots_path = fs_asset_handler.build_screenshots_file_path( + user=user, platform_fs_slug=rom.platform_slug, rom_id=rom.id + ) + await fs_asset_handler.write_file( + file=image, path=screenshots_path, filename=filename + ) + scanned = await scan_screenshot( + file_name=filename, + user=user, + platform_fs_slug=rom.platform_slug, + rom_id=rom.id, + ) + existing = db_screenshot_handler.get_screenshot( + file_name=filename, rom_id=rom.id, user_id=user.id + ) + if existing: + db_screenshot_handler.update_screenshot( + existing.id, {"file_size_bytes": scanned.file_size_bytes} + ) + else: + scanned.rom_id = rom.id + scanned.user_id = user.id + db_screenshot_handler.add_screenshot(screenshot=scanned) + + +async def _store_state_asset( + user: User, rom: Rom, emulator: str, filename: str, content: bytes +) -> None: + """Store a pulled state file through the same flow as POST /api/states.""" + states_path = fs_asset_handler.build_states_file_path( + user=user, + platform_fs_slug=rom.platform.fs_slug, + rom_id=rom.id, + emulator=emulator, + ) + await fs_asset_handler.write_file(file=content, path=states_path, filename=filename) + + scanned_state = await scan_state( + file_name=filename, + user=user, + platform_fs_slug=rom.platform.fs_slug, + rom_id=rom.id, + emulator=emulator, + ) + db_state = db_state_handler.get_state_by_filename( + user_id=user.id, rom_id=rom.id, file_name=filename + ) + if db_state: + db_state_handler.update_state( + db_state.id, {"file_size_bytes": scanned_state.file_size_bytes} + ) + else: + scanned_state.rom_id = rom.id + scanned_state.user_id = user.id + scanned_state.emulator = emulator + db_state_handler.add_state(state=scanned_state) + + # Bind a thumbnail to the state so the resume picker shows the right frame. + # Best-effort: a missing or unreadable screenshot must not fail the sync. + image = _extract_state_screenshot(emulator, content) + if image is not None: + try: + await _store_state_screenshot(user, rom, filename, image) + except Exception: + log.exception("failed to store state screenshot for %s", filename) + + +async def _pull_state_to_library( + user_id: int, rom_id: int, container: dict[str, Any], slot: int +) -> bool: + """Background task: pull a freshly saved state from the broker and store it. + + Best-effort by design, a sync failure must never surface to the player, + the state still exists inside the container. + """ + user = db_user_handler.get_user(user_id) + rom = db_rom_handler.get_rom(rom_id) + if user is None or rom is None: + return False + emulator = _emulator_for_container(container) + + for attempt in range(_STATE_PULL_ATTEMPTS): + if attempt > 0: + await asyncio.sleep(_STATE_PULL_RETRY_DELAY) + result = await asyncio.to_thread(_fetch_state_file, container, slot) + if result is None: + continue + filename, content = result + try: + filename = sanitize_filename(filename) + except ValueError: + log.warning("broker returned invalid state filename") + return False + try: + await _store_state_asset(user, rom, emulator, filename, content) + except Exception: + log.exception("failed to store pulled state %s", filename) + return False + log.info( + "state synced to library, rom=%s slot=%d file=%s", + rom.name, + slot, + filename, + ) + return True + + log.warning("no state file to pull after save, rom_id=%d slot=%d", rom_id, slot) + return False + + +async def _hydrate_states_to_broker( + user_id: int, + rom_id: int, + container: dict[str, Any], + skip_filename: str | None = None, +) -> int: + """Background task: push the user's stored states for this ROM down to the + freshly claimed container, overwriting whatever slots it holds. Emulators + read state files lazily, so pushing right after launch is safe. + + skip_filename protects a resume-from-state file already pushed at claim + time: the user's own state for the same slot shares its filename and + would otherwise overwrite it before the broker's deferred load fires. + """ + user = db_user_handler.get_user(user_id) + rom = db_rom_handler.get_rom(rom_id) + if user is None or rom is None: + return 0 + emulator = _emulator_for_container(container) + + pushed = 0 + for state in db_state_handler.get_states(user_id=user_id, rom_id=rom_id): + if (state.emulator or "").lower() != emulator: + continue + if skip_filename is not None and state.file_name == skip_filename: + continue + try: + content = await fs_asset_handler.read_file( + f"{state.file_path}/{state.file_name}" + ) + except FileNotFoundError: + log.warning("stored state missing on disk, %s", state.file_name) + continue + ok = await asyncio.to_thread( + _push_state_file, container, state.file_name, content + ) + if ok: + pushed += 1 + if pushed: + log.info("hydrated %d state file(s) to container, rom=%s", pushed, rom.name) + return pushed + + +# ── In-game save sync ───────────────────────────────────────────────────────── +# Parallel to the state sync above, but for the emulator's own in-game saves +# (memory cards / NAND / battery saves). The broker ships them as a single zip +# archive via GET/PUT /save-file; RomM stores each pulled archive as one Save +# asset with a .zip extension so the whole card set travels as a unit. + +# A pulled save archive can be large (PCSX2 ships whole 8 MB memory cards, a +# Wii NAND can hold many titles); 256 MB matches the state-file ceiling. +_SAVE_FILE_MAX_BYTES = 256 * 1024 * 1024 + + +def _fetch_save_archive(container: dict[str, Any]) -> bytes | None: + """GET /save-file from the broker. Returns the zip bytes or None. + + 404 means nothing changed since the game launched (the normal "no new + saves" case); any other failure is logged and treated the same way. + """ + url = _broker_url(container, "/save-file") + secret = _broker_secret(container) + req = urllib.request.Request( + url, + method="GET", + headers={**({"X-Broker-Secret": secret} if secret else {})}, + ) + try: + with urllib.request.urlopen( # nosec B310 + req, timeout=_BROKER_TRANSFER_TIMEOUT + ) as resp: + content = resp.read(_SAVE_FILE_MAX_BYTES + 1) + if not content: + return None + if len(content) > _SAVE_FILE_MAX_BYTES: + log.warning("broker save archive exceeds size limit") + return None + return content + except urllib.error.HTTPError as exc: + if exc.code != 404: + log.warning("broker save-file GET failed, HTTP %d", exc.code) + return None + except Exception as exc: + log.warning("broker save-file GET failed, %s", exc) + return None + + +def _push_save_archive(container: dict[str, Any], content: bytes) -> bool: + """PUT /save-file to the broker. Best-effort, logs but never raises.""" + url = _broker_url(container, "/save-file") + secret = _broker_secret(container) + req = urllib.request.Request( + url, + data=content, + method="PUT", + headers={ + "Content-Type": "application/zip", + "Content-Length": str(len(content)), + **({"X-Broker-Secret": secret} if secret else {}), + }, + ) + try: + with urllib.request.urlopen( # nosec B310 + req, timeout=_BROKER_TRANSFER_TIMEOUT + ) as resp: + body = json.loads(resp.read()) + return body.get("status") == "ok" + except Exception as exc: + log.warning("broker save-file PUT failed, %s", exc) + return False + + +async def _store_save_asset( + user: User, rom: Rom, emulator: str, content: bytes +) -> bool: + """Store a pulled save archive as a new Save asset. + + Each pull creates a fresh row (timestamped filename), so the user keeps a + history of save snapshots rather than overwriting. Identical content is + deduplicated by hash so idle exits do not pile up copies. + """ + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H-%M-%S") + filename = sanitize_filename(f"{rom.fs_name_no_ext} [{emulator} {ts}].saves.zip") + + saves_path = fs_asset_handler.build_saves_file_path( + user=user, + platform_fs_slug=rom.platform.fs_slug, + rom_id=rom.id, + emulator=emulator, + ) + await fs_asset_handler.write_file(file=content, path=saves_path, filename=filename) + + scanned_save = await scan_save( + file_name=filename, + user=user, + platform_fs_slug=rom.platform.fs_slug, + rom_id=rom.id, + emulator=emulator, + ) + + # Drop the write if an identical archive is already stored for this ROM. + if scanned_save.content_hash: + existing = db_save_handler.get_save_by_content_hash( + user_id=user.id, rom_id=rom.id, content_hash=scanned_save.content_hash + ) + if existing is not None: + try: + await fs_asset_handler.remove_file(f"{saves_path}/{filename}") + except FileNotFoundError: + pass + return False + + scanned_save.rom_id = rom.id + scanned_save.user_id = user.id + scanned_save.emulator = emulator + db_save_handler.add_save(save=scanned_save) + return True + + +async def _pull_saves_to_library( + user_id: int, rom_id: int, container: dict[str, Any] +) -> bool: + """Background task: pull in-game saves from the broker and store them. + + Best-effort by design, a sync failure must never surface to the player, + the save still exists inside the container. + """ + user = db_user_handler.get_user(user_id) + rom = db_rom_handler.get_rom(rom_id) + if user is None or rom is None: + return False + emulator = _emulator_for_container(container) + + for attempt in range(_STATE_PULL_ATTEMPTS): + if attempt > 0: + await asyncio.sleep(_STATE_PULL_RETRY_DELAY) + content = await asyncio.to_thread(_fetch_save_archive, container) + if content is None: + continue + try: + stored = await _store_save_asset(user, rom, emulator, content) + except Exception: + log.exception("failed to store pulled saves, rom=%s", rom.name) + return False + if stored: + log.info("saves synced to library, rom=%s", rom.name) + else: + log.info("pulled saves unchanged, rom=%s", rom.name) + return True + + log.info("no save changes to pull, rom_id=%d", rom_id) + return False + + +async def _hydrate_saves_to_broker( + user_id: int, rom_id: int, container: dict[str, Any] +) -> bool: + """Push the user's newest stored save archive down to the freshly claimed + container BEFORE the game launches. Games read saves at boot, so this must + happen synchronously ahead of the launch (unlike states, read lazily). + """ + user = db_user_handler.get_user(user_id) + rom = db_rom_handler.get_rom(rom_id) + if user is None or rom is None: + return False + emulator = _emulator_for_container(container) + + newest = None + for save in db_save_handler.get_saves( + user_id=user_id, rom_id=rom_id, order_by="created_at", order_dir="desc" + ): + if (save.emulator or "").lower() != emulator: + continue + if not save.file_name.endswith(".zip"): + continue + newest = save + break + if newest is None: + return False + + try: + content = await fs_asset_handler.read_file( + f"{newest.file_path}/{newest.file_name}" + ) + except FileNotFoundError: + log.warning("stored save missing on disk, %s", newest.file_name) + return False + + ok = await asyncio.to_thread(_push_save_archive, container, content) + if ok: + log.info( + "hydrated saves to container, rom=%s file=%s", + rom.name, + newest.file_name, + ) + return ok + + +# ── Whole memory-card sync (per-user card model) ────────────────────────────── +# Opt-in per container via `memory_card_sync: true`. When on, the container's +# entire Slot-1 folder card is one owned image: hydrated (or wiped to a fresh +# blank card) on claim, and evacuated to the library before the game is stopped. +# This REPLACES the /save-file in-game-save path above for that container; save- +# STATE sync is untouched. Only PCSX2 carries the flag today (#21 generalizes). + +_MEMORY_CARD_MAX_BYTES = 256 * 1024 * 1024 + + +def _empty_zip_bytes() -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w"): + pass + return buf.getvalue() + + +# PUT to a freshly claimed container to wipe slot 1 to a blank card, so the next +# player never inherits the previous owner's saves (the isolation guarantee for +# pooled hosts). The broker's wipe-then-replace lays down an empty card and +# PCSX2 formats it on first save. +_EMPTY_MEMORY_CARD = _empty_zip_bytes() + + +def _memory_card_sync_enabled(container: dict[str, Any]) -> bool: + return bool(container.get("memory_card_sync", False)) + + +class _MemoryCardUnavailable(Exception): + """The broker's Slot-1 card could not be read (endpoint missing, wrong card + type, oversize, or a transport error). Distinct from a broker-confirmed + EMPTY slot: unavailable means we must NOT wipe, since we never captured it.""" + + +def _fetch_memory_card( + container: dict[str, Any], timeout: float = _CARD_HYDRATE_TIMEOUT +) -> bytes | None: + """GET /memory-card from the broker. Tri-state: + + - bytes: the Slot-1 card was captured and can be stored. + - None: the broker CONFIRMS the slot is empty (404 tagged + `X-Memory-Card: absent`). Nothing to store, safe to wipe. + - raise `_MemoryCardUnavailable`: the card could not be read (endpoint + missing / unmarked 404, 409 File card, oversize, empty 200, or a transport + error). The caller must NOT wipe, since the card was never captured. + """ + url = _broker_url(container, "/memory-card") + secret = _broker_secret(container) + req = urllib.request.Request( + url, + method="GET", + headers={**({"X-Broker-Secret": secret} if secret else {})}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 + content = resp.read(_MEMORY_CARD_MAX_BYTES + 1) + if not content: + raise _MemoryCardUnavailable("broker returned an empty card body") + if len(content) > _MEMORY_CARD_MAX_BYTES: + raise _MemoryCardUnavailable("broker memory card exceeds size limit") + return content + except urllib.error.HTTPError as exc: + if exc.code == 404 and exc.headers.get("X-Memory-Card") == "absent": + # Broker confirms the slot is genuinely empty (first run, or already + # wiped). Safe to wipe; there is simply nothing to evacuate. + return None + if exc.code == 409: + raise _MemoryCardUnavailable( + "broker slot 1 is a File card, not a Folder card" + ) from exc + raise _MemoryCardUnavailable( + f"broker memory-card GET failed, HTTP {exc.code}" + ) from exc + except _MemoryCardUnavailable: + raise + except Exception as exc: + raise _MemoryCardUnavailable(f"broker memory-card GET failed, {exc}") from exc + + +def _push_memory_card( + container: dict[str, Any], content: bytes, timeout: float = _CARD_HYDRATE_TIMEOUT +) -> bool: + """PUT /memory-card to the broker (wipe-then-replace). Best-effort, logs + but never raises. The caller decides whether a failure aborts the claim.""" + url = _broker_url(container, "/memory-card") + secret = _broker_secret(container) + req = urllib.request.Request( + url, + data=content, + method="PUT", + headers={ + "Content-Type": "application/zip", + "Content-Length": str(len(content)), + **({"X-Broker-Secret": secret} if secret else {}), + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 + body = json.loads(resp.read()) + return body.get("status") == "ok" + except Exception as exc: + log.warning("broker memory-card PUT failed, %s", exc) + return False + + +def _resolve_memory_card( + user_id: int, emulator: str, memory_card_id: int | None +) -> MemoryCard | None: + """Pick the card to mount for a claim. + + An explicit id must be one the user owns for this emulator (shared/public + cards are view-only for now, resolved through Piece 5 UI, never live-mounted + onto another user's session). With no id, use the user's most-recently-used + card for the emulator, or None when the user has no card yet. Resolution + never creates rows; the claim path creates a blank card only after the + claim is won. + """ + if memory_card_id is not None: + card = db_memory_card_handler.get_card(user_id=user_id, id=memory_card_id) + if card is None or card.emulator != emulator: + raise HTTPException( + status_code=404, detail="Memory card not found for this emulator" + ) + return card + + cards = db_memory_card_handler.get_cards(user_id=user_id, emulator=emulator) + if cards: + return cards[0] # get_cards orders by updated_at desc + return None + + +def _create_blank_memory_card( + user_id: int, emulator: str, platform_id: int | None +) -> MemoryCard: + """Create a fresh blank card for a user's first play on an emulator. The + blank carries no version, so hydrate wipes the container to a clean card + that the emulator formats on first save. + """ + blank = MemoryCard( + user_id=user_id, + emulator=emulator, + platform_id=platform_id, + name=f"{emulator} memory card", + slot=1, + is_public=False, + ) + return db_memory_card_handler.add_card(blank) + + +async def _hydrate_memory_card_to_broker( + user_id: int, card: MemoryCard, container: dict[str, Any] +) -> bool: + """Push the card's newest version down to a freshly claimed container BEFORE + launch (games read the card at boot). A blank card, or one whose stored file + has gone missing, wipes the container to a fresh card so the player never + inherits a previous owner's saves. Returns False only when the broker push + itself fails, so the caller can abort a claim it could not isolate. + """ + latest = db_memory_card_handler.get_latest_version(card.id) + content = _EMPTY_MEMORY_CARD + if latest is not None: + try: + content = await fs_asset_handler.read_file( + f"{latest.file_path}/{latest.file_name}" + ) + except FileNotFoundError: + # The version row exists but the file is gone. Wiping to a blank + # card keeps isolation intact rather than leaking the last card. + log.warning( + "memory card file missing on disk, %s, wiping to blank", + latest.file_name, + ) + ok = await asyncio.to_thread(_push_memory_card, container, content) + if ok: + log.info( + "hydrated memory card to container, card=%d version=%s", + card.id, + latest.file_name if latest is not None else "(blank)", + ) + return ok + + +def _content_hash_of_bytes(content: bytes) -> str | None: + """Compute the dedup hash of a card without writing it to disk. Mirrors + fs_asset_handler.compute_content_hash exactly (zip-entry hash for zips, + plain md5 otherwise, None on failure) so it matches stored content_hash + values. Must stay in lockstep with that implementation. + """ + try: + buf = io.BytesIO(content) + if zipfile.is_zipfile(buf): + with zipfile.ZipFile(buf, "r") as zf: + file_hashes = [] + for name in sorted(zf.namelist()): + if not name.endswith("/"): + entry = zf.read(name) + entry_hash = hashlib.md5( + entry, usedforsecurity=False + ).hexdigest() + file_hashes.append(f"{name}:{entry_hash}") + combined = "\n".join(file_hashes) + return hashlib.md5(combined.encode(), usedforsecurity=False).hexdigest() + return hashlib.md5(content, usedforsecurity=False).hexdigest() + except Exception as exc: + log.debug("could not hash memory card in memory, %s", exc) + return None + + +async def _store_memory_card_version( + user: User, card: MemoryCard, emulator: str, content: bytes +) -> bool: + """Store an evacuated card as a new MemoryCardVersion. Identical content is + deduplicated by hash so repeated exits do not pile up copies. Either way the + card's updated_at is bumped so it floats to the top of the next pick list. + Returns True when a new version was actually stored. + """ + # Most exits leave the card unchanged, so check the hash in memory first + # and skip the disk round-trip for a card that already has this content. + content_hash = _content_hash_of_bytes(content) + if content_hash: + existing = db_memory_card_handler.get_version_by_content_hash( + card_id=card.id, content_hash=content_hash + ) + if existing is not None: + db_memory_card_handler.update_card( + card.id, {"updated_at": datetime.now(timezone.utc)} + ) + return False + + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H-%M-%S") + filename = sanitize_filename(f"{card.name} [{ts}].card.zip") + cards_path = fs_asset_handler.build_memory_cards_file_path( + user=user, emulator=emulator, card_id=card.id + ) + await fs_asset_handler.write_file(file=content, path=cards_path, filename=filename) + + version = await scan_memory_card_version( + file_name=filename, user=user, emulator=emulator, card_id=card.id + ) + + # Fallback dedup on the scanned hash, for when the in-memory hash could + # not be computed. Keeps duplicates out even when the precheck misses. + stored = True + if version.content_hash: + existing = db_memory_card_handler.get_version_by_content_hash( + card_id=card.id, content_hash=version.content_hash + ) + if existing is not None: + try: + await fs_asset_handler.remove_file(f"{cards_path}/{filename}") + except FileNotFoundError: + pass + stored = False + + if stored: + db_memory_card_handler.add_version(version) + + # Touch the card so "most recent" ordering reflects this session even when + # the content was unchanged (updated_at has no onupdate on add_version). + db_memory_card_handler.update_card( + card.id, {"updated_at": datetime.now(timezone.utc)} + ) + return stored + + +async def _evacuate_memory_card( + user_id: int, card_id: int, emulator: str, container: dict[str, Any] +) -> bool: + """Pull the whole Slot-1 card off the broker and store it as a new version. + + Called before the emulator is stopped so a pooled container is captured + before it can be reclaimed. Returns `safe_to_wipe`: True only when the card + was captured (or the broker confirmed the slot is empty), False when it + could not be read. The caller wipes the slot only when this is True, so a + card that failed to evacuate is never destroyed. + """ + user = db_user_handler.get_user(user_id) + card = db_memory_card_handler.get_card_by_id(card_id) + if user is None or card is None: + return False + try: + # Teardown must not hang a release for the full transfer window, so the + # fetch gets a tighter bound than hydrate-on-claim. + content = await asyncio.to_thread( + _fetch_memory_card, container, timeout=_CARD_TEARDOWN_TIMEOUT + ) + except _MemoryCardUnavailable as exc: + log.warning( + "could not evacuate memory card %d, not safe to wipe, %s", + card_id, + exc, + ) + return False + if content is None: + log.info("broker slot empty, nothing to evacuate, card=%d", card_id) + return True + try: + stored = await _store_memory_card_version(user, card, emulator, content) + except Exception: + log.exception("failed to store evacuated memory card %d", card_id) + return False + if stored: + log.info("memory card evacuated to library, card=%d", card_id) + else: + log.info("evacuated memory card unchanged, card=%d", card_id) + return True + + +async def _evacuate_session_card( + session: dict[str, Any], container: dict[str, Any] +) -> bool: + """Evacuate a whole-card-sync session's card before its container is freed. + + MUST be awaited while the Redis claim is still held: releasing the claim + first would let another user claim the container and wipe the card (claim + hydrates wipe-then-replace) before we capture it. Returns `safe_to_wipe`: + True only when the card was captured (or confirmed empty). No-op returning + False for containers without memory_card_sync or sessions that never + resolved a card, so those are never wiped. + """ + if not _memory_card_sync_enabled(container): + return False + card_id = session.get("memory_card_id") + user_id = session.get("user_id") + if not isinstance(card_id, int) or not isinstance(user_id, int): + return False + emulator = _emulator_for_container(container) + try: + return await _evacuate_memory_card(user_id, card_id, emulator, container) + except Exception: + log.exception("memory card evacuation failed, card=%d", card_id) + return False + + +async def _wipe_session_card(container: dict[str, Any]) -> None: + """Blank the broker's Slot-1 card after a confirmed evacuation. + + Defense in depth for pooled hosts: hydrate already wipes-then-replaces on + the next claim, but wiping now guarantees no card is left behind between + sessions for a bad or crashing hydrate to inherit. MUST run only when + evacuation reported safe_to_wipe, after the game is stopped (so the + emulator's exit flush cannot re-lay the card) and BEFORE the Redis claim is + released (so a concurrent claimant's fresh card is never clobbered). + Best-effort: a failed wipe is logged, not fatal, since the next claim wipes. + """ + if not _memory_card_sync_enabled(container): + return + # Teardown path, so a tighter bound than hydrate-on-claim. + ok = await asyncio.to_thread( + _push_memory_card, container, _EMPTY_MEMORY_CARD, timeout=_CARD_TEARDOWN_TIMEOUT + ) + if ok: + log.info("wiped broker memory-card slot after evacuation") + else: + log.warning("memory-card slot wipe failed, relying on next-claim wipe") + + +# Streaming sessions shorter than this are treated as accidental (a claim that +# was released almost immediately) and not recorded as playtime. +_MIN_PLAY_SESSION_MS = 5_000 + + +async def _record_play_session(session: dict[str, Any]) -> None: + """Record a finished streaming session as RomM playtime. + + Reuses the same ingest path as device sync (dedup on user+rom+start_time, + updates the ROM's last_played). The session's stored claimed_at is the + start and now is the end. Best-effort: any failure is logged, never fatal, + so playtime accounting cannot block or fail a teardown. + """ + user_id = session.get("user_id") + rom_id = session.get("rom_id") + claimed_at = session.get("claimed_at") + if ( + not isinstance(user_id, int) + or not isinstance(rom_id, int) + or not isinstance(claimed_at, str) + ): + return + + try: + start = datetime.fromisoformat(claimed_at) + except ValueError: + return + if start.tzinfo is None: + start = start.replace(tzinfo=timezone.utc) + + end = datetime.now(timezone.utc) + duration_ms = int((end - start).total_seconds() * 1000) + if duration_ms < _MIN_PLAY_SESSION_MS: + return + + try: + user = db_user_handler.get_user(user_id) + ingest_play_sessions( + user_id=user_id, + username=user.username if user else str(user_id), + entries=[ + { + "rom_id": rom_id, + "save_slot": None, + "start_time": start, + "end_time": end, + "duration_ms": duration_ms, + } + ], + ) + except Exception: + log.exception("failed to record play session") + + +async def _teardown_abandoned_session( + container: dict[str, Any], session_key: str, session: dict[str, Any] +) -> None: + """Free a container whose owner vanished without releasing (heartbeat went + stale). Same order as an owner release: stop the emulator so the card is + quiescent, evacuate and wipe it, credit the owner's playtime, then drop + the claim. + """ + await asyncio.to_thread(_stop_broker, container) + safe_to_wipe = await _evacuate_session_card(session, container) + if safe_to_wipe: + await _wipe_session_card(container) + await _record_play_session(session) + await async_cache.delete(_session_redis_key(session_key)) + + +# Slot number encoded in each emulator's state filename, e.g. PCSX2 writes +# "SERIAL (CRC).03.p2s" for slot 3 and Dolphin writes "GAMEID.s03". Resuming +# from a picked state needs the slot to tell the broker what to load. +_STATE_SLOT_PATTERNS = { + "pcsx2": re.compile(r"\.(\d{1,2})\.p2s$"), + "dolphin": re.compile(r"\.s(\d{2})$"), +} + + +def _slot_from_state_filename(emulator: str, filename: str) -> int | None: + pattern = _STATE_SLOT_PATTERNS.get(emulator) + if pattern is None: + return None + match = pattern.search(filename) + if match is None: + return None + slot = int(match.group(1)) + return slot if slot >= 1 else None + + +def _resolve_resume_state( + user_id: int, rom: Rom, container: dict[str, Any], state_id: int +) -> tuple[Any, int]: + """Validate a resume-from-state pick and return (state, slot). + + Visibility follows the same rule as the state list the picker was built + from: the claiming user's own states plus other users' public ones. + Raises 404 for anything invisible, 400 when the state cannot drive a + resume on this container. + """ + state = next( + ( + s + for s in db_state_handler.get_rom_shared_states( + rom_id=rom.id, user_id=user_id + ) + if s.id == state_id + ), + None, + ) + if state is None: + raise HTTPException(status_code=404, detail="State not found") + + emulator = _emulator_for_container(container) + if (state.emulator or "").lower() != emulator: + raise HTTPException( + status_code=400, + detail="State was made by a different emulator", + ) + + slot = _slot_from_state_filename(emulator, state.file_name) + if slot is None: + raise HTTPException( + status_code=400, + detail="State filename carries no recognizable slot number", + ) + return state, slot # ── Routes ──────────────────────────────────────────────────────────────────── @@ -480,6 +1561,12 @@ async def get_config(request: Request) -> JSONResponse: # Ship slot capabilities so the frontend selector reads them # instead of keeping its own hardcoded per-platform copy. "capabilities": platform_capabilities(platform), + # State namespace for this container, so the frontend can + # filter the resume picker the same way hydration filters. + "emulator": _emulator_for_container(c), + # Whether this container syncs whole memory cards, so the + # frontend only offers the card picker where it applies. + "supports_memory_cards": _memory_card_sync_enabled(c), } ) @@ -513,17 +1600,42 @@ async def claim_session( # launch. Raises a 404 that masks the hidden ROM's existence. assert_rom_visible(request, rom, not_found_detail="ROM not found") - container = _container_for_platform(rom.platform_slug) + platform = rom.platform_slug + container = _container_for_platform(platform) if container is None: raise HTTPException( status_code=404, - detail=f"No streaming container configured for platform '{rom.platform_slug}'", + detail=f"No streaming container configured for platform '{platform}'", + ) + + # Validate the resume pick before claiming so a bad state_id cannot + # leave the container wedged behind a failed launch. + resume_state = None + resume_slot: int | None = None + if req.state_id is not None: + resume_state, resume_slot = _resolve_resume_state( + request.user.id, rom, container, req.state_id + ) + + # Resolve the memory card to mount before claiming too, so a bad card id + # fails cleanly (whole-card-sync containers only). May be None on first + # play; the blank card is created only after the claim is won. + memory_card = None + if _memory_card_sync_enabled(container): + memory_card = _resolve_memory_card( + request.user.id, + _emulator_for_container(container), + req.memory_card_id, ) # The emulator containers mount the RomM library at the same path the # backend uses (LIBRARY_BASE_PATH, /romm/library by default), so the - # backend-side path is valid inside the broker container too. - rom_path = f"{LIBRARY_BASE_PATH}/{rom.full_path}" + # backend-side path is valid inside the broker container too. If a + # container mounts the library at a different path, `library_path` on + # its config entry overrides the prefix so the broker receives a path + # that is valid inside that container. + library_base = (container.get("library_path") or LIBRARY_BASE_PATH).rstrip("/") + rom_path = f"{library_base}/{rom.full_path}" rom_name = rom.name or rom.fs_name_no_ext session_key = _container_key(container) @@ -531,19 +1643,54 @@ async def claim_session( session = { "rom_id": rom.id, "rom_name": rom_name, + # Stored so admin views can release through the platform-keyed + # DELETE route without reverse-mapping the container key. + "platform": platform, "claimed_at": now, + # Liveness stamp, refreshed by the heartbeat endpoint. A session that + # stops refreshing counts as abandoned and can be taken over. + "last_seen": now, "user_id": request.user.id, + # Carried so every teardown path (owner release, save-and-exit, admin + # force-release) can evacuate the right card before stopping the game. + "memory_card_id": memory_card.id if memory_card is not None else None, } # SET NX is atomic: exactly one concurrent claim wins the key. The TTL # bounds how long an abandoned session (broker dead / backend crashed) - # can hold the container; control calls refresh it while in active use. + # can hold the container; control calls and heartbeats refresh it. claimed = await async_cache.set( _session_redis_key(session_key), json.dumps(session), nx=True, ex=SESSION_TTL_SECONDS, ) + if not claimed: + # The key exists, but its owner may be long gone: a closed tab or a + # crashed browser never sends a release, and the TTL alone would hold + # the container for hours. A stale heartbeat means abandoned, so tear + # the old session down (evacuating its card and crediting its + # playtime) and retry once. A drain marker is never taken over: the + # broker is still killing the previous emulator, and the marker + # expires on its own within seconds. + existing = await _get_session(session_key) + if ( + existing is not None + and not existing.get("draining") + and _session_is_stale(existing) + ): + log.warning( + "taking over stale session, platform=%s user_id=%s", + platform, + existing.get("user_id"), + ) + await _teardown_abandoned_session(container, session_key, existing) + claimed = await async_cache.set( + _session_redis_key(session_key), + json.dumps(session), + nx=True, + ex=SESSION_TTL_SECONDS, + ) if not claimed: existing = await _get_session(session_key) or {} raise HTTPException( @@ -555,24 +1702,111 @@ async def claim_session( }, ) + # Auto-create the blank card only after the claim is won, so a lost race + # (409) never leaves an orphan card behind. If a later step fails and aborts + # the claim, delete the blank we just made so an aborted claim leaks nothing. + created_blank_card_id: int | None = None + if _memory_card_sync_enabled(container) and memory_card is None: + memory_card = _create_blank_memory_card( + request.user.id, _emulator_for_container(container), rom.platform_id + ) + created_blank_card_id = memory_card.id + session["memory_card_id"] = memory_card.id + await async_cache.set( + _session_redis_key(session_key), + json.dumps(session), + ex=SESSION_TTL_SECONDS, + ) + + # Push the resume state before launch so its file is in place when the + # broker's deferred slot load fires. Best-effort: a failed push falls + # back to a fresh launch, reported through `resume` in the response. + resume_pushed = False + if resume_state is not None: + try: + content = await fs_asset_handler.read_file( + f"{resume_state.file_path}/{resume_state.file_name}" + ) + resume_pushed = await asyncio.to_thread( + _push_state_file, container, resume_state.file_name, content + ) + except Exception: + log.exception("could not read resume state %s", resume_state.file_name) + if not resume_pushed: + log.warning("resume state not pushed, launching fresh") + + # Prepare in-game saves before launch - games read them at boot, so unlike + # states this cannot be deferred to a background task. + if memory_card is not None: + # Whole-card sync: hydrate (or wipe to blank) is REQUIRED. If it fails + # we cannot guarantee the container is isolated from the previous + # player's card, so abort the claim rather than launch a leaky session. + try: + hydrated = await _hydrate_memory_card_to_broker( + request.user.id, memory_card, container + ) + except Exception: + log.exception("memory card hydration failed") + hydrated = False + if not hydrated: + await async_cache.delete(_session_redis_key(session_key)) + if created_blank_card_id is not None: + db_memory_card_handler.delete_card(created_blank_card_id) + raise HTTPException( + status_code=502, detail="Could not prepare the memory card" + ) + else: + # Legacy per-file save sync (containers without memory_card_sync). + # Best-effort: a failed hydration just means the container keeps its own. + try: + await _hydrate_saves_to_broker(request.user.id, rom.id, container) + except Exception: + log.exception("save hydration failed, continuing launch") + try: # Tell the broker to load the ROM, raises HTTPException on failure. # Wrapped in asyncio.to_thread because urllib is synchronous. - await asyncio.to_thread(_call_broker, container, rom_path, rom_name) + await asyncio.to_thread( + _call_broker, + container, + rom_path, + rom_name, + resume_slot if resume_pushed else None, + ) except Exception: # Launch failed, free the claim so the container isn't wedged. await async_cache.delete(_session_redis_key(session_key)) + if created_blank_card_id is not None: + db_memory_card_handler.delete_card(created_blank_card_id) raise - log.info("session claimed, platform=%s rom=%s", rom.platform_slug, rom_name) + log.info("session claimed, platform=%s rom=%s", platform, rom_name) + + # Hydrate the container's save-state slots from the user's stored states + # in the background - the stream should not wait on file transfers. + _spawn_sync_task( + _hydrate_states_to_broker( + request.user.id, + rom.id, + container, + skip_filename=( + resume_state.file_name + if resume_state is not None and resume_pushed + else None + ), + ) + ) return JSONResponse( { - "platform": rom.platform_slug, + "platform": platform, "host": container.get("host", ""), - "label": container.get("label", rom.platform_slug.upper()), + "label": container.get("label", platform.upper()), "rom_name": rom_name, "claimed_at": now, + # None when no resume was requested; False signals the frontend + # to tell the player the session started fresh. + "resume": resume_pushed if req.state_id is not None else None, } ) @@ -586,13 +1820,27 @@ async def save_and_exit_session( wait=true (default): blocks until broker confirms save+kill complete. wait=false: broker fires save+kill in background, returns immediately. """ - container, session_key, _ = await _resolve_owned_session(platform, request) - - saved = await asyncio.to_thread( - _save_and_exit_broker, container, slot=req.slot, wait=req.wait + container, session_key, session = await _resolve_owned_session(platform, request) + + # Whole-card sync must evacuate a quiescent card, so force a blocking + # save+kill for these containers even on the navigate-away (wait=false) + # path. Otherwise the evacuate below can read a card the emulator is + # still writing, and the wipe can race its exit flush. + card_sync = _memory_card_sync_enabled(container) + effective_wait = True if card_sync else req.wait + saved, effective_slot = await asyncio.to_thread( + _save_and_exit_broker, container, slot=req.slot, wait=effective_wait ) - if req.wait: + # Evacuate the whole card while the claim still guards the container, so a + # concurrent claim cannot wipe it first. The save+kill above was blocking + # on the card-sync path, so the game is stopped and the card is quiescent. + # Awaited before the key is released. + safe_to_wipe = await _evacuate_session_card(session, container) + if safe_to_wipe: + await _wipe_session_card(container) + + if effective_wait: # Broker confirmed the save+kill is done, the key can go now. await async_cache.delete(_session_redis_key(session_key)) else: @@ -608,10 +1856,48 @@ async def save_and_exit_session( json.dumps({"draining": True}), ex=SESSION_DRAIN_SECONDS, ) + + await _record_play_session(session) + + # Sync the exit save to the library. With wait=false the broker save may + # still be running; the pull blocks on the broker until it finishes. + rom_id = session.get("rom_id") + if isinstance(rom_id, int) and (saved or not effective_wait): + _spawn_sync_task( + _pull_state_to_library(request.user.id, rom_id, container, effective_slot) + ) + + # Legacy per-file in-game save pull, only for containers not on whole-card + # sync (those were evacuated above, independent of any savestate). + if isinstance(rom_id, int) and not card_sync: + _spawn_sync_task(_pull_saves_to_library(request.user.id, rom_id, container)) + log.info("save-and-exit, platform=%s saved=%s", platform, saved) return JSONResponse({"status": "ok", "saved": saved, "platform": platform}) +@protected_route(router.post, "/sessions/{platform}/heartbeat", [Scope.ROMS_READ]) +async def heartbeat_session(request: Request, platform: str) -> JSONResponse: + """Refresh the session's liveness stamp. + + The frontend calls this every ~30s while a session is active. A session + that stops refreshing counts as abandoned after _SESSION_STALE_SECONDS + and the next claim may take the container over. + """ + _, session_key, session = await _resolve_owned_session(platform, request) + session["last_seen"] = datetime.now(timezone.utc).isoformat() + # XX: only rewrite a key that still exists, so a heartbeat racing a + # teardown cannot resurrect a released session as a ghost claim. The + # rewrite also resets the TTL back to the full window. + await async_cache.set( + _session_redis_key(session_key), + json.dumps(session), + xx=True, + ex=SESSION_TTL_SECONDS, + ) + return JSONResponse({"status": "ok", "platform": platform}) + + @protected_route(router.post, "/sessions/{platform}/volume", [Scope.ROMS_READ]) async def set_volume( request: Request, platform: str, req: Annotated[VolumeRequest, Body()] @@ -647,7 +1933,7 @@ async def save_state( request: Request, platform: str, req: Annotated[SaveStateRequest, Body()] ) -> JSONResponse: """Save game state to a manual slot without stopping the emulator.""" - container, session_key, _ = await _resolve_owned_session(platform, request) + container, session_key, session = await _resolve_owned_session(platform, request) _assert_valid_slot(platform, req.slot, allow_autosave=False) ok = await asyncio.to_thread(_save_state_broker, container, req.slot) @@ -655,6 +1941,15 @@ async def save_state( raise HTTPException(status_code=502, detail="Broker failed to save state") await _refresh_session(session_key) + + # Every save syncs to the library in the background. The broker holds the + # /state-file response until the emulator finishes writing the slot. + rom_id = session.get("rom_id") + if isinstance(rom_id, int): + _spawn_sync_task( + _pull_state_to_library(request.user.id, rom_id, container, req.slot) + ) + return JSONResponse({"status": "saving", "slot": req.slot, "platform": platform}) @@ -690,22 +1985,51 @@ async def release_session(request: Request, platform: str) -> JSONResponse: return JSONResponse({"status": "not_found", "platform": platform}) _assert_session_owner(session, request) - await async_cache.delete(_session_redis_key(session_key)) - # Best-effort stop, don't block the user on broker errors. + # Stop the emulator first so the card is quiescent before evacuation: a + # running game's exit flush could otherwise re-lay a card over the wipe, and + # reading a live card risks a torn snapshot. The claim still guards the + # container throughout, so no concurrent claim can interleave. await asyncio.to_thread(_stop_broker, container) + # Evacuate the whole card, then wipe the slot, both before releasing the + # claim so a concurrent claim cannot clobber the fresh card or inherit the + # old one. Wipe runs only when evacuation actually captured the card. + safe_to_wipe = await _evacuate_session_card(session, container) + if safe_to_wipe: + await _wipe_session_card(container) + + await async_cache.delete(_session_redis_key(session_key)) + await _record_play_session(session) + + # Legacy per-file save pull, only for containers not on whole-card sync. The + # broker keeps the files after the emulator dies, so a fire-and-forget pull + # still succeeds. + rom_id = session.get("rom_id") + if isinstance(rom_id, int) and not _memory_card_sync_enabled(container): + _spawn_sync_task(_pull_saves_to_library(session["user_id"], rom_id, container)) + log.info("session released, platform=%s", platform) return JSONResponse({"status": "released", "platform": platform}) @protected_route(router.get, "/sessions", [Scope.ROMS_READ]) async def list_sessions(request: Request) -> JSONResponse: - """Admin debug view, active sessions keyed by broker URL.""" + """Admin view, active sessions across all configured containers. + + Entries carry the platform the session was claimed under so an admin + client can release one through `DELETE /sessions/{platform}`. + """ if request.user.role != Role.ADMIN: raise HTTPException(status_code=403, detail="Forbidden") - sessions: dict[str, Any] = {} + containers_by_key = { + _container_key(c): c + for c in _get_streaming_config().get("containers", []) + if isinstance(c, dict) + } + + sessions: list[dict[str, Any]] = [] async for key in async_cache.scan_iter(match=f"{_SESSION_KEY_PREFIX}*"): raw = await async_cache.get(key) if raw is None: @@ -716,17 +2040,28 @@ async def list_sessions(request: Request) -> JSONResponse: continue # scan_iter yields bytes unless the client decodes responses. key_str = key.decode() if isinstance(key, bytes) else key - sessions[key_str.removeprefix(_SESSION_KEY_PREFIX)] = { - "rom_name": s.get("rom_name"), - "claimed_at": s.get("claimed_at"), - "user_id": s.get("user_id"), - } - return JSONResponse(sessions) + container_key = key_str.removeprefix(_SESSION_KEY_PREFIX) + container = containers_by_key.get(container_key, {}) + user_id = s.get("user_id") + user = db_user_handler.get_user(user_id) if user_id is not None else None + sessions.append( + { + "container": container_key, + "label": container.get("label"), + "platform": s.get("platform"), + "rom_id": s.get("rom_id"), + "rom_name": s.get("rom_name"), + "claimed_at": s.get("claimed_at"), + "user_id": user_id, + "username": user.username if user else None, + } + ) + return JSONResponse({"sessions": sessions}) @protected_route(router.delete, "/sessions", [Scope.ROMS_READ]) async def force_release_all(request: Request) -> JSONResponse: - """Force-release all active sessions.""" + """Admin, force-release all active sessions.""" if request.user.role != Role.ADMIN: raise HTTPException(status_code=403, detail="Forbidden") @@ -738,16 +2073,40 @@ async def force_release_all(request: Request) -> JSONResponse: if isinstance(c, dict) } + async def _teardown(key: str | bytes, container_key: str) -> None: + container = containers_by_key.get(container_key) + + # Stop the emulator, then evacuate and wipe the card, all while the claim + # still guards the container and before deleting the key. Stopping first + # quiesces the card so an exit flush cannot undo the wipe. + if container is not None: + # Best-effort stop; a broker error must not abort the sweep. + await asyncio.to_thread(_stop_broker, container) + session = await _get_session(container_key) + if session is not None: + safe_to_wipe = await _evacuate_session_card(session, container) + if safe_to_wipe: + await _wipe_session_card(container) + # Credit playtime to the session's owner, not the admin. + await _record_play_session(session) + + await async_cache.delete(key) + released = [] + teardowns = [] async for key in async_cache.scan_iter(match=f"{_SESSION_KEY_PREFIX}*"): - await async_cache.delete(key) # scan_iter yields bytes unless the client decodes responses. key_str = key.decode() if isinstance(key, bytes) else key container_key = key_str.removeprefix(_SESSION_KEY_PREFIX) - container = containers_by_key.get(container_key) - if container is not None: - # Best-effort stop; a broker error must not abort the sweep. - await asyncio.to_thread(_stop_broker, container) released.append(container_key) + teardowns.append(_teardown(key, container_key)) + + # Tear down concurrently so one slow or stuck broker cannot serialize the + # whole sweep behind its timeout. + results = await asyncio.gather(*teardowns, return_exceptions=True) + for container_key, result in zip(released, results, strict=False): + if isinstance(result, BaseException): + log.warning("force-release failed for %s, %s", container_key, result) + log.info("all sessions force-released by admin, %s", released) return JSONResponse({"status": "released", "platforms": released}) diff --git a/backend/handler/database/__init__.py b/backend/handler/database/__init__.py index 6feaed5612..f3406fcd83 100644 --- a/backend/handler/database/__init__.py +++ b/backend/handler/database/__init__.py @@ -3,6 +3,7 @@ from .device_save_sync_handler import DBDeviceSaveSyncHandler from .devices_handler import DBDevicesHandler from .firmware_handler import DBFirmwareHandler +from .memory_cards_handler import DBMemoryCardsHandler from .permissions_handler import DBPermissionsHandler from .platforms_handler import DBPlatformsHandler from .play_sessions_handler import DBPlaySessionsHandler @@ -19,6 +20,7 @@ db_device_handler = DBDevicesHandler() db_device_save_sync_handler = DBDeviceSaveSyncHandler() db_firmware_handler = DBFirmwareHandler() +db_memory_card_handler = DBMemoryCardsHandler() db_permission_handler = DBPermissionsHandler() db_platform_handler = DBPlatformsHandler() db_play_session_handler = DBPlaySessionsHandler() diff --git a/backend/handler/database/memory_cards_handler.py b/backend/handler/database/memory_cards_handler.py new file mode 100644 index 0000000000..3141fa6ed2 --- /dev/null +++ b/backend/handler/database/memory_cards_handler.py @@ -0,0 +1,177 @@ +from collections.abc import Sequence + +from sqlalchemy import delete, desc, or_, select, update +from sqlalchemy.orm import Session + +from decorators.database import begin_session +from models.assets import MemoryCard, MemoryCardVersion + +from .base_handler import DBBaseHandler + + +class DBMemoryCardsHandler(DBBaseHandler): + # --- Card identity --- + + @begin_session + def add_card( + self, + card: MemoryCard, + session: Session = None, # type: ignore + ) -> MemoryCard: + return session.merge(card) + + @begin_session + def get_card( + self, + user_id: int, + id: int, + session: Session = None, # type: ignore + ) -> MemoryCard | None: + """Owner-scoped fetch, for mutations the caller must own (rename, + share, delete).""" + return session.scalar( + select(MemoryCard).filter_by(user_id=user_id, id=id).limit(1) + ) + + @begin_session + def get_card_by_id( + self, + id: int, + session: Session = None, # type: ignore + ) -> MemoryCard | None: + """Unscoped fetch. Used when hydrating a card shared by another user, + where the caller does not own it (visibility is enforced separately).""" + return session.get(MemoryCard, id) + + @begin_session + def get_cards( + self, + user_id: int, + emulator: str | None = None, + session: Session = None, # type: ignore + ) -> Sequence[MemoryCard]: + """A user's own cards, optionally filtered to one emulator (the pick + list shown at session claim).""" + query = select(MemoryCard).filter_by(user_id=user_id) + if emulator is not None: + query = query.filter(MemoryCard.emulator == emulator) + return session.scalars(query.order_by(desc(MemoryCard.updated_at))).all() + + @begin_session + def get_shared_cards( + self, + emulator: str, + user_id: int, + session: Session = None, # type: ignore + ) -> Sequence[MemoryCard]: + """Cards for an emulator visible to the requesting user: their own plus + other users' public ones. Mirrors db_state_handler.get_rom_shared_states + but keyed by emulator rather than rom.""" + query = ( + select(MemoryCard) + .filter(MemoryCard.emulator == emulator) + .filter(or_(MemoryCard.user_id == user_id, MemoryCard.is_public)) + .order_by(desc(MemoryCard.updated_at)) + ) + return session.scalars(query).all() + + @begin_session + def update_card( + self, + id: int, + data: dict, + session: Session = None, # type: ignore + ) -> MemoryCard: + session.execute( + update(MemoryCard) + .where(MemoryCard.id == id) + .values(**data) + .execution_options(synchronize_session="evaluate") + ) + return session.query(MemoryCard).filter_by(id=id).one() + + @begin_session + def delete_card( + self, + id: int, + session: Session = None, # type: ignore + ) -> None: + # Versions cascade via the FK / relationship. + session.execute( + delete(MemoryCard) + .where(MemoryCard.id == id) + .execution_options(synchronize_session="evaluate") + ) + + # --- Card versions (snapshots) --- + + @begin_session + def add_version( + self, + version: MemoryCardVersion, + session: Session = None, # type: ignore + ) -> MemoryCardVersion: + return session.merge(version) + + @begin_session + def get_latest_version( + self, + card_id: int, + session: Session = None, # type: ignore + ) -> MemoryCardVersion | None: + """Newest snapshot of a card, used to hydrate a container at claim.""" + return session.scalar( + select(MemoryCardVersion) + .filter_by(memory_card_id=card_id) + .order_by(desc(MemoryCardVersion.created_at)) + .limit(1) + ) + + @begin_session + def get_version_by_content_hash( + self, + card_id: int, + content_hash: str, + session: Session = None, # type: ignore + ) -> MemoryCardVersion | None: + """Dedup lookup on evacuate: skip storing a snapshot identical to one + already held for this card.""" + return session.scalar( + select(MemoryCardVersion) + .filter_by(memory_card_id=card_id, content_hash=content_hash) + .limit(1) + ) + + @begin_session + def get_version_by_id( + self, + id: int, + session: Session = None, # type: ignore + ) -> MemoryCardVersion | None: + """Unscoped fetch, for the content-download route.""" + return session.get(MemoryCardVersion, id) + + @begin_session + def get_versions( + self, + card_id: int, + session: Session = None, # type: ignore + ) -> Sequence[MemoryCardVersion]: + """A card's snapshot history, newest first.""" + return session.scalars( + select(MemoryCardVersion) + .filter_by(memory_card_id=card_id) + .order_by(desc(MemoryCardVersion.created_at)) + ).all() + + @begin_session + def delete_version( + self, + id: int, + session: Session = None, # type: ignore + ) -> None: + session.execute( + delete(MemoryCardVersion) + .where(MemoryCardVersion.id == id) + .execution_options(synchronize_session="evaluate") + ) diff --git a/backend/handler/filesystem/assets_handler.py b/backend/handler/filesystem/assets_handler.py index 5062159243..9144c4f2e8 100644 --- a/backend/handler/filesystem/assets_handler.py +++ b/backend/handler/filesystem/assets_handler.py @@ -142,6 +142,15 @@ def build_screenshots_file_path( user, "screenshots", platform_fs_slug, rom_id ) + # /users/557365723a31/memory_cards/pcsx2/{card_id} + def build_memory_cards_file_path(self, user: User, emulator: str, card_id: int): + # Not scoped by rom/platform: a memory card is per (user, emulator) and + # holds every game's saves. Versions share the folder, distinguished by + # their timestamped file names. + return os.path.join( + self.user_folder_path(user), "memory_cards", emulator, str(card_id) + ) + async def _compute_file_hash(self, file_path: str) -> str: hash_obj = hashlib.md5(usedforsecurity=False) async with await self.stream_file(file_path=file_path) as f: diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index cc7c41cd12..70338da284 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -45,7 +45,7 @@ from logger.formatter import BLUE, LIGHTYELLOW from logger.formatter import highlight as hl from logger.logger import log -from models.assets import Save, Screenshot, State +from models.assets import MemoryCardVersion, Save, Screenshot, State from models.firmware import Firmware from models.platform import Platform from models.rom import Rom, RomFile, RomFileCategory @@ -1179,6 +1179,19 @@ async def scan_state( return State(**scanned_asset) +async def scan_memory_card_version( + file_name: str, + user: User, + emulator: str, + card_id: int, +) -> MemoryCardVersion: + cards_path = fs_asset_handler.build_memory_cards_file_path( + user=user, emulator=emulator, card_id=card_id + ) + scanned_asset = await _scan_asset(file_name, cards_path, should_hash=True) + return MemoryCardVersion(**scanned_asset, memory_card_id=card_id) + + async def scan_screenshot( file_name: str, user: User, diff --git a/backend/main.py b/backend/main.py index e9f12072cd..d7c6bb2de4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -39,6 +39,7 @@ from endpoints.firmware import router as firmware_router from endpoints.heartbeat import router as heartbeat_router from endpoints.logs import router as logs_router +from endpoints.memory_cards import router as memory_cards_router from endpoints.music import router as music_router from endpoints.netplay import router as netplay_router from endpoints.permissions import router as permissions_router @@ -176,6 +177,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(export_router, prefix="/api") app.include_router(netplay_router, prefix="/api") app.include_router(permissions_router, prefix="/api") +app.include_router(memory_cards_router, prefix="/api") app.include_router(streaming_router, prefix="/api") app.mount("/ws", socket_handler.socket_app) diff --git a/backend/models/assets.py b/backend/models/assets.py index b5fbc5141f..4e41627c1f 100644 --- a/backend/models/assets.py +++ b/backend/models/assets.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from models.device_save_sync import DeviceSaveSync + from models.platform import Platform from models.rom import Rom from models.user import User @@ -141,3 +142,72 @@ def screenshot(self) -> Screenshot | None: file_name=self.file_name, # Match state filename against screenshot filename stem file_name_no_ext=self.file_name_no_ext, ) + + +class MemoryCard(BaseModel): + """A per-user, per-emulator memory card that follows the user across + streaming sessions. Unlike Save/State it is not tied to a single ROM: for + formats like the PCSX2 folder card one card holds every game's saves. The + card is an identity (name, owner); its actual data lives in `versions`, + a snapshot history mirroring how EmulatorJS keeps multiple Save rows. + """ + + __tablename__ = "memory_cards" + __table_args__ = {"extend_existing": True} + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + # `emulator` is the hard scoping key: a card is looked up by (user, emulator) + # at session claim, so one Dolphin card serves both GameCube and Wii roms. + emulator: Mapped[str] = mapped_column(String(length=50)) + # `platform_id` is a loose, nullable hint (which platform the card was + # created under) for display/filtering only. It never scopes the lookup, so + # a card stays visible across every platform its emulator drives. + platform_id: Mapped[int | None] = mapped_column( + ForeignKey("platforms.id", ondelete="SET NULL"), + default=None, + ) + name: Mapped[str] = mapped_column(String(length=255)) + # Only slot 1 is used today; kept so a future multi-slot layout needs no + # schema change. + slot: Mapped[int] = mapped_column(default=1) + # `is_public` mirrors Save/State, letting another user browse this card and + # hydrate a snapshot of it. Sharing is one-way: the recipient's writes go + # to their own new card, never back to this one. + is_public: Mapped[bool] = mapped_column(default=False) + + user: Mapped[User] = relationship(lazy="joined", back_populates="memory_cards") + # One-directional: the loose platform hint, for display only. + platform: Mapped[Platform | None] = relationship(lazy="joined") + versions: Mapped[list[MemoryCardVersion]] = relationship( + back_populates="memory_card", + cascade="all, delete-orphan", + lazy="raise", + order_by="MemoryCardVersion.created_at.desc()", + ) + + +class MemoryCardVersion(BaseAsset): + """A single snapshot of a `MemoryCard`'s data (the whole card image, e.g. + the zipped PCSX2 folder card). Multiple versions per card form its history. + """ + + __tablename__ = "memory_card_versions" + __table_args__ = {"extend_existing": True} + + memory_card_id: Mapped[int] = mapped_column( + ForeignKey("memory_cards.id", ondelete="CASCADE") + ) + content_hash: Mapped[str | None] = mapped_column(String(length=32)) + + memory_card: Mapped[MemoryCard] = relationship( + lazy="joined", back_populates="versions" + ) + + @cached_property + def download_path(self) -> str: + # Served under the memory-cards router rather than the default + # `/api/{tablename}/...`, keeping every card route in one namespace. + return ( + f"/api/memory-cards/versions/{self.id}/content?timestamp={self.updated_at}" + ) diff --git a/backend/models/user.py b/backend/models/user.py index b92b0f8b4b..bbf04ad144 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -14,7 +14,7 @@ from utils.database import CustomJSON if TYPE_CHECKING: - from models.assets import Save, Screenshot, State + from models.assets import MemoryCard, Save, Screenshot, State from models.client_token import ClientToken from models.collection import Collection, SmartCollection from models.device import Device @@ -91,6 +91,9 @@ class User(BaseModel, SimpleUser): saves: Mapped[list[Save]] = relationship(lazy="raise", back_populates="user") states: Mapped[list[State]] = relationship(lazy="raise", back_populates="user") + memory_cards: Mapped[list[MemoryCard]] = relationship( + lazy="raise", back_populates="user" + ) screenshots: Mapped[list[Screenshot]] = relationship( lazy="raise", back_populates="user" ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ced230b688..cdb634e81e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -14,6 +14,7 @@ from handler.auth import auth_handler from handler.auth.base_handler import ALGORITHM, oct_key from handler.database import ( + db_memory_card_handler, db_permission_handler, db_platform_handler, db_rom_handler, @@ -22,7 +23,7 @@ db_state_handler, db_user_handler, ) -from models.assets import Save, Screenshot, State +from models.assets import MemoryCard, MemoryCardVersion, Save, Screenshot, State from models.client_token import ClientToken from models.device import Device from models.device_save_sync import DeviceSaveSync @@ -95,6 +96,8 @@ def clear_database(): s.query(SyncSession).delete(synchronize_session="evaluate") s.query(DeviceSaveSync).delete(synchronize_session="evaluate") s.query(Device).delete(synchronize_session="evaluate") + s.query(MemoryCardVersion).delete(synchronize_session="evaluate") + s.query(MemoryCard).delete(synchronize_session="evaluate") s.query(Save).delete(synchronize_session="evaluate") s.query(State).delete(synchronize_session="evaluate") s.query(Screenshot).delete(synchronize_session="evaluate") @@ -265,6 +268,36 @@ def screenshot(rom: Rom, platform: Platform, admin_user: User): return db_screenshot_handler.add_screenshot(screenshot) +@pytest.fixture +def memory_card(admin_user: User, platform: Platform): + """A private PCSX2 memory card owned by the admin user, no versions yet.""" + card = MemoryCard( + user_id=admin_user.id, + emulator="pcsx2", + platform_id=platform.id, + name="test_card", + slot=1, + is_public=False, + ) + return db_memory_card_handler.add_card(card) + + +@pytest.fixture +def memory_card_version(memory_card: MemoryCard, platform: Platform): + """A single snapshot attached to the `memory_card` fixture.""" + version = MemoryCardVersion( + memory_card_id=memory_card.id, + file_name="test_card.zip", + file_name_no_tags="test_card", + file_name_no_ext="test_card", + file_extension="zip", + file_path=f"{platform.slug}/memory_cards/pcsx2", + file_size_bytes=4.0, + content_hash="0123456789abcdef0123456789abcdef", + ) + return db_memory_card_handler.add_version(version) + + @pytest.fixture def admin_user(): user = User( diff --git a/backend/tests/endpoints/test_memory_cards.py b/backend/tests/endpoints/test_memory_cards.py new file mode 100644 index 0000000000..d21d0a9bdf --- /dev/null +++ b/backend/tests/endpoints/test_memory_cards.py @@ -0,0 +1,340 @@ +from unittest import mock + +from fastapi import status + +from handler.database import db_memory_card_handler +from models.assets import MemoryCard, MemoryCardVersion +from models.platform import Platform + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# --- Create --- + + +def test_create_memory_card(client, access_token: str, platform: Platform): + response = client.post( + "/api/memory-cards", + json={"name": "My Card", "emulator": "pcsx2", "platform_id": platform.id}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["name"] == "My Card" + assert body["emulator"] == "pcsx2" + assert body["platform_id"] == platform.id + assert body["slot"] == 1 + assert body["is_public"] is False + + +def test_create_memory_card_without_platform(client, access_token: str): + response = client.post( + "/api/memory-cards", + json={"name": "No Platform", "emulator": "pcsx2"}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["platform_id"] is None + + +def test_create_memory_card_blank_name_rejected(client, access_token: str): + response = client.post( + "/api/memory-cards", + json={"name": " ", "emulator": "pcsx2"}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_create_memory_card_unknown_platform_rejected(client, access_token: str): + response = client.post( + "/api/memory-cards", + json={"name": "Card", "emulator": "pcsx2", "platform_id": 99999}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# --- List (own) --- + + +def test_list_own_memory_cards(client, access_token: str, memory_card: MemoryCard): + response = client.get("/api/memory-cards", headers=_auth(access_token)) + assert response.status_code == status.HTTP_200_OK + ids = [c["id"] for c in response.json()] + assert memory_card.id in ids + + +def test_list_memory_cards_filtered_by_emulator( + client, access_token: str, memory_card: MemoryCard +): + matching = client.get( + "/api/memory-cards?emulator=pcsx2", headers=_auth(access_token) + ) + assert matching.status_code == status.HTTP_200_OK + assert [c["id"] for c in matching.json()] == [memory_card.id] + + other = client.get( + "/api/memory-cards?emulator=dolphin", headers=_auth(access_token) + ) + assert other.status_code == status.HTTP_200_OK + assert other.json() == [] + + +def test_list_does_not_show_another_users_private_card( + client, viewer_access_token: str, memory_card: MemoryCard +): + response = client.get("/api/memory-cards", headers=_auth(viewer_access_token)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + +# --- Shared --- + + +def test_shared_lists_own_and_public_cards( + client, viewer_access_token: str, memory_card: MemoryCard +): + # Private card of another user is not visible. + hidden = client.get( + "/api/memory-cards/shared?emulator=pcsx2", headers=_auth(viewer_access_token) + ) + assert hidden.status_code == status.HTTP_200_OK + assert hidden.json() == [] + + # Once public, it shows up enriched with the owner's username. + db_memory_card_handler.update_card(memory_card.id, {"is_public": True}) + shared = client.get( + "/api/memory-cards/shared?emulator=pcsx2", headers=_auth(viewer_access_token) + ) + assert shared.status_code == status.HTTP_200_OK + body = shared.json() + assert len(body) == 1 + assert body[0]["id"] == memory_card.id + assert body[0]["username"] == "test_admin" + + +# --- Get one --- + + +def test_get_own_memory_card(client, access_token: str, memory_card: MemoryCard): + response = client.get( + f"/api/memory-cards/{memory_card.id}", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == memory_card.id + + +def test_get_other_users_private_card_is_404( + client, viewer_access_token: str, memory_card: MemoryCard +): + response = client.get( + f"/api/memory-cards/{memory_card.id}", headers=_auth(viewer_access_token) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_other_users_public_card( + client, viewer_access_token: str, memory_card: MemoryCard +): + db_memory_card_handler.update_card(memory_card.id, {"is_public": True}) + response = client.get( + f"/api/memory-cards/{memory_card.id}", headers=_auth(viewer_access_token) + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == memory_card.id + + +def test_get_missing_memory_card_is_404(client, access_token: str): + response = client.get("/api/memory-cards/99999", headers=_auth(access_token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# --- Versions --- + + +def test_list_memory_card_versions( + client, + access_token: str, + memory_card: MemoryCard, + memory_card_version: MemoryCardVersion, +): + response = client.get( + f"/api/memory-cards/{memory_card.id}/versions", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert len(body) == 1 + assert body[0]["id"] == memory_card_version.id + assert body[0]["content_hash"] == "0123456789abcdef0123456789abcdef" + assert body[0]["download_path"].startswith( + f"/api/memory-cards/versions/{memory_card_version.id}/content?timestamp=" + ) + + +@mock.patch("endpoints.memory_cards.fs_asset_handler.validate_path") +def test_owner_downloads_version_content( + mock_validate_path, + client, + access_token: str, + memory_card_version: MemoryCardVersion, + tmp_path, +): + test_file = tmp_path / "card.zip" + test_file.write_bytes(b"CARD_ZIP") + mock_validate_path.return_value = test_file + + response = client.get( + f"/api/memory-cards/versions/{memory_card_version.id}/content", + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_200_OK + assert response.content == b"CARD_ZIP" + + +def test_other_user_cannot_download_private_version( + client, viewer_access_token: str, memory_card_version: MemoryCardVersion +): + response = client.get( + f"/api/memory-cards/versions/{memory_card_version.id}/content", + headers=_auth(viewer_access_token), + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@mock.patch("endpoints.memory_cards.fs_asset_handler.validate_path") +def test_other_user_downloads_public_version( + mock_validate_path, + client, + viewer_access_token: str, + memory_card: MemoryCard, + memory_card_version: MemoryCardVersion, + tmp_path, +): + db_memory_card_handler.update_card(memory_card.id, {"is_public": True}) + test_file = tmp_path / "card.zip" + test_file.write_bytes(b"SHARED_CARD") + mock_validate_path.return_value = test_file + + response = client.get( + f"/api/memory-cards/versions/{memory_card_version.id}/content", + headers=_auth(viewer_access_token), + ) + assert response.status_code == status.HTTP_200_OK + assert response.content == b"SHARED_CARD" + + +def test_download_missing_version_is_404(client, access_token: str): + response = client.get( + "/api/memory-cards/versions/99999/content", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# --- Rename --- + + +def test_rename_memory_card(client, access_token: str, memory_card: MemoryCard): + response = client.put( + f"/api/memory-cards/{memory_card.id}", + json={"name": "Renamed"}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "Renamed" + + +def test_rename_blank_name_rejected(client, access_token: str, memory_card: MemoryCard): + response = client.put( + f"/api/memory-cards/{memory_card.id}", + json={"name": " "}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_other_user_cannot_rename_card( + client, viewer_access_token: str, memory_card: MemoryCard +): + response = client.put( + f"/api/memory-cards/{memory_card.id}", + json={"name": "Hijacked"}, + headers=_auth(viewer_access_token), + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# --- Visibility --- + + +def test_toggle_visibility(client, access_token: str, memory_card: MemoryCard): + response = client.put( + f"/api/memory-cards/{memory_card.id}/visibility", + json={"is_public": True}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["is_public"] is True + + refreshed = db_memory_card_handler.get_card_by_id(memory_card.id) + assert refreshed is not None and refreshed.is_public is True + + +def test_other_user_cannot_change_visibility( + client, viewer_access_token: str, memory_card: MemoryCard +): + response = client.put( + f"/api/memory-cards/{memory_card.id}/visibility", + json={"is_public": True}, + headers=_auth(viewer_access_token), + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# --- Delete --- + + +@mock.patch("endpoints.memory_cards.fs_asset_handler.remove_file") +def test_delete_own_card( + mock_remove_file, + client, + access_token: str, + memory_card: MemoryCard, + memory_card_version: MemoryCardVersion, +): + response = client.post( + "/api/memory-cards/delete", + json={"cards": [memory_card.id]}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_200_OK + assert response.json() == [memory_card.id] + + # The version's archive is removed, and both rows are gone. + mock_remove_file.assert_awaited_once() + assert db_memory_card_handler.get_card_by_id(memory_card.id) is None + assert db_memory_card_handler.get_version_by_id(memory_card_version.id) is None + + +def test_delete_empty_list_rejected(client, access_token: str): + response = client.post( + "/api/memory-cards/delete", + json={"cards": []}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_other_user_cannot_delete_card( + client, viewer_access_token: str, memory_card: MemoryCard +): + response = client.post( + "/api/memory-cards/delete", + json={"cards": [memory_card.id]}, + headers=_auth(viewer_access_token), + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + # The card survives the rejected delete. + assert db_memory_card_handler.get_card_by_id(memory_card.id) is not None diff --git a/backend/tests/endpoints/test_streaming.py b/backend/tests/endpoints/test_streaming.py index 6babcf8df6..0490e88dbc 100644 --- a/backend/tests/endpoints/test_streaming.py +++ b/backend/tests/endpoints/test_streaming.py @@ -1,24 +1,39 @@ import asyncio +import io +import json import logging +import zipfile from contextlib import contextmanager -from datetime import timedelta -from unittest.mock import MagicMock, patch +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient from main import app from config import LIBRARY_BASE_PATH, OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS +from endpoints import streaming from handler.auth import oauth_handler -from handler.database import db_platform_handler, db_rom_handler +from handler.database import ( + db_memory_card_handler, + db_platform_handler, + db_play_session_handler, + db_rom_handler, + db_save_handler, + db_state_handler, +) from handler.database.base_handler import sync_session from handler.redis_handler import async_cache +from models.assets import MemoryCard, MemoryCardVersion, Save, Screenshot, State from models.permission import HiddenEntity, PermEntity from models.platform import Platform from models.rom import Rom from models.user import User +# ── Fixtures / helpers ──────────────────────────────────────────────────────── + def _hide(entity: PermEntity, entity_id: int, user_id: int) -> None: with sync_session.begin() as s: @@ -33,7 +48,7 @@ def client(): @pytest.fixture(autouse=True) def clear_streaming_sessions(): - """Streaming sessions live in Redis (fakeredis under pytest) - start clean.""" + """Streaming sessions live in Redis (fakeredis under pytest), start clean.""" asyncio.run(async_cache.flushall()) yield @@ -108,14 +123,15 @@ def _auth(token): return {"Authorization": f"Bearer {token}"} -def _claim(client, token, rom_id): - return client.post( - "/api/streaming/sessions", json={"rom_id": rom_id}, headers=_auth(token) - ) +def _claim(client, token, rom_id, state_id=None): + body = {"rom_id": rom_id} + if state_id is not None: + body["state_id"] = state_id + return client.post("/api/streaming/sessions", json=body, headers=_auth(token)) def _claim_ok(client, token, rom_id): - """Claim with the broker launch stubbed - the common happy-path setup.""" + """Claim with the broker launch stubbed, the common happy-path setup.""" with patch("endpoints.streaming._call_broker"): return _claim(client, token, rom_id) @@ -160,6 +176,19 @@ def test_get_config_ships_platform_capabilities(client, access_token): } +def test_get_config_reports_memory_card_support(client, access_token, rom: Rom): + """The picker gate: only containers with memory_card_sync report support.""" + plain = _container_for(rom) + syncing = {**_container_for(rom), "platform": "ps2", "memory_card_sync": True} + with _streaming(plain, syncing): + response = client.get("/api/streaming/config", headers=_auth(access_token)) + assert response.status_code == 200 + containers = response.json()["containers"] + supported = {c["platform"]: c["supports_memory_cards"] for c in containers} + assert supported[rom.platform_slug] is False + assert supported["ps2"] is True + + # ── Claiming ────────────────────────────────────────────────────────────────── @@ -170,10 +199,22 @@ def test_claim_derives_rom_path_server_side(client, access_token, rom: Rom): r = _claim(client, access_token, rom.id) assert r.status_code == 200 assert r.json()["rom_name"] == rom.name - _, rom_path, _ = call_broker.call_args[0] + _, rom_path, _, _ = call_broker.call_args[0] assert rom_path == f"{LIBRARY_BASE_PATH}/{rom.full_path}" +def test_claim_honors_container_library_path(client, access_token, rom: Rom): + """`library_path` on the container entry replaces LIBRARY_BASE_PATH so the + broker gets a path valid inside a container with a different mount.""" + container = {**_container_for(rom), "library_path": "/mnt/games/"} + with _streaming(container): + with patch("endpoints.streaming._call_broker") as call_broker: + r = _claim(client, access_token, rom.id) + assert r.status_code == 200 + _, rom_path, _, _ = call_broker.call_args[0] + assert rom_path == f"/mnt/games/{rom.full_path}" + + def test_claim_unknown_rom_returns_404(client, access_token): with _streaming(): r = _claim(client, access_token, 999999) @@ -227,8 +268,6 @@ def test_claim_skips_container_missing_host(client, access_token, rom: Rom): async def test_claim_sets_session_ttl(access_token, rom: Rom): """A claimed session must carry a TTL so an abandoned one eventually frees the container instead of wedging it forever.""" - from endpoints.streaming import SESSION_TTL_SECONDS, _session_redis_key - with _streaming(_container_for(rom)): with patch("endpoints.streaming._call_broker"): async with httpx.AsyncClient( @@ -240,17 +279,17 @@ async def test_claim_sets_session_ttl(access_token, rom: Rom): headers=_auth(access_token), ) assert r.status_code == 200 - ttl = await async_cache.ttl(_session_redis_key(_container_for(rom)["broker_host"])) + key = streaming._session_redis_key(streaming._container_key(_container_for(rom))) + ttl = await async_cache.ttl(key) assert ttl > 0 - assert ttl <= SESSION_TTL_SECONDS + assert ttl <= streaming.SESSION_TTL_SECONDS def test_second_claim_on_same_container_rejected(client, access_token, rom: Rom): """The container is single-tenant: a second claim must 409 with the holder.""" with _streaming(_container_for(rom)): - with patch("endpoints.streaming._call_broker"): - r1 = _claim(client, access_token, rom.id) - r2 = _claim(client, access_token, rom.id) + r1 = _claim_ok(client, access_token, rom.id) + r2 = _claim_ok(client, access_token, rom.id) assert r1.status_code == 200 assert r2.status_code == 409 assert r2.json()["detail"]["rom_name"] == rom.name @@ -280,17 +319,14 @@ def test_claim_session_same_container_two_platforms_rejected( _container_for(rom, broker_host=shared_broker), _container_for(rom2, broker_host=shared_broker), ): - with patch("endpoints.streaming._call_broker"): - r1 = _claim(client, access_token, rom.id) - r2 = _claim(client, access_token, rom2.id) + r1 = _claim_ok(client, access_token, rom.id) + r2 = _claim_ok(client, access_token, rom2.id) assert r1.status_code == 200 assert r2.status_code == 409 def test_failed_broker_launch_frees_the_claim(client, access_token, rom: Rom): """If the broker rejects the launch, the container must not stay claimed.""" - from fastapi import HTTPException - with _streaming(_container_for(rom)): with patch( "endpoints.streaming._call_broker", @@ -310,21 +346,115 @@ async def test_concurrent_claim_only_one_succeeds(access_token, rom: Rom): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as ac: + headers = _auth(access_token) r1, r2 = await asyncio.gather( ac.post( "/api/streaming/sessions", json={"rom_id": rom.id}, - headers=_auth(access_token), + headers=headers, ), ac.post( "/api/streaming/sessions", json={"rom_id": rom.id}, - headers=_auth(access_token), + headers=headers, ), ) assert sorted([r1.status_code, r2.status_code]) == [200, 409] +# ── Staleness / heartbeat ───────────────────────────────────────────────────── + + +def _age_session(rom: Rom, seconds: int) -> None: + """Rewrite the stored session's last_seen to `seconds` ago.""" + key = streaming._session_redis_key(streaming._container_key(_container_for(rom))) + raw = asyncio.run(async_cache.get(key)) + session = json.loads(raw) + session["last_seen"] = ( + datetime.now(timezone.utc) - timedelta(seconds=seconds) + ).isoformat() + asyncio.run(async_cache.set(key, json.dumps(session))) + + +def test_session_is_stale_handles_bad_stamps(): + """Missing or corrupt stamps must count as stale, not wedge the container.""" + assert streaming._session_is_stale({}) is True + assert streaming._session_is_stale({"last_seen": "not-a-date"}) is True + fresh = datetime.now(timezone.utc).isoformat() + assert streaming._session_is_stale({"last_seen": fresh}) is False + old = ( + datetime.now(timezone.utc) + - timedelta(seconds=streaming._SESSION_STALE_SECONDS + 1) + ).isoformat() + assert streaming._session_is_stale({"last_seen": old}) is True + + +def test_stale_session_taken_over_on_claim( + client, access_token, viewer_access_token, rom: Rom +): + """A claim against a session whose heartbeat stopped must tear the old + session down (broker stop included) and win the container.""" + with _streaming(_container_for(rom)): + r1 = _claim_ok(client, access_token, rom.id) + _age_session(rom, streaming._SESSION_STALE_SECONDS + 60) + with patch("endpoints.streaming._stop_broker") as stop_broker: + r2 = _claim_ok(client, viewer_access_token, rom.id) + assert r1.status_code == 200 + assert r2.status_code == 200 + stop_broker.assert_called_once() + + +def test_fresh_session_not_taken_over( + client, access_token, viewer_access_token, rom: Rom +): + """A session with a live heartbeat keeps its claim: second claim is 409 + and the running emulator is never stopped.""" + with _streaming(_container_for(rom)): + r1 = _claim_ok(client, access_token, rom.id) + with patch("endpoints.streaming._stop_broker") as stop_broker: + r2 = _claim_ok(client, viewer_access_token, rom.id) + assert r1.status_code == 200 + assert r2.status_code == 409 + stop_broker.assert_not_called() + + +def test_heartbeat_refreshes_last_seen(client, access_token, rom: Rom): + """A heartbeat on an aged session must reset its staleness clock so a + rival claim no longer takes it over.""" + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + _age_session(rom, streaming._SESSION_STALE_SECONDS + 60) + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(access_token), + ) + assert r.status_code == 200 + key = streaming._session_redis_key(streaming._container_key(_container_for(rom))) + session = json.loads(asyncio.run(async_cache.get(key))) + assert not streaming._session_is_stale(session) + + +def test_heartbeat_without_session_returns_404(client, access_token, rom: Rom): + with _streaming(_container_for(rom)): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(access_token), + ) + assert r.status_code == 404 + + +def test_heartbeat_by_other_user_is_forbidden( + client, access_token, viewer_access_token, rom: Rom +): + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(viewer_access_token), + ) + assert r.status_code == 403 + + # ── Release / ownership ─────────────────────────────────────────────────────── @@ -369,6 +499,61 @@ def test_save_state_by_other_user_is_forbidden( assert r.status_code == 403 +def test_save_and_exit_releases_session(client, access_token, rom: Rom): + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + with ( + patch("endpoints.streaming._save_and_exit_broker", return_value=(True, 10)), + # Plain MagicMock: the async original would auto-mock to AsyncMock, + # whose call handed to the mocked spawn is a never-awaited coroutine. + patch("endpoints.streaming._pull_state_to_library", new=MagicMock()), + patch("endpoints.streaming._pull_saves_to_library", new=MagicMock()), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", + json={"slot": 10, "wait": True}, + headers=_auth(access_token), + ) + # Container must be claimable again after save-and-exit. + r2 = _claim_ok(client, access_token, rom.id) + assert r.status_code == 200 + assert r.json()["saved"] is True + assert r2.status_code == 200 + + +def test_save_and_exit_failure_still_releases_session(client, access_token, rom: Rom): + """A failed save is reported as saved=False, but the session is still + released - the container must not stay claimed by a dead session.""" + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + with patch( + "endpoints.streaming._save_and_exit_broker", return_value=(False, 10) + ): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", + json={"slot": 10, "wait": True}, + headers=_auth(access_token), + ) + r2 = _claim_ok(client, access_token, rom.id) + assert r.status_code == 200 + assert r.json()["saved"] is False + assert r2.status_code == 200 + + +def test_force_release_all_stops_brokers(client, access_token, rom: Rom): + """Force-release must tell each broker to stop, not just clear Redis.""" + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + with patch("endpoints.streaming._stop_broker") as stop_broker: + r = client.delete("/api/streaming/sessions", headers=_auth(access_token)) + assert r.status_code == 200 + assert stop_broker.call_count == 1 + + +# ── Save-state sync ─────────────────────────────────────────────────────────── + + def test_save_state_rejects_slot_above_platform_max(client, access_token): """Dolphin exposes 7 manual slots; slot 8 clears the coarse union bound (<=9) but must be rejected against the platform's real ceiling.""" @@ -411,37 +596,115 @@ def test_load_state_rejects_slot_between_max_and_autosave(client, access_token): assert r.status_code == 422 -def test_save_and_exit_releases_session(client, access_token, rom: Rom): +def _state_for(rom: Rom, user: User, file_name: str, emulator: str) -> State: + name_no_ext, _, extension = file_name.rpartition(".") + return State( + rom_id=rom.id, + user_id=user.id, + file_name=file_name, + file_name_no_tags=name_no_ext, + file_name_no_ext=name_no_ext, + file_extension=extension, + emulator=emulator, + file_path=f"{rom.platform_slug}/states/{emulator}", + file_size_bytes=1.0, + ) + + +def test_claim_spawns_state_hydration(client, access_token, rom: Rom): + """Claiming a session must schedule a background hydration of the + container's save-state slots from the user's stored states.""" + with _streaming(_container_for(rom)): + with ( + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._spawn_sync_task") as spawn, + patch( + "endpoints.streaming._hydrate_states_to_broker", new=MagicMock() + ) as hydrate, + ): + r = _claim(client, access_token, rom.id) + assert r.status_code == 200 + spawn.assert_called_once() + assert hydrate.call_args[0][1] == rom.id + + +def test_save_state_spawns_library_pull(client, access_token): + """Every manual save-state must schedule a background pull to the library.""" + rom = _rom_on("ps2") with _streaming(_container_for(rom)): _claim_ok(client, access_token, rom.id) - with patch("endpoints.streaming._save_and_exit_broker", return_value=True): + with ( + patch("endpoints.streaming._save_state_broker", return_value=True), + patch("endpoints.streaming._spawn_sync_task") as spawn, + patch( + "endpoints.streaming._pull_state_to_library", new=MagicMock() + ) as pull, + ): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/save-state", + json={"slot": 3}, + headers=_auth(access_token), + ) + assert r.status_code == 200 + spawn.assert_called_once() + _, pulled_rom_id, _, pulled_slot = pull.call_args[0] + assert pulled_rom_id == rom.id + assert pulled_slot == 3 + + +def test_save_and_exit_pulls_broker_effective_slot(client, access_token, rom: Rom): + """The state pull must target the slot the broker actually saved to (slot 0 + is resolved broker-side to its exit-save slot), not the requested slot. The + in-game save pull is spawned alongside it.""" + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + with ( + patch("endpoints.streaming._save_and_exit_broker", return_value=(True, 10)), + patch("endpoints.streaming._spawn_sync_task") as spawn, + patch( + "endpoints.streaming._pull_state_to_library", new=MagicMock() + ) as pull, + patch("endpoints.streaming._pull_saves_to_library", new=MagicMock()), + ): r = client.post( f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", - json={"slot": 10, "wait": True}, + json={"slot": 0, "wait": True}, headers=_auth(access_token), ) - # Container must be claimable again after save-and-exit. - r2 = _claim_ok(client, access_token, rom.id) assert r.status_code == 200 - assert r.json()["saved"] is True - assert r2.status_code == 200 + # One spawn for the state pull, one for the in-game save pull. + assert spawn.call_count == 2 + assert pull.call_args[0][3] == 10 -def test_save_and_exit_failure_still_releases_session(client, access_token, rom: Rom): - """A failed save is reported as saved=False, but the session is still - released - the container must not stay claimed by a dead session.""" +def test_save_and_exit_failed_blocking_save_skips_state_pull( + client, access_token, rom: Rom +): + """A confirmed-failed blocking save has no state to pull, but in-game saves + are still synced (memory cards flush during play, not on the savestate).""" with _streaming(_container_for(rom)): _claim_ok(client, access_token, rom.id) - with patch("endpoints.streaming._save_and_exit_broker", return_value=False): + with ( + patch( + "endpoints.streaming._save_and_exit_broker", return_value=(False, 10) + ), + patch("endpoints.streaming._spawn_sync_task") as spawn, + patch( + "endpoints.streaming._pull_state_to_library", new=MagicMock() + ) as state_pull, + patch( + "endpoints.streaming._pull_saves_to_library", new=MagicMock() + ) as save_pull, + ): r = client.post( f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", json={"slot": 10, "wait": True}, headers=_auth(access_token), ) - r2 = _claim_ok(client, access_token, rom.id) assert r.status_code == 200 - assert r.json()["saved"] is False - assert r2.status_code == 200 + state_pull.assert_not_called() + save_pull.assert_called_once() + spawn.assert_called_once() def test_save_and_exit_wait_false_drains_instead_of_freeing( @@ -450,11 +713,14 @@ def test_save_and_exit_wait_false_drains_instead_of_freeing( """wait=false means the broker is still killing in the background; the session key must briefly block a re-claim (drain) rather than be deleted immediately, so a new launch can't land on a not-yet-dead emulator.""" - from endpoints.streaming import SESSION_DRAIN_SECONDS, _session_redis_key - with _streaming(_container_for(rom)): _claim_ok(client, access_token, rom.id) - with patch("endpoints.streaming._save_and_exit_broker", return_value=True): + with ( + patch("endpoints.streaming._save_and_exit_broker", return_value=(True, 10)), + patch("endpoints.streaming._pull_state_to_library", new=MagicMock()), + patch("endpoints.streaming._pull_saves_to_library", new=MagicMock()), + patch("endpoints.streaming._spawn_sync_task"), + ): r = client.post( f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", json={"slot": 0, "wait": False}, @@ -466,20 +732,510 @@ def test_save_and_exit_wait_false_drains_instead_of_freeing( # Re-claim during the drain window is rejected (409), not accepted (200). assert r2.status_code == 409 # Drain TTL is bounded to the short window, not the full session TTL. - ttl = asyncio.run( - async_cache.ttl(_session_redis_key(_container_for(rom)["broker_host"])) + key = streaming._session_redis_key(streaming._container_key(_container_for(rom))) + ttl = asyncio.run(async_cache.ttl(key)) + assert 0 < ttl <= streaming.SESSION_DRAIN_SECONDS + + +def test_pull_state_to_library_stores_state(rom: Rom, admin_user: User): + """A pulled state file lands in the user's state library under the + container's emulator namespace, keyed by the broker-supplied filename.""" + container = {**_container_for(rom), "label": "PCSX2"} + scanned = _state_for(rom, admin_user, "Game.03.p2s", "pcsx2") + with ( + patch( + "endpoints.streaming._fetch_state_file", + return_value=("Game.03.p2s", b"state-bytes"), + ), + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()) as wf, + patch("endpoints.streaming.scan_state", new=AsyncMock(return_value=scanned)), + ): + ok = asyncio.run( + streaming._pull_state_to_library(admin_user.id, rom.id, container, 3) + ) + assert ok is True + wf.assert_awaited_once() + assert wf.await_args_list[0].kwargs["filename"] == "Game.03.p2s" + db_state = db_state_handler.get_state_by_filename( + user_id=admin_user.id, rom_id=rom.id, file_name="Game.03.p2s" ) - assert 0 < ttl <= SESSION_DRAIN_SECONDS + assert db_state is not None + assert db_state.emulator == "pcsx2" -def test_force_release_all_stops_brokers(client, access_token, rom: Rom): - """Force-release must tell each broker to stop, not just clear Redis.""" +def test_pull_state_rejects_unsanitizable_filename(rom: Rom, admin_user: User): + """A broker filename that sanitizes to nothing must be dropped, not stored.""" + with ( + patch("endpoints.streaming._fetch_state_file", return_value=("***", b"bytes")), + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()) as wf, + ): + ok = asyncio.run( + streaming._pull_state_to_library( + admin_user.id, rom.id, _container_for(rom), 1 + ) + ) + assert ok is False + wf.assert_not_awaited() + + +def test_hydrate_pushes_only_matching_emulator_states(rom: Rom, admin_user: User): + """Hydration must push only states saved under this container's emulator + namespace - EmulatorJS states for the same ROM stay out of the container.""" + db_state_handler.add_state(_state_for(rom, admin_user, "Game.01.p2s", "pcsx2")) + db_state_handler.add_state(_state_for(rom, admin_user, "Game.state", "retroarch")) + container = {**_container_for(rom), "label": "PCSX2"} + with ( + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(return_value=b"state-bytes"), + ), + patch("endpoints.streaming._push_state_file", return_value=True) as push, + ): + pushed = asyncio.run( + streaming._hydrate_states_to_broker(admin_user.id, rom.id, container) + ) + assert pushed == 1 + push.assert_called_once() + assert push.call_args[0][1] == "Game.01.p2s" + + +def test_hydrate_skips_states_missing_on_disk(rom: Rom, admin_user: User): + """A DB row whose file vanished from disk is skipped, not fatal.""" + db_state_handler.add_state(_state_for(rom, admin_user, "Game.01.p2s", "pcsx2")) + container = {**_container_for(rom), "label": "PCSX2"} + with ( + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(side_effect=FileNotFoundError), + ), + patch("endpoints.streaming._push_state_file", return_value=True) as push, + ): + pushed = asyncio.run( + streaming._hydrate_states_to_broker(admin_user.id, rom.id, container) + ) + assert pushed == 0 + push.assert_not_called() + + +def _p2s_bytes(screenshot: bytes | None = b"PNGDATA") -> bytes: + """Build a PCSX2 .p2s-shaped zip, optionally embedding a Screenshot.png.""" + from tests._zipfile_shim import reload_zipfile + + # zipfile-inflate64 in the import chain breaks writestr; restore stdlib first. + reload_zipfile() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("Sstates.bin", b"savestate-payload") + if screenshot is not None: + zf.writestr("Screenshot.png", screenshot) + return buf.getvalue() + + +def test_extract_state_screenshot_pcsx2_returns_png(): + assert streaming._extract_state_screenshot("pcsx2", _p2s_bytes(b"PNG")) == b"PNG" + + +def test_extract_state_screenshot_non_pcsx2_returns_none(): + # Other emulators are out of scope until #21 generalizes the model. + assert streaming._extract_state_screenshot("dolphin", _p2s_bytes()) is None + + +def test_extract_state_screenshot_missing_entry_returns_none(): + assert streaming._extract_state_screenshot("pcsx2", _p2s_bytes(None)) is None + + +def test_extract_state_screenshot_empty_entry_returns_none(): + assert streaming._extract_state_screenshot("pcsx2", _p2s_bytes(b"")) is None + + +def test_extract_state_screenshot_not_a_zip_returns_none(): + assert streaming._extract_state_screenshot("pcsx2", b"not-a-zip") is None + + +def test_store_state_screenshot_binds_to_state(admin_user: User, rom: Rom): + """A stored state screenshot lands in the screenshots dir under the state's + stem, so State.screenshot resolves it as the resume-picker thumbnail.""" + db_state_handler.add_state(_state_for(rom, admin_user, "Game.03.p2s", "pcsx2")) + scanned = Screenshot( + file_name="Game.03.png", + file_name_no_tags="Game.03", + file_name_no_ext="Game.03", + file_extension="png", + file_path=f"{rom.platform_slug}/screenshots", + file_size_bytes=7, + ) + with ( + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()) as wf, + patch( + "endpoints.streaming.scan_screenshot", + new=AsyncMock(return_value=scanned), + ), + ): + asyncio.run( + streaming._store_state_screenshot( + admin_user, rom, "Game.03.p2s", b"PNGDATA" + ) + ) + wf.assert_awaited_once() + assert wf.await_args_list[0].kwargs["filename"] == "Game.03.png" + state = db_state_handler.get_state_by_filename( + user_id=admin_user.id, rom_id=rom.id, file_name="Game.03.p2s" + ) + assert state.screenshot is not None + assert state.screenshot.file_name == "Game.03.png" + assert state.screenshot.is_gallery is False + + +def test_store_state_asset_binds_extracted_screenshot(admin_user: User, rom: Rom): + """End to end: pulling a .p2s stores the state and binds its embedded frame + as the state's thumbnail.""" + scanned_state = _state_for(rom, admin_user, "Game.03.p2s", "pcsx2") + scanned_shot = Screenshot( + file_name="Game.03.png", + file_name_no_tags="Game.03", + file_name_no_ext="Game.03", + file_extension="png", + file_path=f"{rom.platform_slug}/screenshots", + file_size_bytes=7, + ) + with ( + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()), + patch( + "endpoints.streaming.scan_state", + new=AsyncMock(return_value=scanned_state), + ), + patch( + "endpoints.streaming.scan_screenshot", + new=AsyncMock(return_value=scanned_shot), + ) as scan_shot, + ): + asyncio.run( + streaming._store_state_asset( + admin_user, rom, "pcsx2", "Game.03.p2s", _p2s_bytes(b"PNG") + ) + ) + scan_shot.assert_awaited_once() + state = db_state_handler.get_state_by_filename( + user_id=admin_user.id, rom_id=rom.id, file_name="Game.03.p2s" + ) + assert state.screenshot is not None + assert state.screenshot.file_name == "Game.03.png" + + +def test_store_state_asset_without_screenshot_still_stores_state( + admin_user: User, rom: Rom +): + """A .p2s with no embedded frame syncs the state with no thumbnail; the + missing screenshot must not fail the state sync.""" + scanned_state = _state_for(rom, admin_user, "Game.04.p2s", "pcsx2") + with ( + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()), + patch( + "endpoints.streaming.scan_state", + new=AsyncMock(return_value=scanned_state), + ), + patch("endpoints.streaming.scan_screenshot", new=AsyncMock()) as scan_shot, + ): + asyncio.run( + streaming._store_state_asset( + admin_user, rom, "pcsx2", "Game.04.p2s", _p2s_bytes(None) + ) + ) + scan_shot.assert_not_awaited() + state = db_state_handler.get_state_by_filename( + user_id=admin_user.id, rom_id=rom.id, file_name="Game.04.p2s" + ) + assert state is not None + assert state.screenshot is None + + +# ── In-game save sync ───────────────────────────────────────────────────────── + + +def _save_for( + rom: Rom, user: User, file_name: str, emulator: str, content_hash: str | None = None +) -> Save: + name_no_ext, _, extension = file_name.rpartition(".") + return Save( + rom_id=rom.id, + user_id=user.id, + file_name=file_name, + file_name_no_tags=name_no_ext, + file_name_no_ext=name_no_ext, + file_extension=extension, + emulator=emulator, + content_hash=content_hash, + file_path=f"{rom.platform_slug}/saves/{emulator}", + file_size_bytes=1.0, + ) + + +def test_pull_saves_stores_new_archive(rom: Rom, admin_user: User): + """A pulled save archive lands as a new Save asset under the container's + emulator namespace.""" + container = {**_container_for(rom), "label": "PCSX2"} + scanned = _save_for(rom, admin_user, "Game [pcsx2].saves.zip", "pcsx2", "hash-a") + with ( + patch("endpoints.streaming._fetch_save_archive", return_value=b"zip-bytes"), + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()) as wf, + patch("endpoints.streaming.scan_save", new=AsyncMock(return_value=scanned)), + ): + ok = asyncio.run( + streaming._pull_saves_to_library(admin_user.id, rom.id, container) + ) + assert ok is True + wf.assert_awaited_once() + saves = db_save_handler.get_saves(user_id=admin_user.id, rom_id=rom.id) + assert any(s.emulator == "pcsx2" and s.content_hash == "hash-a" for s in saves) + + +def test_pull_saves_dedups_identical_archive(rom: Rom, admin_user: User): + """Re-pulling an unchanged archive (same content hash) must not add a second + row, and must delete the just-written duplicate file.""" + db_save_handler.add_save( + _save_for(rom, admin_user, "Game [pcsx2 old].saves.zip", "pcsx2", "dup-hash") + ) + container = {**_container_for(rom), "label": "PCSX2"} + scanned = _save_for( + rom, admin_user, "Game [pcsx2 new].saves.zip", "pcsx2", "dup-hash" + ) + with ( + patch("endpoints.streaming._fetch_save_archive", return_value=b"zip-bytes"), + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()), + patch("endpoints.streaming.scan_save", new=AsyncMock(return_value=scanned)), + patch( + "endpoints.streaming.fs_asset_handler.remove_file", new=AsyncMock() + ) as rm, + ): + ok = asyncio.run( + streaming._pull_saves_to_library(admin_user.id, rom.id, container) + ) + assert ok is True + rm.assert_awaited_once() + saves = db_save_handler.get_saves(user_id=admin_user.id, rom_id=rom.id) + hashes = [s.content_hash for s in saves if s.emulator == "pcsx2"] + assert hashes == ["dup-hash"] + + +def test_pull_saves_no_changes_returns_false(rom: Rom, admin_user: User): + """A 404 from the broker (nothing changed) yields no stored save.""" + container = {**_container_for(rom), "label": "PCSX2"} + with ( + patch("endpoints.streaming._fetch_save_archive", return_value=None), + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()) as wf, + ): + ok = asyncio.run( + streaming._pull_saves_to_library(admin_user.id, rom.id, container) + ) + assert ok is False + wf.assert_not_awaited() + + +def test_hydrate_saves_pushes_newest_matching_zip(rom: Rom, admin_user: User): + """Hydration pushes the newest .zip save for this container's emulator, and + ignores non-zip saves and other emulators' saves.""" + db_save_handler.add_save( + _save_for(rom, admin_user, "Game [pcsx2 a].saves.zip", "pcsx2", "h1") + ) + db_save_handler.add_save( + _save_for(rom, admin_user, "Game [pcsx2 b].saves.zip", "pcsx2", "h2") + ) + db_save_handler.add_save(_save_for(rom, admin_user, "loose.mcr", "pcsx2", "h3")) + db_save_handler.add_save( + _save_for(rom, admin_user, "Game [dolphin].saves.zip", "dolphin", "h4") + ) + container = {**_container_for(rom), "label": "PCSX2"} + with ( + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(return_value=b"zip-bytes"), + ), + patch("endpoints.streaming._push_save_archive", return_value=True) as push, + ): + ok = asyncio.run( + streaming._hydrate_saves_to_broker(admin_user.id, rom.id, container) + ) + assert ok is True + push.assert_called_once() + # The pushed file must be a pcsx2 .zip, never the .mcr or the dolphin save. + pushed_content = push.call_args[0][1] + assert pushed_content == b"zip-bytes" + + +def test_hydrate_saves_no_matching_save_returns_false(rom: Rom, admin_user: User): + """No stored zip save for the emulator means nothing to hydrate.""" + db_save_handler.add_save(_save_for(rom, admin_user, "loose.mcr", "pcsx2", "h1")) + container = {**_container_for(rom), "label": "PCSX2"} + with patch("endpoints.streaming._push_save_archive", return_value=True) as push: + ok = asyncio.run( + streaming._hydrate_saves_to_broker(admin_user.id, rom.id, container) + ) + assert ok is False + push.assert_not_called() + + +def test_claim_hydrates_saves_before_launch(client, access_token, rom: Rom): + """Claiming a session must push stored in-game saves to the container before + the broker launch (games read saves at boot).""" + call_order = [] + with _streaming(_container_for(rom)): + with ( + patch( + "endpoints.streaming._call_broker", + side_effect=lambda *a, **k: call_order.append("launch"), + ), + patch( + "endpoints.streaming._hydrate_saves_to_broker", + new=AsyncMock(side_effect=lambda *a, **k: call_order.append("saves")), + ) as hydrate_saves, + patch("endpoints.streaming._spawn_sync_task"), + patch("endpoints.streaming._hydrate_states_to_broker", new=MagicMock()), + ): + r = _claim(client, access_token, rom.id) + assert r.status_code == 200 + hydrate_saves.assert_awaited_once() + assert call_order == ["saves", "launch"] + + +def test_release_spawns_saves_pull(client, access_token, rom: Rom): + """Releasing a session must schedule a background pull of in-game saves.""" with _streaming(_container_for(rom)): _claim_ok(client, access_token, rom.id) - with patch("endpoints.streaming._stop_broker") as stop_broker: - r = client.delete("/api/streaming/sessions", headers=_auth(access_token)) + with ( + patch("endpoints.streaming._stop_broker"), + patch("endpoints.streaming._spawn_sync_task") as spawn, + patch( + "endpoints.streaming._pull_saves_to_library", new=MagicMock() + ) as pull, + ): + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + headers=_auth(access_token), + ) assert r.status_code == 200 - assert stop_broker.call_count == 1 + spawn.assert_called_once() + assert pull.call_args[0][1] == rom.id + + +# ── Resume-from-state ───────────────────────────────────────────────────────── + + +def test_slot_from_state_filename(): + assert streaming._slot_from_state_filename("pcsx2", "SLUS (A1B2).3.p2s") == 3 + assert streaming._slot_from_state_filename("pcsx2", "SLUS (A1B2).10.p2s") == 10 + assert streaming._slot_from_state_filename("dolphin", "GALE01.s02") == 2 + assert streaming._slot_from_state_filename("pcsx2", "Game.p2s") is None + assert streaming._slot_from_state_filename("dolphin", "GALE01.gci") is None + assert streaming._slot_from_state_filename("pcsx2", "Game.0.p2s") is None + assert streaming._slot_from_state_filename("retroarch", "Game.state") is None + + +def _resume_claim(client, token, rom, state_id, push_ok=True): + """Claim with a resume state and full launch-path mocks. Returns + (response, push mock, call_broker mock, hydrate mock).""" + container = {**_container_for(rom), "label": "PCSX2"} + with _streaming(container): + with ( + patch("endpoints.streaming._call_broker") as call_broker, + patch("endpoints.streaming._push_state_file", return_value=push_ok) as push, + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(return_value=b"state-bytes"), + ), + patch("endpoints.streaming._spawn_sync_task"), + patch( + "endpoints.streaming._hydrate_states_to_broker", new=MagicMock() + ) as hydrate, + ): + r = _claim(client, token, rom.id, state_id=state_id) + return r, push, call_broker, hydrate + + +def test_claim_with_own_state_pushes_file_and_slot( + client, access_token, rom: Rom, admin_user: User +): + """A picked state is pushed before launch and its slot rides the launch + call; hydration must skip that filename so it cannot be overwritten.""" + state = db_state_handler.add_state( + _state_for(rom, admin_user, "Game.03.p2s", "pcsx2") + ) + r, push, call_broker, hydrate = _resume_claim(client, access_token, rom, state.id) + assert r.status_code == 200 + assert r.json()["resume"] is True + push.assert_called_once() + assert push.call_args[0][1] == "Game.03.p2s" + assert push.call_args[0][2] == b"state-bytes" + assert call_broker.call_args[0][3] == 3 + assert hydrate.call_args.kwargs["skip_filename"] == "Game.03.p2s" + + +def test_claim_with_other_users_public_state_allowed( + client, access_token, rom: Rom, viewer_user: User +): + """Resuming from another user's shared state is the sharing feature.""" + shared = _state_for(rom, viewer_user, "Game.02.p2s", "pcsx2") + shared.is_public = True + state = db_state_handler.add_state(shared) + r, push, call_broker, _ = _resume_claim(client, access_token, rom, state.id) + assert r.status_code == 200 + assert r.json()["resume"] is True + assert call_broker.call_args[0][3] == 2 + + +def test_claim_with_other_users_private_state_404( + client, access_token, rom: Rom, viewer_user: User +): + """Another user's private state is invisible - same as nonexistent.""" + state = db_state_handler.add_state( + _state_for(rom, viewer_user, "Game.02.p2s", "pcsx2") + ) + r, _, _, _ = _resume_claim(client, access_token, rom, state.id) + assert r.status_code == 404 + # The rejected pick must not have claimed the container. + with _streaming(_container_for(rom)): + assert _claim_ok(client, access_token, rom.id).status_code == 200 + + +def test_claim_with_wrong_emulator_state_400( + client, access_token, rom: Rom, admin_user: User +): + state = db_state_handler.add_state( + _state_for(rom, admin_user, "Game.state", "retroarch") + ) + r, _, _, _ = _resume_claim(client, access_token, rom, state.id) + assert r.status_code == 400 + + +def test_claim_with_unparseable_slot_400( + client, access_token, rom: Rom, admin_user: User +): + state = db_state_handler.add_state(_state_for(rom, admin_user, "Game.p2s", "pcsx2")) + r, _, _, _ = _resume_claim(client, access_token, rom, state.id) + assert r.status_code == 400 + + +def test_claim_failed_push_launches_fresh( + client, access_token, rom: Rom, admin_user: User +): + """A push failure must not block the session: launch without load_slot + and report resume=false so the player can tell the user.""" + state = db_state_handler.add_state( + _state_for(rom, admin_user, "Game.03.p2s", "pcsx2") + ) + r, _, call_broker, hydrate = _resume_claim( + client, access_token, rom, state.id, push_ok=False + ) + assert r.status_code == 200 + assert r.json()["resume"] is False + assert call_broker.call_args[0][3] is None + assert hydrate.call_args.kwargs["skip_filename"] is None + + +def test_claim_without_state_reports_no_resume(client, access_token, rom: Rom): + with _streaming(_container_for(rom)): + r = _claim_ok(client, access_token, rom.id) + assert r.status_code == 200 + assert r.json()["resume"] is None # ── Auth guards ─────────────────────────────────────────────────────────────── @@ -504,3 +1260,599 @@ def test_list_sessions_requires_auth(client): def test_list_sessions_requires_admin(client, viewer_access_token): r = client.get("/api/streaming/sessions", headers=_auth(viewer_access_token)) assert r.status_code == 403 + + +def test_list_sessions_returns_enriched_entries( + client, access_token, viewer_access_token, viewer_user, rom: Rom +): + """The admin list carries platform, rom and username for the release UI.""" + with _streaming(_container_for(rom)): + _claim_ok(client, viewer_access_token, rom.id) + r = client.get("/api/streaming/sessions", headers=_auth(access_token)) + assert r.status_code == 200 + sessions = r.json()["sessions"] + assert len(sessions) == 1 + entry = sessions[0] + assert entry["container"] == "http://192.168.1.10:8000" + assert entry["platform"] == rom.platform_slug + assert entry["rom_id"] == rom.id + assert entry["username"] == viewer_user.username + assert entry["claimed_at"] + + +def test_admin_can_release_other_users_session( + client, access_token, viewer_access_token, rom: Rom +): + """An admin may release a session claimed by someone else.""" + with _streaming(_container_for(rom)): + _claim_ok(client, viewer_access_token, rom.id) + with patch("endpoints.streaming._stop_broker") as stop: + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert r.json()["status"] == "released" + stop.assert_called_once() + + +# ── Whole memory-card sync ──────────────────────────────────────────────────── + + +def _mc_container_for(rom: Rom, broker_host="http://192.168.1.10:8000"): + """A container on whole-card sync, namespaced to the pcsx2 emulator.""" + return { + **_container_for(rom, broker_host), + "emulator": "pcsx2", + "memory_card_sync": True, + } + + +def _mc_claim(client, token, rom_id, memory_card_id=None): + body = {"rom_id": rom_id} + if memory_card_id is not None: + body["memory_card_id"] = memory_card_id + return client.post("/api/streaming/sessions", json=body, headers=_auth(token)) + + +def _make_card(user: User, emulator="pcsx2", name="My PS2 card", is_public=False): + return db_memory_card_handler.add_card( + MemoryCard( + user_id=user.id, + emulator=emulator, + platform_id=None, + name=name, + slot=1, + is_public=is_public, + ) + ) + + +def _card_version(card_id: int, file_name: str, content_hash: str | None): + name_no_ext, _, extension = file_name.rpartition(".") + return MemoryCardVersion( + memory_card_id=card_id, + file_name=file_name, + file_name_no_tags=name_no_ext, + file_name_no_ext=name_no_ext, + file_extension=extension, + content_hash=content_hash, + file_path=f"users/1/memory_cards/pcsx2/{card_id}", + file_size_bytes=1.0, + ) + + +def test_resolve_memory_card_explicit_owned(admin_user: User): + card = _make_card(admin_user) + resolved = streaming._resolve_memory_card(admin_user.id, "pcsx2", card.id) + assert resolved is not None + assert resolved.id == card.id + + +def test_resolve_memory_card_wrong_emulator_404(admin_user: User): + card = _make_card(admin_user, emulator="dolphin") + with pytest.raises(HTTPException) as exc: + streaming._resolve_memory_card(admin_user.id, "pcsx2", card.id) + assert exc.value.status_code == 404 + + +def test_resolve_memory_card_foreign_id_404(admin_user: User, viewer_user: User): + """An id owned by another user is not resolvable, even if public.""" + card = _make_card(viewer_user, is_public=True) + with pytest.raises(HTTPException) as exc: + streaming._resolve_memory_card(admin_user.id, "pcsx2", card.id) + assert exc.value.status_code == 404 + + +def test_resolve_memory_card_default_most_recent(admin_user: User): + older = _make_card(admin_user, name="older") + newer = _make_card(admin_user, name="newer") + # Server-default timestamps share a second, so pin the ordering explicitly. + db_memory_card_handler.update_card( + older.id, {"updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc)} + ) + db_memory_card_handler.update_card( + newer.id, {"updated_at": datetime(2026, 6, 1, tzinfo=timezone.utc)} + ) + resolved = streaming._resolve_memory_card(admin_user.id, "pcsx2", None) + assert resolved is not None + assert resolved.id == newer.id + + +def test_resolve_memory_card_none_when_user_has_no_card(admin_user: User): + """Resolution never creates rows; a cardless user resolves to None so the + claim path can defer creation until the claim is won.""" + assert db_memory_card_handler.get_cards(admin_user.id, "pcsx2") == [] + assert streaming._resolve_memory_card(admin_user.id, "pcsx2", None) is None + assert db_memory_card_handler.get_cards(admin_user.id, "pcsx2") == [] + + +def test_create_blank_memory_card(admin_user: User, rom: Rom): + """First play on an emulator with no card creates a blank owned card.""" + created = streaming._create_blank_memory_card( + admin_user.id, "pcsx2", rom.platform_id + ) + assert created.id is not None + assert created.user_id == admin_user.id + assert created.emulator == "pcsx2" + assert created.platform_id == rom.platform_id + assert created.is_public is False + assert db_memory_card_handler.get_latest_version(created.id) is None + + +def test_hydrate_memory_card_pushes_latest_version(admin_user: User, rom: Rom): + card = _make_card(admin_user) + db_memory_card_handler.add_version( + _card_version(card.id, "My PS2 card [2026-07-12 10-00-00].card.zip", "h1") + ) + with ( + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(return_value=b"card-bytes"), + ), + patch("endpoints.streaming._push_memory_card", return_value=True) as push, + ): + ok = asyncio.run( + streaming._hydrate_memory_card_to_broker( + admin_user.id, card, _mc_container_for(rom) + ) + ) + assert ok is True + assert push.call_args[0][1] == b"card-bytes" + + +def test_hydrate_blank_card_wipes_container(admin_user: User, rom: Rom): + """A card with no version pushes the empty zip so the container is wiped.""" + card = _make_card(admin_user) + with patch("endpoints.streaming._push_memory_card", return_value=True) as push: + ok = asyncio.run( + streaming._hydrate_memory_card_to_broker( + admin_user.id, card, _mc_container_for(rom) + ) + ) + assert ok is True + assert push.call_args[0][1] == streaming._EMPTY_MEMORY_CARD + + +def test_hydrate_missing_file_wipes_to_blank(admin_user: User, rom: Rom): + """A version row whose file is gone must wipe to blank, never leak.""" + card = _make_card(admin_user) + db_memory_card_handler.add_version( + _card_version(card.id, "My PS2 card [gone].card.zip", "h1") + ) + with ( + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(side_effect=FileNotFoundError), + ), + patch("endpoints.streaming._push_memory_card", return_value=True) as push, + ): + ok = asyncio.run( + streaming._hydrate_memory_card_to_broker( + admin_user.id, card, _mc_container_for(rom) + ) + ) + assert ok is True + assert push.call_args[0][1] == streaming._EMPTY_MEMORY_CARD + + +def test_hydrate_returns_false_on_push_failure(admin_user: User, rom: Rom): + card = _make_card(admin_user) + with patch("endpoints.streaming._push_memory_card", return_value=False): + ok = asyncio.run( + streaming._hydrate_memory_card_to_broker( + admin_user.id, card, _mc_container_for(rom) + ) + ) + assert ok is False + + +def test_store_memory_card_version_stores_new(admin_user: User): + card = _make_card(admin_user) + scanned = _card_version(card.id, "My PS2 card [new].card.zip", "hash-new") + with ( + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()) as wf, + patch( + "endpoints.streaming.scan_memory_card_version", + new=AsyncMock(return_value=scanned), + ), + ): + stored = asyncio.run( + streaming._store_memory_card_version( + admin_user, card, "pcsx2", b"card-bytes" + ) + ) + assert stored is True + wf.assert_awaited_once() + assert db_memory_card_handler.get_latest_version(card.id).content_hash == "hash-new" + + +def test_store_memory_card_version_dedups_identical(admin_user: User): + card = _make_card(admin_user) + db_memory_card_handler.add_version( + _card_version(card.id, "My PS2 card [old].card.zip", "dup") + ) + scanned = _card_version(card.id, "My PS2 card [new].card.zip", "dup") + with ( + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()), + patch( + "endpoints.streaming.scan_memory_card_version", + new=AsyncMock(return_value=scanned), + ), + patch( + "endpoints.streaming.fs_asset_handler.remove_file", new=AsyncMock() + ) as rm, + ): + stored = asyncio.run( + streaming._store_memory_card_version( + admin_user, card, "pcsx2", b"card-bytes" + ) + ) + assert stored is False + rm.assert_awaited_once() + assert len(db_memory_card_handler.get_versions(card.id)) == 1 + + +def test_evacuate_memory_card_stores_snapshot(admin_user: User, rom: Rom): + card = _make_card(admin_user) + with ( + patch("endpoints.streaming._fetch_memory_card", return_value=b"card-bytes"), + patch( + "endpoints.streaming._store_memory_card_version", + new=AsyncMock(return_value=True), + ) as store, + ): + ok = asyncio.run( + streaming._evacuate_memory_card( + admin_user.id, card.id, "pcsx2", _mc_container_for(rom) + ) + ) + assert ok is True + store.assert_awaited_once() + + +def test_evacuate_memory_card_confirmed_empty_is_safe_to_wipe( + admin_user: User, rom: Rom +): + """A broker-confirmed empty slot (fetch returns None) stores nothing but is + safe to wipe, so evacuation reports True.""" + card = _make_card(admin_user) + with ( + patch("endpoints.streaming._fetch_memory_card", return_value=None), + patch( + "endpoints.streaming._store_memory_card_version", new=AsyncMock() + ) as store, + ): + ok = asyncio.run( + streaming._evacuate_memory_card( + admin_user.id, card.id, "pcsx2", _mc_container_for(rom) + ) + ) + assert ok is True + store.assert_not_awaited() + + +def test_evacuate_memory_card_unavailable_is_not_safe_to_wipe( + admin_user: User, rom: Rom +): + """When the card cannot be read (endpoint missing, wrong card type, transport + error), evacuation must report False so the slot is never wiped.""" + card = _make_card(admin_user) + with ( + patch( + "endpoints.streaming._fetch_memory_card", + side_effect=streaming._MemoryCardUnavailable("boom"), + ), + patch( + "endpoints.streaming._store_memory_card_version", new=AsyncMock() + ) as store, + ): + ok = asyncio.run( + streaming._evacuate_memory_card( + admin_user.id, card.id, "pcsx2", _mc_container_for(rom) + ) + ) + assert ok is False + store.assert_not_awaited() + + +def _http_error(code: int, headers: dict[str, str] | None = None): + import http.client + import urllib.error + + hdrs = http.client.HTTPMessage() + for name, value in (headers or {}).items(): + hdrs[name] = value + return urllib.error.HTTPError("http://broker/memory-card", code, "err", hdrs, None) + + +def test_fetch_memory_card_returns_bytes(rom: Rom): + resp = MagicMock() + resp.__enter__.return_value.read.return_value = b"card-bytes" + with patch("endpoints.streaming.urllib.request.urlopen", return_value=resp): + assert streaming._fetch_memory_card(_mc_container_for(rom)) == b"card-bytes" + + +def test_fetch_memory_card_absent_header_returns_none(rom: Rom): + """A 404 tagged X-Memory-Card: absent means the slot is genuinely empty.""" + with patch( + "endpoints.streaming.urllib.request.urlopen", + side_effect=_http_error(404, {"X-Memory-Card": "absent"}), + ): + assert streaming._fetch_memory_card(_mc_container_for(rom)) is None + + +def test_fetch_memory_card_unmarked_404_raises(rom: Rom): + """An untagged 404 (endpoint missing on an old broker) must NOT be mistaken + for an empty slot; it raises so the card is never wiped.""" + with patch( + "endpoints.streaming.urllib.request.urlopen", side_effect=_http_error(404) + ): + with pytest.raises(streaming._MemoryCardUnavailable): + streaming._fetch_memory_card(_mc_container_for(rom)) + + +def test_fetch_memory_card_file_card_409_raises(rom: Rom): + with patch( + "endpoints.streaming.urllib.request.urlopen", side_effect=_http_error(409) + ): + with pytest.raises(streaming._MemoryCardUnavailable): + streaming._fetch_memory_card(_mc_container_for(rom)) + + +def test_fetch_memory_card_transport_error_raises(rom: Rom): + import urllib.error + + with patch( + "endpoints.streaming.urllib.request.urlopen", + side_effect=urllib.error.URLError("broker down"), + ): + with pytest.raises(streaming._MemoryCardUnavailable): + streaming._fetch_memory_card(_mc_container_for(rom)) + + +def test_claim_hydrates_memory_card_before_launch(client, access_token, rom: Rom): + """On a sync container the whole card hydrates before launch, and the legacy + per-file save path is skipped.""" + call_order = [] + + def _note_launch(*a, **k): + call_order.append("launch") + + def _note_card(*a, **k): + call_order.append("card") + return True + + with _streaming(_mc_container_for(rom)): + with ( + patch("endpoints.streaming._call_broker", side_effect=_note_launch), + patch( + "endpoints.streaming._hydrate_memory_card_to_broker", + new=AsyncMock(side_effect=_note_card), + ) as hydrate_card, + patch( + "endpoints.streaming._hydrate_saves_to_broker", new=AsyncMock() + ) as legacy, + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id) + assert r.status_code == 200 + hydrate_card.assert_awaited_once() + legacy.assert_not_awaited() + assert call_order == ["card", "launch"] + + +def test_claim_aborts_when_card_hydration_fails( + client, access_token, admin_user: User, rom: Rom +): + """A failed card hydration must free the claim and return 502, never launch + a container that could still hold the previous player's card. The blank card + auto-created for this claim must be cleaned up so an aborted claim leaks none.""" + assert db_memory_card_handler.get_cards(admin_user.id, "pcsx2") == [] + with _streaming(_mc_container_for(rom)): + with ( + patch("endpoints.streaming._call_broker") as launch, + patch( + "endpoints.streaming._hydrate_memory_card_to_broker", + new=AsyncMock(return_value=False), + ), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id) + assert r.status_code == 502 + launch.assert_not_called() + # The claim must be released so the container is not wedged. + assert ( + asyncio.run( + streaming._get_session(streaming._container_key(_mc_container_for(rom))) + ) + is None + ) + # No orphan blank card survives the aborted claim. + assert db_memory_card_handler.get_cards(admin_user.id, "pcsx2") == [] + + +def test_save_and_exit_evacuates_card(client, access_token, rom: Rom): + with _streaming(_mc_container_for(rom)): + with ( + patch("endpoints.streaming._call_broker"), + patch( + "endpoints.streaming._hydrate_memory_card_to_broker", + new=AsyncMock(return_value=True), + ), + patch("endpoints.streaming._spawn_sync_task"), + ): + _mc_claim(client, access_token, rom.id) + with ( + patch("endpoints.streaming._save_and_exit_broker", return_value=(True, 1)), + patch( + "endpoints.streaming._evacuate_memory_card", + new=AsyncMock(return_value=True), + ) as evac, + patch("endpoints.streaming._wipe_session_card", new=AsyncMock()) as wipe, + patch("endpoints.streaming._pull_saves_to_library") as legacy, + patch("endpoints.streaming._spawn_sync_task"), + ): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", + json={}, + headers=_auth(access_token), + ) + assert r.status_code == 200 + evac.assert_awaited_once() + # A successful evacuation wipes the slot as defense in depth. + wipe.assert_awaited_once() + legacy.assert_not_called() + + +def test_release_evacuates_card(client, access_token, rom: Rom): + with _streaming(_mc_container_for(rom)): + with ( + patch("endpoints.streaming._call_broker"), + patch( + "endpoints.streaming._hydrate_memory_card_to_broker", + new=AsyncMock(return_value=True), + ), + patch("endpoints.streaming._spawn_sync_task"), + ): + _mc_claim(client, access_token, rom.id) + with ( + patch("endpoints.streaming._stop_broker") as stop, + patch( + "endpoints.streaming._evacuate_memory_card", + new=AsyncMock(return_value=True), + ) as evac, + patch("endpoints.streaming._wipe_session_card", new=AsyncMock()) as wipe, + patch("endpoints.streaming._spawn_sync_task") as spawn, + ): + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + headers=_auth(access_token), + ) + assert r.status_code == 200 + stop.assert_called_once() + evac.assert_awaited_once() + # A successful evacuation wipes the slot as defense in depth. + wipe.assert_awaited_once() + # Legacy per-file pull must not be scheduled on a sync container. + spawn.assert_not_called() + + +def test_save_and_exit_wait_false_forces_blocking_on_card_sync( + client, access_token, rom: Rom +): + """Whole-card sync must quiesce the emulator before evacuating, so a + wait=false request still runs a blocking save+kill.""" + with _streaming(_mc_container_for(rom)): + with ( + patch("endpoints.streaming._call_broker"), + patch( + "endpoints.streaming._hydrate_memory_card_to_broker", + new=AsyncMock(return_value=True), + ), + patch("endpoints.streaming._spawn_sync_task"), + ): + _mc_claim(client, access_token, rom.id) + with ( + patch( + "endpoints.streaming._save_and_exit_broker", return_value=(True, 1) + ) as save, + patch( + "endpoints.streaming._evacuate_memory_card", + new=AsyncMock(return_value=True), + ) as evac, + patch("endpoints.streaming._wipe_session_card", new=AsyncMock()), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", + json={"wait": False}, + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert save.call_args.kwargs["wait"] is True + evac.assert_awaited_once() + + +def test_lost_claim_race_does_not_create_blank_card( + client, access_token, viewer_access_token, viewer_user: User, rom: Rom +): + """A claim that loses the SET NX race (409) must not leave an orphan blank + card behind for a user who had none.""" + with _streaming(_mc_container_for(rom)): + with ( + patch("endpoints.streaming._call_broker"), + patch( + "endpoints.streaming._hydrate_memory_card_to_broker", + new=AsyncMock(return_value=True), + ), + patch("endpoints.streaming._spawn_sync_task"), + ): + assert _mc_claim(client, access_token, rom.id).status_code == 200 + assert db_memory_card_handler.get_cards(viewer_user.id, "pcsx2") == [] + r = _mc_claim(client, viewer_access_token, rom.id) + assert r.status_code == 409 + assert db_memory_card_handler.get_cards(viewer_user.id, "pcsx2") == [] + + +# ── Playtime ────────────────────────────────────────────────────────────────── + + +def test_record_play_session_stores_duration(admin_user: User, rom: Rom): + """A finished streaming session is recorded as playtime and updates the + ROM's last_played, keyed off the stored claim timestamp.""" + start = datetime.now(timezone.utc) - timedelta(minutes=10) + session = { + "user_id": admin_user.id, + "rom_id": rom.id, + "claimed_at": start.isoformat(), + } + asyncio.run(streaming._record_play_session(session)) + + total_ms = db_play_session_handler.get_total_play_time(admin_user.id, rom.id) + # ~10 minutes, allow slack for wall-clock drift between claim and record. + assert 9 * 60_000 <= total_ms <= 11 * 60_000 + rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=admin_user.id) + assert rom_user is not None and rom_user.last_played is not None + + +def test_record_play_session_skips_accidental_short_session(admin_user: User, rom: Rom): + """A claim released almost immediately is noise, not playtime.""" + session = { + "user_id": admin_user.id, + "rom_id": rom.id, + "claimed_at": datetime.now(timezone.utc).isoformat(), + } + asyncio.run(streaming._record_play_session(session)) + assert db_play_session_handler.get_total_play_time(admin_user.id, rom.id) == 0 + + +def test_record_play_session_ignores_malformed_session(admin_user: User, rom: Rom): + """Missing rom_id / claimed_at must be a no-op, never an error.""" + asyncio.run(streaming._record_play_session({"user_id": admin_user.id})) + asyncio.run( + streaming._record_play_session( + {"user_id": admin_user.id, "rom_id": rom.id, "claimed_at": "not-a-date"} + ) + ) + assert db_play_session_handler.get_total_play_time(admin_user.id, rom.id) == 0 diff --git a/backend/tools/import_memory_card.py b/backend/tools/import_memory_card.py new file mode 100644 index 0000000000..f6b807b1ef --- /dev/null +++ b/backend/tools/import_memory_card.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Import a Slot-1 folder memory-card zip as a user's MemoryCard version. + +One-shot seed for migrating a card that already lives inside an emulator +container into the per-user whole-card model. Grab the card first with an +authenticated GET against the broker: + + curl -H "X-Broker-Secret: " \ + http://:8000/memory-card -o pcsx2-card.zip + +Then, from the backend directory on the RomM instance: + + uv run python -m tools.import_memory_card zclendenen pcsx2 pcsx2-card.zip + +Reuses the user's most-recent card for the emulator if one exists, otherwise +creates a blank card and stores the zip as its version 1. Dedupes by content +hash, so re-running with an identical card does nothing. +""" + +import asyncio +import io +import sys +import zipfile + +# isort: off +# Load the auth package first so the decorators.auth <-> handler.auth import +# cycle resolves before endpoints.streaming pulls it in (matches the module +# order the app's own entrypoint establishes at startup). Keep isort from +# reordering these below the endpoints import. +import handler.auth # noqa: F401,E402 +from endpoints.streaming import _store_memory_card_version # noqa: E402 + +# isort: on +from handler.database import db_memory_card_handler, db_user_handler +from models.assets import MemoryCard + + +async def _import(username: str, emulator: str, content: bytes) -> int: + user = db_user_handler.get_user_by_username(username) + if user is None: + print(f"error: no user named {username!r}", file=sys.stderr) + return 2 + + cards = db_memory_card_handler.get_cards(user.id, emulator) + if cards: + card = cards[0] # most-recently-updated + print(f"reusing card id={card.id} name={card.name!r}") + else: + card = db_memory_card_handler.add_card( + MemoryCard( + user_id=user.id, + emulator=emulator, + platform_id=None, + name=f"{emulator} memory card", + slot=1, + is_public=False, + ) + ) + print(f"created blank card id={card.id}") + + stored = await _store_memory_card_version(user, card, emulator, content) + latest = db_memory_card_handler.get_latest_version(card.id) + print( + f"stored={stored} card_id={card.id} " + f"latest_version={latest.file_name if latest else None} " + f"hash={latest.content_hash if latest else None}" + ) + return 0 + + +def main() -> int: + if len(sys.argv) != 4: + print( + "usage: python -m tools.import_memory_card ", + file=sys.stderr, + ) + return 2 + _, username, emulator, zip_path = sys.argv + + with open(zip_path, "rb") as fh: + content = fh.read() + if not content: + print(f"error: {zip_path} is empty", file=sys.stderr) + return 2 + # Guard against importing a non-archive: a `curl -o` of a broker 404/409 + # writes the JSON error body to the file, which must never become a card. + if not zipfile.is_zipfile(io.BytesIO(content)): + print( + f"error: {zip_path} is not a zip archive " + f"(got {content[:80]!r}); did the broker GET return an error body?", + file=sys.stderr, + ) + return 2 + + return asyncio.run(_import(username, emulator, content)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/config.example.yml b/examples/config.example.yml index 42a312eba6..89d8284acb 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -201,6 +201,11 @@ # broker_host: http://192.168.1.51:8000 # # what shows up on the play button # label: PCSX2 +# # Opt in to whole memory-card sync: the user's entire PS2 memory card is +# # hydrated onto the container at claim and evacuated back on exit, instead +# # of syncing individual in-game save files. Requires a broker that serves +# # the /memory-card endpoint. +# memory_card_sync: true # # # Add more emulator containers here as needed: # # - platform: psx diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 79b398b263..70f6e74093 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -17,18 +17,21 @@ export type { Body_add_user_api_users_post } from './models/Body_add_user_api_us export type { Body_confirm_download_api_saves__id__downloaded_post } from './models/Body_confirm_download_api_saves__id__downloaded_post'; export type { Body_create_user_from_invite_api_users_register_post } from './models/Body_create_user_from_invite_api_users_register_post'; export type { Body_delete_firmware_api_firmware_delete_post } from './models/Body_delete_firmware_api_firmware_delete_post'; +export type { Body_delete_memory_cards_api_memory_cards_delete_post } from './models/Body_delete_memory_cards_api_memory_cards_delete_post'; export type { Body_delete_roms_api_roms_delete_post } from './models/Body_delete_roms_api_roms_delete_post'; export type { Body_delete_saves_api_saves_delete_post } from './models/Body_delete_saves_api_saves_delete_post'; export type { Body_delete_states_api_states_delete_post } from './models/Body_delete_states_api_states_delete_post'; export type { Body_patch_rom_api_roms__id__patch_post } from './models/Body_patch_rom_api_roms__id__patch_post'; export type { Body_refresh_retro_achievements_api_users__id__ra_refresh_post } from './models/Body_refresh_retro_achievements_api_users__id__ra_refresh_post'; export type { Body_remove_hidden_entity_api_permissions_hidden_delete } from './models/Body_remove_hidden_entity_api_permissions_hidden_delete'; +export type { Body_rename_memory_card_api_memory_cards__id__put } from './models/Body_rename_memory_card_api_memory_cards__id__put'; export type { Body_request_password_reset_api_forgot_password_post } from './models/Body_request_password_reset_api_forgot_password_post'; export type { Body_reset_password_api_reset_password_post } from './models/Body_reset_password_api_reset_password_post'; export type { Body_token_api_token_post } from './models/Body_token_api_token_post'; export type { Body_track_save_api_saves__id__track_post } from './models/Body_track_save_api_saves__id__track_post'; export type { Body_untrack_save_api_saves__id__untrack_post } from './models/Body_untrack_save_api_saves__id__untrack_post'; export type { Body_update_collection_api_collections__id__put } from './models/Body_update_collection_api_collections__id__put'; +export type { Body_update_memory_card_visibility_api_memory_cards__id__visibility_put } from './models/Body_update_memory_card_visibility_api_memory_cards__id__visibility_put'; export type { Body_update_platform_api_platforms__id__put } from './models/Body_update_platform_api_platforms__id__put'; export type { Body_update_rom_api_roms__id__put } from './models/Body_update_rom_api_roms__id__put'; export type { Body_update_save_api_saves__id__put } from './models/Body_update_save_api_saves__id__put'; @@ -38,6 +41,7 @@ export type { Body_update_smart_collection_api_collections_smart__id__put } from export type { Body_update_state_api_states__id__put } from './models/Body_update_state_api_states__id__put'; export type { Body_update_state_visibility_api_states__id__visibility_put } from './models/Body_update_state_visibility_api_states__id__visibility_put'; export type { BulkOperationResponse } from './models/BulkOperationResponse'; +export type { ClaimSessionRequest } from './models/ClaimSessionRequest'; export type { CleanupTaskMeta } from './models/CleanupTaskMeta'; export type { CleanupTaskStatusResponse } from './models/CleanupTaskStatusResponse'; export type { ClientSaveState } from './models/ClientSaveState'; @@ -94,8 +98,12 @@ export type { IGDBRelatedGame } from './models/IGDBRelatedGame'; export type { InviteLinkSchema } from './models/InviteLinkSchema'; export type { JobStatus } from './models/JobStatus'; export type { LaunchboxImage } from './models/LaunchboxImage'; +export type { LoadStateRequest } from './models/LoadStateRequest'; export type { LogEntrySchema } from './models/LogEntrySchema'; export type { ManualMetadata } from './models/ManualMetadata'; +export type { MemoryCardCreatePayload } from './models/MemoryCardCreatePayload'; +export type { MemoryCardSchema } from './models/MemoryCardSchema'; +export type { MemoryCardVersionSchema } from './models/MemoryCardVersionSchema'; export type { MetadataCoverageItem } from './models/MetadataCoverageItem'; export type { MetadataMediaType } from './models/MetadataMediaType'; export type { MetadataSourcesDict } from './models/MetadataSourcesDict'; @@ -104,6 +112,7 @@ export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform'; export type { MusicPage_FacetValueSchema_ } from './models/MusicPage_FacetValueSchema_'; export type { MusicPage_MusicTrackSchema_ } from './models/MusicPage_MusicTrackSchema_'; export type { MusicTrackSchema } from './models/MusicTrackSchema'; +export type { MuteRequest } from './models/MuteRequest'; export type { NetplayICEServer } from './models/NetplayICEServer'; export type { OIDCDict } from './models/OIDCDict'; export type { OIDCLogoutResponse } from './models/OIDCLogoutResponse'; @@ -147,7 +156,9 @@ export type { RomUserData } from './models/RomUserData'; export type { RomUserSchema } from './models/RomUserSchema'; export type { RomUserStatus } from './models/RomUserStatus'; export type { RoomsResponse } from './models/RoomsResponse'; +export type { SaveAndExitRequest } from './models/SaveAndExitRequest'; export type { SaveSchema } from './models/SaveSchema'; +export type { SaveStateRequest } from './models/SaveStateRequest'; export type { SaveSummarySchema } from './models/SaveSummarySchema'; export type { ScanStats } from './models/ScanStats'; export type { ScanTaskMeta } from './models/ScanTaskMeta'; @@ -188,6 +199,7 @@ export type { UpdateTaskMeta } from './models/UpdateTaskMeta'; export type { UpdateTaskStatusResponse } from './models/UpdateTaskStatusResponse'; export type { UserCollectionSchema } from './models/UserCollectionSchema'; export type { UserForm } from './models/UserForm'; +export type { UserMemoryCardSchema } from './models/UserMemoryCardSchema'; export type { UserNoteSchema } from './models/UserNoteSchema'; export type { UserPermissionsSchema } from './models/UserPermissionsSchema'; export type { UserPermissionsUpdate } from './models/UserPermissionsUpdate'; @@ -197,6 +209,7 @@ export type { UserScreenshotSchema } from './models/UserScreenshotSchema'; export type { UserStateSchema } from './models/UserStateSchema'; export type { ValidationError } from './models/ValidationError'; export type { VirtualCollectionSchema } from './models/VirtualCollectionSchema'; +export type { VolumeRequest } from './models/VolumeRequest'; export type { WatcherTaskMeta } from './models/WatcherTaskMeta'; export type { WatcherTaskStatusResponse } from './models/WatcherTaskStatusResponse'; export type { WebrcadeFeedCategorySchema } from './models/WebrcadeFeedCategorySchema'; diff --git a/frontend/src/__generated__/models/Body_delete_memory_cards_api_memory_cards_delete_post.ts b/frontend/src/__generated__/models/Body_delete_memory_cards_api_memory_cards_delete_post.ts new file mode 100644 index 0000000000..66abb83579 --- /dev/null +++ b/frontend/src/__generated__/models/Body_delete_memory_cards_api_memory_cards_delete_post.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Body_delete_memory_cards_api_memory_cards_delete_post = { + /** + * List of memory card ids to delete. + */ + cards: Array; +}; + diff --git a/frontend/src/__generated__/models/Body_rename_memory_card_api_memory_cards__id__put.ts b/frontend/src/__generated__/models/Body_rename_memory_card_api_memory_cards__id__put.ts new file mode 100644 index 0000000000..2a3b70658e --- /dev/null +++ b/frontend/src/__generated__/models/Body_rename_memory_card_api_memory_cards__id__put.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Body_rename_memory_card_api_memory_cards__id__put = { + name: string; +}; + diff --git a/frontend/src/__generated__/models/Body_update_memory_card_visibility_api_memory_cards__id__visibility_put.ts b/frontend/src/__generated__/models/Body_update_memory_card_visibility_api_memory_cards__id__visibility_put.ts new file mode 100644 index 0000000000..f0ffa6845b --- /dev/null +++ b/frontend/src/__generated__/models/Body_update_memory_card_visibility_api_memory_cards__id__visibility_put.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Body_update_memory_card_visibility_api_memory_cards__id__visibility_put = { + is_public: boolean; +}; + diff --git a/frontend/src/__generated__/models/ClaimSessionRequest.ts b/frontend/src/__generated__/models/ClaimSessionRequest.ts new file mode 100644 index 0000000000..80a6192b95 --- /dev/null +++ b/frontend/src/__generated__/models/ClaimSessionRequest.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ClaimSessionRequest = { + rom_id: number; + state_id?: (number | null); + memory_card_id?: (number | null); +}; + diff --git a/frontend/src/__generated__/models/LoadStateRequest.ts b/frontend/src/__generated__/models/LoadStateRequest.ts new file mode 100644 index 0000000000..87ac77aa23 --- /dev/null +++ b/frontend/src/__generated__/models/LoadStateRequest.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type LoadStateRequest = { + slot?: number; +}; + diff --git a/frontend/src/__generated__/models/MemoryCardCreatePayload.ts b/frontend/src/__generated__/models/MemoryCardCreatePayload.ts new file mode 100644 index 0000000000..f5246071fa --- /dev/null +++ b/frontend/src/__generated__/models/MemoryCardCreatePayload.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type MemoryCardCreatePayload = { + name: string; + emulator: string; + platform_id?: (number | null); + is_public?: boolean; +}; + diff --git a/frontend/src/__generated__/models/MemoryCardSchema.ts b/frontend/src/__generated__/models/MemoryCardSchema.ts new file mode 100644 index 0000000000..dff1555389 --- /dev/null +++ b/frontend/src/__generated__/models/MemoryCardSchema.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A card's identity. Its data lives in `versions`; the list views return + * the card without them (fetch history via the versions route) so the schema + * never touches the lazy="raise" relationship. + */ +export type MemoryCardSchema = { + id: number; + user_id: number; + emulator: string; + platform_id?: (number | null); + name: string; + slot: number; + is_public?: boolean; + created_at: string; + updated_at: string; +}; + diff --git a/frontend/src/__generated__/models/MemoryCardVersionSchema.ts b/frontend/src/__generated__/models/MemoryCardVersionSchema.ts new file mode 100644 index 0000000000..d4f9a286a6 --- /dev/null +++ b/frontend/src/__generated__/models/MemoryCardVersionSchema.ts @@ -0,0 +1,20 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A single snapshot in a card's history. Unlike the ROM-scoped assets it + * has no rom_id/user_id, so it does not reuse the shared BaseAsset schema. + */ +export type MemoryCardVersionSchema = { + id: number; + memory_card_id: number; + file_name: string; + file_size_bytes: number; + content_hash?: (string | null); + download_path: string; + missing_from_fs: boolean; + created_at: string; + updated_at: string; +}; + diff --git a/frontend/src/__generated__/models/MuteRequest.ts b/frontend/src/__generated__/models/MuteRequest.ts new file mode 100644 index 0000000000..033c27ee6b --- /dev/null +++ b/frontend/src/__generated__/models/MuteRequest.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type MuteRequest = { + mute?: (boolean | null); +}; + diff --git a/frontend/src/__generated__/models/SaveAndExitRequest.ts b/frontend/src/__generated__/models/SaveAndExitRequest.ts new file mode 100644 index 0000000000..684cc2ed69 --- /dev/null +++ b/frontend/src/__generated__/models/SaveAndExitRequest.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SaveAndExitRequest = { + slot?: number; + wait?: boolean; +}; + diff --git a/frontend/src/__generated__/models/SaveStateRequest.ts b/frontend/src/__generated__/models/SaveStateRequest.ts new file mode 100644 index 0000000000..6d0ab36a92 --- /dev/null +++ b/frontend/src/__generated__/models/SaveStateRequest.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SaveStateRequest = { + slot?: number; +}; + diff --git a/frontend/src/__generated__/models/UserMemoryCardSchema.ts b/frontend/src/__generated__/models/UserMemoryCardSchema.ts new file mode 100644 index 0000000000..ec14e830b2 --- /dev/null +++ b/frontend/src/__generated__/models/UserMemoryCardSchema.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A card enriched with its owner's username, for the shared/community + * picker. Mirrors UserStateSchema. + */ +export type UserMemoryCardSchema = { + id: number; + user_id: number; + emulator: string; + platform_id?: (number | null); + name: string; + slot: number; + is_public?: boolean; + created_at: string; + updated_at: string; + username: string; + user_avatar_path?: string; + user_updated_at?: (string | null); +}; + diff --git a/frontend/src/__generated__/models/VolumeRequest.ts b/frontend/src/__generated__/models/VolumeRequest.ts new file mode 100644 index 0000000000..08cffe2889 --- /dev/null +++ b/frontend/src/__generated__/models/VolumeRequest.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type VolumeRequest = { + level: number; +}; + diff --git a/frontend/src/locales/bg_BG/activity.json b/frontend/src/locales/bg_BG/activity.json index 7a2780ae9f..857c115a74 100644 --- a/frontend/src/locales/bg_BG/activity.json +++ b/frontend/src/locales/bg_BG/activity.json @@ -10,5 +10,12 @@ "now-playing": "Сега играе", "playing-on": "Играе на {device}", "playing-since": "Играе от {time}", - "total-sessions": "Общо сесии" + "release-failed": "Сесията не можа да бъде освободена", + "release-session": "Освобождаване", + "release-session-body": "Това незабавно ще спре {game} и ще прекъсне връзката на {user}. Незапазеният напредък ще бъде загубен.", + "release-session-title": "Освобождаване на стрийминг сесията?", + "session-released": "Сесията е освободена", + "streaming-sessions": "Стрийминг сесии", + "total-sessions": "Общо сесии", + "unknown-user": "Неизвестен потребител" } diff --git a/frontend/src/locales/bg_BG/play.json b/frontend/src/locales/bg_BG/play.json index d018526b0a..64873b9fa1 100644 --- a/frontend/src/locales/bg_BG/play.json +++ b/frontend/src/locales/bg_BG/play.json @@ -1,29 +1,78 @@ { "all-saves": "Всички записи", "all-states": "Всички бързи записи", + "autosave": "Автоматично запазване", "back-to-gallery": "Към галерията", "back-to-game-details": "Към детайлите на играта", "background-color": "Цвят на фона", + "cancel-launch": "Отказ на стартирането", "change-save": "Смени записа", "change-state": "Смени бързия запис", "clear-cache": "Изчисти кеша на EmulatorJS", "clear-cache-description": "Записите и бързите записи съхранени на сървъра няма да бъдат засегнати.", "clear-cache-title": "Сигурен ли си, че искаш да изчистиш кеша на EmulatorJS?", "clear-cache-warning": "Това ще премахне всички записи и бързи записи съхранени в браузъра.", + "create-memory-card": "Нова карта памет", + "delete-memory-card": "Изтриване на карта памет", + "delete-memory-card-body": "Това ще изтрие завинаги „{name}\" и всички запазени версии. Действието е необратимо.", "deselect-save": "Отмени избрания запис", "deselect-state": "Отмени избрания бърз запис", + "emulator": "Емулатор", + "error-hint-auth": "Възможно е да нямате права за стрийминг или сесията ви да е изтекла. Опитайте да влезете отново.", + "error-hint-broker": "Контейнерът {label} отказа да стартира играта. Проверете дневниците на контейнера за подробности.", + "error-hint-network": "RomM не можа да бъде достигнат. Проверете мрежовата си връзка и дали сървърът работи.", + "error-hint-not-configured": "Добавете контейнер за тази платформа към конфигурацията за стрийминг на RomM.", + "error-hint-server": "RomM се натъкна на неочаквана грешка при стартиране на сесията. Проверете дневниците на сървъра на RomM.", + "error-hint-unreachable": "Контейнерът {label} не можа да бъде достигнат. Проверете дали контейнерът работи и дали неговият брокер слуша.", + "exit-chord-hint": "По време на игра задръжте Select + Start за момент, за да отворите менюто за изход.", + "exit-dialog-text": "Играта все още върви. Какво искате да направите?", + "exit-dialog-text-loading": "Играта все още се стартира. Да се отмени ли стартирането?", + "exit-dialog-title": "Изход от играта?", + "exit-full-screen": "Изход от цял екран", + "exit-without-saving": "Изход без запазване", "full-screen": "Цял екран", + "keep-playing": "Продължи играта", + "load-autosave": "Зареждане на автоматичното запазване", + "load-state": "Зареждане на състояние", + "manage-memory-cards": "Управление на картите памет", + "memory-card": "Карта памет", + "memory-card-count": "{count} карти", + "memory-card-create-failed": "Картата памет не може да бъде създадена", + "memory-card-created": "Картата памет е създадена", + "memory-card-delete-failed": "Картата памет не може да бъде изтрита", + "memory-card-deleted": "Картата памет е изтрита", + "memory-card-hint": "По подразбиране се зарежда най-новата карта. Напредъкът се синхронизира при изход.", + "memory-card-no-versions": "Все още няма запазени версии", + "memory-card-rename-failed": "Картата памет не може да бъде преименувана", + "memory-card-renamed": "Картата памет е преименувана", + "memory-card-share-failed": "Споделянето на картата памет не може да бъде променено", + "memory-card-share-label": "Споделена с други потребители", + "memory-card-shared": "Споделена", + "memory-card-updated": "Обновена {when}", + "memory-card-versions": "История на версиите", + "memory-cards": "Карти памет", + "memory-cards-empty": "Все още нямате карти памет за този емулатор.", + "mute": "Заглушаване", + "new-memory-card": "Нова карта", + "no-memory-cards": "Все още няма карти памет", "no-save-selected": "Няма избран запис", "no-saves-available": "Няма налични записи", "no-screenshot-available": "Няма налична екранна снимка", "no-state-selected": "Няма избран бърз запис", "no-states-available": "Няма налични бързи записи", + "not-supported": "Не се поддържа", "play": "Играй", + "play-on": "Игра на {label}", "powered-by": "Захранено от", "quit": "Излез", + "rename-memory-card": "Преименуване на карта памет", + "resume-failed": "Избраното състояние не можа да бъде заредено. Играта започна отначало.", "resume-from-save": "Продължи от запис", "resume-from-state": "Продължи от бърз запис", "save-and-quit": "Запази и излез", + "save-slot": "Слот за запазване", + "save-slots": "Слотове за запазване", + "save-state": "Запазване на състояние", "select-background-color": "Избери цвят на фона", "select-save": "Избери запис", "select-state": "Избери бърз запис", @@ -52,5 +101,6 @@ "stream-try-again": "Опитайте отново", "stream-unknown-game": "Неизвестна игра", "stream-unmute": "Включване на звука", - "stream-volume": "Сила на звука" + "stream-volume": "Сила на звука", + "streaming-description": "Играта работи в специален контейнер {label} и се предава директно към вашия браузър." } diff --git a/frontend/src/locales/cs_CZ/activity.json b/frontend/src/locales/cs_CZ/activity.json index b4d9ff2ab8..941c0595db 100644 --- a/frontend/src/locales/cs_CZ/activity.json +++ b/frontend/src/locales/cs_CZ/activity.json @@ -10,5 +10,12 @@ "now-playing": "Právě hraje", "playing-on": "Hraje na {device}", "playing-since": "Hraje od {time}", - "total-sessions": "Celkem relací" + "release-failed": "Relaci se nepodařilo uvolnit", + "release-session": "Uvolnit", + "release-session-body": "Tímto se okamžitě zastaví {game} a odpojí se {user}. Neuložený postup bude ztracen.", + "release-session-title": "Uvolnit streamovací relaci?", + "session-released": "Relace uvolněna", + "streaming-sessions": "Streamovací relace", + "total-sessions": "Celkem relací", + "unknown-user": "Neznámý uživatel" } diff --git a/frontend/src/locales/cs_CZ/play.json b/frontend/src/locales/cs_CZ/play.json index d867faef14..99145ef9df 100644 --- a/frontend/src/locales/cs_CZ/play.json +++ b/frontend/src/locales/cs_CZ/play.json @@ -1,29 +1,78 @@ { "all-saves": "Všechny uložené pozice", "all-states": "Všechny stavy", + "autosave": "Automatické ukládání", "back-to-gallery": "Zpět do galerie", "back-to-game-details": "Zpět k detailům hry", "background-color": "Barva pozadí", + "cancel-launch": "Zrušit spuštění", "change-save": "Změnit uloženou pozici", "change-state": "Změnit stav", "clear-cache": "Vymazat cache EmulatorJS", "clear-cache-description": "Uložené pozice nebo stavy na serveru nebudou ovlivněny.", "clear-cache-title": "Opravdu chcete vymazat cache EmulatorJS?", "clear-cache-warning": "Tímto se odstraní všechny uložené pozice a stavy uložené v prohlížeči.", + "create-memory-card": "Nová paměťová karta", + "delete-memory-card": "Odstranit paměťovou kartu", + "delete-memory-card-body": "Tímto trvale odstraníte „{name}\" a všechny její uložené verze. Tuto akci nelze vrátit zpět.", "deselect-save": "Zrušit výběr uložené pozice", "deselect-state": "Zrušit výběr stavu", + "emulator": "Emulátor", + "error-hint-auth": "Možná nemáte oprávnění ke streamování nebo vaše relace vypršela. Zkuste se přihlásit znovu.", + "error-hint-broker": "Kontejner {label} odmítl spustit hru. Podrobnosti najdete v protokolech kontejneru.", + "error-hint-network": "RomM se nepodařilo kontaktovat. Zkontrolujte síťové připojení a zda server běží.", + "error-hint-not-configured": "Přidejte kontejner pro tuto platformu do konfigurace streamování RomM.", + "error-hint-server": "V RomM došlo při spouštění relace k neočekávané chybě. Zkontrolujte protokoly serveru RomM.", + "error-hint-unreachable": "Kontejner {label} se nepodařilo kontaktovat. Zkontrolujte, zda kontejner běží a zda jeho broker naslouchá.", + "exit-chord-hint": "Během hry podržte chvíli Select + Start pro otevření nabídky ukončení.", + "exit-dialog-text": "Hra stále běží. Co chcete udělat?", + "exit-dialog-text-loading": "Hra se stále spouští. Zrušit spuštění?", + "exit-dialog-title": "Ukončit hru?", + "exit-full-screen": "Ukončit celou obrazovku", + "exit-without-saving": "Ukončit bez uložení", "full-screen": "Celá obrazovka", + "keep-playing": "Pokračovat ve hře", + "load-autosave": "Načíst automatické uložení", + "load-state": "Načíst stav", + "manage-memory-cards": "Spravovat paměťové karty", + "memory-card": "Paměťová karta", + "memory-card-count": "{count} karet", + "memory-card-create-failed": "Paměťovou kartu se nepodařilo vytvořit", + "memory-card-created": "Paměťová karta vytvořena", + "memory-card-delete-failed": "Paměťovou kartu se nepodařilo odstranit", + "memory-card-deleted": "Paměťová karta odstraněna", + "memory-card-hint": "Ve výchozím nastavení se načte nejnovější karta. Postup se synchronizuje při ukončení.", + "memory-card-no-versions": "Zatím žádné uložené verze", + "memory-card-rename-failed": "Paměťovou kartu se nepodařilo přejmenovat", + "memory-card-renamed": "Paměťová karta přejmenována", + "memory-card-share-failed": "Sdílení paměťové karty se nepodařilo změnit", + "memory-card-share-label": "Sdíleno s ostatními uživateli", + "memory-card-shared": "Sdíleno", + "memory-card-updated": "Aktualizováno {when}", + "memory-card-versions": "Historie verzí", + "memory-cards": "Paměťové karty", + "memory-cards-empty": "Pro tento emulátor zatím nemáte žádné paměťové karty.", + "mute": "Ztlumit", + "new-memory-card": "Nová karta", + "no-memory-cards": "Zatím žádné paměťové karty", "no-save-selected": "Není vybrána žádná uložená pozice", "no-saves-available": "Nejsou k dispozici žádné uložené pozice", "no-screenshot-available": "Žádný screenshot není k dispozici", "no-state-selected": "Není vybrán žádný stav", "no-states-available": "Nejsou k dispozici žádné stavy", + "not-supported": "Nepodporováno", "play": "Hrát", + "play-on": "Hrát na {label}", "powered-by": "Poháněno", "quit": "Ukončit", + "rename-memory-card": "Přejmenovat paměťovou kartu", + "resume-failed": "Vybraný stav se nepodařilo načíst. Hra začala od začátku.", "resume-from-save": "Pokračovat z uložené pozice", "resume-from-state": "Pokračovat ze stavu", "save-and-quit": "Uložit a ukončit", + "save-slot": "Pozice uložení", + "save-slots": "Pozice uložení", + "save-state": "Uložit stav", "select-background-color": "Vybrat barvu pozadí", "select-save": "Vybrat uloženou pozici", "select-state": "Vybrat stav", @@ -52,5 +101,6 @@ "stream-try-again": "Zkusit znovu", "stream-unknown-game": "Neznámá hra", "stream-unmute": "Zrušit ztlumení", - "stream-volume": "Hlasitost" + "stream-volume": "Hlasitost", + "streaming-description": "Hra běží ve vyhrazeném kontejneru {label} a streamuje se přímo do vašeho prohlížeče." } diff --git a/frontend/src/locales/de_DE/activity.json b/frontend/src/locales/de_DE/activity.json index 48eca97d1f..96fe566770 100644 --- a/frontend/src/locales/de_DE/activity.json +++ b/frontend/src/locales/de_DE/activity.json @@ -10,5 +10,12 @@ "now-playing": "Wird gespielt", "playing-on": "Spielt auf {device}", "playing-since": "Spielt seit {time}", - "total-sessions": "Sitzungen gesamt" + "release-failed": "Sitzung konnte nicht freigegeben werden", + "release-session": "Freigeben", + "release-session-body": "Dadurch wird {game} sofort beendet und {user} getrennt. Nicht gespeicherter Fortschritt geht verloren.", + "release-session-title": "Streaming-Sitzung freigeben?", + "session-released": "Sitzung freigegeben", + "streaming-sessions": "Streaming-Sitzungen", + "total-sessions": "Sitzungen gesamt", + "unknown-user": "Unbekannter Benutzer" } diff --git a/frontend/src/locales/de_DE/play.json b/frontend/src/locales/de_DE/play.json index 6fe61bfdff..0a13540289 100644 --- a/frontend/src/locales/de_DE/play.json +++ b/frontend/src/locales/de_DE/play.json @@ -1,29 +1,78 @@ { "all-saves": "Alle Spielstände", "all-states": "Alle Zustände", + "autosave": "Automatisches Speichern", "back-to-gallery": "Zurück zur Plattformübersicht", "back-to-game-details": "Zurück zu den Spieldetails", "background-color": "Hintergrundfarbe", + "cancel-launch": "Start abbrechen", "change-save": "Speicherstand ändern", "change-state": "Speicherstand ändern", "clear-cache": "EmulatorJS-Cache löschen", "clear-cache-description": "Es hat keine Auswirkungen auf Spielstände und Speicherungen, die auf dem Server gespeichert sind.", "clear-cache-title": "Möchtest du den EmulatorJS-Cache wirklich löschen?", "clear-cache-warning": "Dadurch werden alle im Browser gespeicherten Spielstände und Speicherungen entfernt.", + "create-memory-card": "Neue Speicherkarte", + "delete-memory-card": "Speicherkarte löschen", + "delete-memory-card-body": "Dadurch werden „{name}\" und alle gespeicherten Versionen davon dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", "deselect-save": "Speicherstand abwählen", "deselect-state": "Speicherstand abwählen", + "emulator": "Emulator", + "error-hint-auth": "Möglicherweise hast du keine Berechtigung zum Streamen oder deine Sitzung ist abgelaufen. Versuche, dich erneut anzumelden.", + "error-hint-broker": "Der {label}-Container hat den Start des Spiels abgelehnt. Prüfe die Container-Protokolle für Details.", + "error-hint-network": "RomM konnte nicht erreicht werden. Prüfe deine Netzwerkverbindung und ob der Server läuft.", + "error-hint-not-configured": "Füge der Streaming-Konfiguration von RomM einen Container für diese Plattform hinzu.", + "error-hint-server": "Bei RomM ist beim Starten der Sitzung ein unerwarteter Fehler aufgetreten. Prüfe die RomM-Serverprotokolle.", + "error-hint-unreachable": "Der {label}-Container konnte nicht erreicht werden. Prüfe, ob der Container läuft und sein Broker erreichbar ist.", + "exit-chord-hint": "Halte während des Spiels Select + Start einen Moment gedrückt, um das Beenden-Menü zu öffnen.", + "exit-dialog-text": "Das Spiel läuft noch. Was möchtest du tun?", + "exit-dialog-text-loading": "Das Spiel startet noch. Start abbrechen?", + "exit-dialog-title": "Spiel beenden?", + "exit-full-screen": "Vollbild beenden", + "exit-without-saving": "Beenden ohne Speichern", "full-screen": "Vollbild", + "keep-playing": "Weiterspielen", + "load-autosave": "Automatische Speicherung laden", + "load-state": "Spielstand laden", + "manage-memory-cards": "Speicherkarten verwalten", + "memory-card": "Speicherkarte", + "memory-card-count": "{count} Karten", + "memory-card-create-failed": "Speicherkarte konnte nicht erstellt werden", + "memory-card-created": "Speicherkarte erstellt", + "memory-card-delete-failed": "Speicherkarte konnte nicht gelöscht werden", + "memory-card-deleted": "Speicherkarte gelöscht", + "memory-card-hint": "Die neueste Karte wird standardmäßig geladen. Der Fortschritt wird beim Beenden zurücksynchronisiert.", + "memory-card-no-versions": "Noch keine gespeicherten Versionen", + "memory-card-rename-failed": "Speicherkarte konnte nicht umbenannt werden", + "memory-card-renamed": "Speicherkarte umbenannt", + "memory-card-share-failed": "Freigabe der Speicherkarte konnte nicht geändert werden", + "memory-card-share-label": "Mit anderen Benutzern geteilt", + "memory-card-shared": "Geteilt", + "memory-card-updated": "Aktualisiert {when}", + "memory-card-versions": "Versionsverlauf", + "memory-cards": "Speicherkarten", + "memory-cards-empty": "Du hast noch keine Speicherkarten für diesen Emulator.", + "mute": "Stummschalten", + "new-memory-card": "Neue Karte", + "no-memory-cards": "Noch keine Speicherkarten", "no-save-selected": "Kein Speicherstand ausgewählt", "no-saves-available": "Keine Speicherstände verfügbar", "no-screenshot-available": "Kein Screenshot verfügbar", "no-state-selected": "Kein Zustand ausgewählt", "no-states-available": "Keine Zustände verfügbar", + "not-supported": "Nicht unterstützt", "play": "Spielen", + "play-on": "Auf {label} spielen", "powered-by": "Bereitgestellt von", "quit": "Beenden", + "rename-memory-card": "Speicherkarte umbenennen", + "resume-failed": "Der ausgewählte Spielstand konnte nicht geladen werden. Das Spiel wurde neu gestartet.", "resume-from-save": "Vom Spielstand fortsetzen", "resume-from-state": "Vom Zustand fortsetzen", "save-and-quit": "Speichern und beenden", + "save-slot": "Speicherplatz", + "save-slots": "Speicherplätze", + "save-state": "Spielstand speichern", "select-background-color": "Hintergrundfarbe auswählen", "select-save": "Speicherstand auswählen", "select-state": "Speicherstand auswählen", @@ -52,5 +101,6 @@ "stream-try-again": "Erneut versuchen", "stream-unknown-game": "Unbekanntes Spiel", "stream-unmute": "Stummschaltung aufheben", - "stream-volume": "Lautstärke" + "stream-volume": "Lautstärke", + "streaming-description": "Das Spiel läuft in einem dedizierten {label}-Container und wird direkt in deinen Browser gestreamt." } diff --git a/frontend/src/locales/en_GB/activity.json b/frontend/src/locales/en_GB/activity.json index e9af67bf1b..cd80e564f4 100644 --- a/frontend/src/locales/en_GB/activity.json +++ b/frontend/src/locales/en_GB/activity.json @@ -10,5 +10,12 @@ "now-playing": "Now Playing", "playing-on": "Playing on {device}", "playing-since": "Playing since {time}", - "total-sessions": "Total sessions" + "release-failed": "Could not release the session", + "release-session": "Release", + "release-session-body": "This will stop {game} immediately and disconnect {user}. Unsaved progress will be lost.", + "release-session-title": "Release streaming session?", + "session-released": "Session released", + "streaming-sessions": "Streaming sessions", + "total-sessions": "Total sessions", + "unknown-user": "Unknown user" } diff --git a/frontend/src/locales/en_GB/play.json b/frontend/src/locales/en_GB/play.json index aa78dfaece..5baf3f876c 100644 --- a/frontend/src/locales/en_GB/play.json +++ b/frontend/src/locales/en_GB/play.json @@ -1,29 +1,78 @@ { "all-saves": "All saves", "all-states": "All states", + "autosave": "Autosave", "back-to-gallery": "Back to gallery", "back-to-game-details": "Back to game details", "background-color": "Background color", + "cancel-launch": "Cancel launch", "change-save": "Change save", "change-state": "Change state", "clear-cache": "Clear EmulatorJS Cache", "clear-cache-description": "Any saves or states stored on the server will not be affected.", "clear-cache-title": "Are you sure you want to clear the EmulatorJS cache?", "clear-cache-warning": "This will remove all saves and states stored in the browser.", + "create-memory-card": "New memory card", + "delete-memory-card": "Delete memory card", + "delete-memory-card-body": "This permanently deletes \"{name}\" and every saved version of it. This cannot be undone.", "deselect-save": "Deselect save", "deselect-state": "Deselect state", + "emulator": "Emulator", + "error-hint-auth": "You may not have permission to stream, or your login session expired. Try signing in again.", + "error-hint-broker": "The {label} container refused to launch the game. Check the container logs for details.", + "error-hint-network": "RomM could not be reached. Check your network connection and that the server is running.", + "error-hint-not-configured": "Add a container for this platform to RomM's streaming configuration.", + "error-hint-server": "RomM hit an unexpected error while starting the session. Check the RomM server logs.", + "error-hint-unreachable": "The {label} container could not be reached. Check that the container is running and that its broker is listening.", + "exit-chord-hint": "While playing, hold Select + Start for a moment to open the exit menu.", + "exit-dialog-text": "The game is still running. What do you want to do?", + "exit-dialog-text-loading": "The game is still starting. Cancel the launch?", + "exit-dialog-title": "Exit game?", + "exit-full-screen": "Exit full screen", + "exit-without-saving": "Exit without saving", "full-screen": "Full screen", + "keep-playing": "Keep playing", + "load-autosave": "Load autosave", + "load-state": "Load state", + "manage-memory-cards": "Manage memory cards", + "memory-card": "Memory card", + "memory-card-count": "{count} cards", + "memory-card-create-failed": "Could not create the memory card", + "memory-card-created": "Memory card created", + "memory-card-delete-failed": "Could not delete the memory card", + "memory-card-deleted": "Memory card deleted", + "memory-card-hint": "The newest card loads by default. Progress syncs back when you exit.", + "memory-card-no-versions": "No saved versions yet", + "memory-card-rename-failed": "Could not rename the memory card", + "memory-card-renamed": "Memory card renamed", + "memory-card-share-failed": "Could not change sharing for the memory card", + "memory-card-share-label": "Shared with other users", + "memory-card-shared": "Shared", + "memory-card-updated": "Updated {when}", + "memory-card-versions": "Version history", + "memory-cards": "Memory cards", + "memory-cards-empty": "You don't have any memory cards for this emulator yet.", + "mute": "Mute", + "new-memory-card": "New card", + "no-memory-cards": "No memory cards yet", "no-save-selected": "No save selected", "no-saves-available": "No saves available", "no-screenshot-available": "No screenshot available", "no-state-selected": "No state selected", "no-states-available": "No states available", + "not-supported": "Not supported", "play": "Play", + "play-on": "Play on {label}", "powered-by": "Powered by", "quit": "Quit", + "rename-memory-card": "Rename memory card", + "resume-failed": "Could not load the selected state. The game started fresh.", "resume-from-save": "Resume from save", "resume-from-state": "Resume from state", "save-and-quit": "Save and quit", + "save-slot": "Save slot", + "save-slots": "Save slots", + "save-state": "Save state", "select-background-color": "Select background color", "select-save": "Select save", "select-state": "Select state", @@ -52,5 +101,6 @@ "stream-try-again": "Try Again", "stream-unknown-game": "Unknown Game", "stream-unmute": "Unmute", - "stream-volume": "Volume" + "stream-volume": "Volume", + "streaming-description": "The game runs in a dedicated {label} container and streams straight to your browser." } diff --git a/frontend/src/locales/en_US/activity.json b/frontend/src/locales/en_US/activity.json index e9af67bf1b..cd80e564f4 100644 --- a/frontend/src/locales/en_US/activity.json +++ b/frontend/src/locales/en_US/activity.json @@ -10,5 +10,12 @@ "now-playing": "Now Playing", "playing-on": "Playing on {device}", "playing-since": "Playing since {time}", - "total-sessions": "Total sessions" + "release-failed": "Could not release the session", + "release-session": "Release", + "release-session-body": "This will stop {game} immediately and disconnect {user}. Unsaved progress will be lost.", + "release-session-title": "Release streaming session?", + "session-released": "Session released", + "streaming-sessions": "Streaming sessions", + "total-sessions": "Total sessions", + "unknown-user": "Unknown user" } diff --git a/frontend/src/locales/en_US/play.json b/frontend/src/locales/en_US/play.json index aa78dfaece..5baf3f876c 100644 --- a/frontend/src/locales/en_US/play.json +++ b/frontend/src/locales/en_US/play.json @@ -1,29 +1,78 @@ { "all-saves": "All saves", "all-states": "All states", + "autosave": "Autosave", "back-to-gallery": "Back to gallery", "back-to-game-details": "Back to game details", "background-color": "Background color", + "cancel-launch": "Cancel launch", "change-save": "Change save", "change-state": "Change state", "clear-cache": "Clear EmulatorJS Cache", "clear-cache-description": "Any saves or states stored on the server will not be affected.", "clear-cache-title": "Are you sure you want to clear the EmulatorJS cache?", "clear-cache-warning": "This will remove all saves and states stored in the browser.", + "create-memory-card": "New memory card", + "delete-memory-card": "Delete memory card", + "delete-memory-card-body": "This permanently deletes \"{name}\" and every saved version of it. This cannot be undone.", "deselect-save": "Deselect save", "deselect-state": "Deselect state", + "emulator": "Emulator", + "error-hint-auth": "You may not have permission to stream, or your login session expired. Try signing in again.", + "error-hint-broker": "The {label} container refused to launch the game. Check the container logs for details.", + "error-hint-network": "RomM could not be reached. Check your network connection and that the server is running.", + "error-hint-not-configured": "Add a container for this platform to RomM's streaming configuration.", + "error-hint-server": "RomM hit an unexpected error while starting the session. Check the RomM server logs.", + "error-hint-unreachable": "The {label} container could not be reached. Check that the container is running and that its broker is listening.", + "exit-chord-hint": "While playing, hold Select + Start for a moment to open the exit menu.", + "exit-dialog-text": "The game is still running. What do you want to do?", + "exit-dialog-text-loading": "The game is still starting. Cancel the launch?", + "exit-dialog-title": "Exit game?", + "exit-full-screen": "Exit full screen", + "exit-without-saving": "Exit without saving", "full-screen": "Full screen", + "keep-playing": "Keep playing", + "load-autosave": "Load autosave", + "load-state": "Load state", + "manage-memory-cards": "Manage memory cards", + "memory-card": "Memory card", + "memory-card-count": "{count} cards", + "memory-card-create-failed": "Could not create the memory card", + "memory-card-created": "Memory card created", + "memory-card-delete-failed": "Could not delete the memory card", + "memory-card-deleted": "Memory card deleted", + "memory-card-hint": "The newest card loads by default. Progress syncs back when you exit.", + "memory-card-no-versions": "No saved versions yet", + "memory-card-rename-failed": "Could not rename the memory card", + "memory-card-renamed": "Memory card renamed", + "memory-card-share-failed": "Could not change sharing for the memory card", + "memory-card-share-label": "Shared with other users", + "memory-card-shared": "Shared", + "memory-card-updated": "Updated {when}", + "memory-card-versions": "Version history", + "memory-cards": "Memory cards", + "memory-cards-empty": "You don't have any memory cards for this emulator yet.", + "mute": "Mute", + "new-memory-card": "New card", + "no-memory-cards": "No memory cards yet", "no-save-selected": "No save selected", "no-saves-available": "No saves available", "no-screenshot-available": "No screenshot available", "no-state-selected": "No state selected", "no-states-available": "No states available", + "not-supported": "Not supported", "play": "Play", + "play-on": "Play on {label}", "powered-by": "Powered by", "quit": "Quit", + "rename-memory-card": "Rename memory card", + "resume-failed": "Could not load the selected state. The game started fresh.", "resume-from-save": "Resume from save", "resume-from-state": "Resume from state", "save-and-quit": "Save and quit", + "save-slot": "Save slot", + "save-slots": "Save slots", + "save-state": "Save state", "select-background-color": "Select background color", "select-save": "Select save", "select-state": "Select state", @@ -52,5 +101,6 @@ "stream-try-again": "Try Again", "stream-unknown-game": "Unknown Game", "stream-unmute": "Unmute", - "stream-volume": "Volume" + "stream-volume": "Volume", + "streaming-description": "The game runs in a dedicated {label} container and streams straight to your browser." } diff --git a/frontend/src/locales/es_ES/activity.json b/frontend/src/locales/es_ES/activity.json index 40bb0676ca..2f70cb05f6 100644 --- a/frontend/src/locales/es_ES/activity.json +++ b/frontend/src/locales/es_ES/activity.json @@ -10,5 +10,12 @@ "now-playing": "Jugando ahora", "playing-on": "Jugando en {device}", "playing-since": "Jugando desde {time}", - "total-sessions": "Sesiones totales" + "release-failed": "No se pudo liberar la sesión", + "release-session": "Liberar", + "release-session-body": "Esto detendrá {game} inmediatamente y desconectará a {user}. El progreso no guardado se perderá.", + "release-session-title": "¿Liberar la sesión de streaming?", + "session-released": "Sesión liberada", + "streaming-sessions": "Sesiones de streaming", + "total-sessions": "Sesiones totales", + "unknown-user": "Usuario desconocido" } diff --git a/frontend/src/locales/es_ES/play.json b/frontend/src/locales/es_ES/play.json index e1f1e41107..be7ded6066 100644 --- a/frontend/src/locales/es_ES/play.json +++ b/frontend/src/locales/es_ES/play.json @@ -1,29 +1,78 @@ { "all-saves": "Todas las partidas", "all-states": "Todos los estados", + "autosave": "Guardado automático", "back-to-gallery": "Volver a galería", "back-to-game-details": "Volver a detalles", "background-color": "Color de fondo", + "cancel-launch": "Cancelar inicio", "change-save": "Cambiar guardado", "change-state": "Cambiar estado", "clear-cache": "Limpiar caché de EmulatorJS", "clear-cache-description": "No afectará a las partidas guardadas o estados almacenados en el servidor.", "clear-cache-title": "¿Estás seguro de que quieres limpiar la caché de EmulatorJS?", "clear-cache-warning": "Esto eliminará todas las partidas y estados almacenados en el navegador.", + "create-memory-card": "Nueva tarjeta de memoria", + "delete-memory-card": "Eliminar tarjeta de memoria", + "delete-memory-card-body": "Esto elimina permanentemente \"{name}\" y todas sus versiones guardadas. Esta acción no se puede deshacer.", "deselect-save": "Deseleccionar guardado", "deselect-state": "Deseleccionar estado", + "emulator": "Emulador", + "error-hint-auth": "Puede que no tengas permiso para transmitir o que tu sesión haya expirado. Intenta iniciar sesión de nuevo.", + "error-hint-broker": "El contenedor {label} rechazó iniciar el juego. Consulta los registros del contenedor para más detalles.", + "error-hint-network": "No se pudo contactar con RomM. Comprueba tu conexión de red y que el servidor esté en funcionamiento.", + "error-hint-not-configured": "Añade un contenedor para esta plataforma a la configuración de streaming de RomM.", + "error-hint-server": "RomM sufrió un error inesperado al iniciar la sesión. Consulta los registros del servidor de RomM.", + "error-hint-unreachable": "No se pudo contactar con el contenedor {label}. Comprueba que el contenedor esté en ejecución y que su broker esté escuchando.", + "exit-chord-hint": "Mientras juegas, mantén pulsados Select + Start un momento para abrir el menú de salida.", + "exit-dialog-text": "El juego sigue en marcha. ¿Qué quieres hacer?", + "exit-dialog-text-loading": "El juego todavía se está iniciando. ¿Cancelar el inicio?", + "exit-dialog-title": "¿Salir del juego?", + "exit-full-screen": "Salir de pantalla completa", + "exit-without-saving": "Salir sin guardar", "full-screen": "Pantalla completa", + "keep-playing": "Seguir jugando", + "load-autosave": "Cargar guardado automático", + "load-state": "Cargar estado", + "manage-memory-cards": "Gestionar tarjetas de memoria", + "memory-card": "Tarjeta de memoria", + "memory-card-count": "{count} tarjetas", + "memory-card-create-failed": "No se pudo crear la tarjeta de memoria", + "memory-card-created": "Tarjeta de memoria creada", + "memory-card-delete-failed": "No se pudo eliminar la tarjeta de memoria", + "memory-card-deleted": "Tarjeta de memoria eliminada", + "memory-card-hint": "La tarjeta más reciente se carga de forma predeterminada. El progreso se sincroniza al salir.", + "memory-card-no-versions": "Aún no hay versiones guardadas", + "memory-card-rename-failed": "No se pudo renombrar la tarjeta de memoria", + "memory-card-renamed": "Tarjeta de memoria renombrada", + "memory-card-share-failed": "No se pudo cambiar el uso compartido de la tarjeta de memoria", + "memory-card-share-label": "Compartida con otros usuarios", + "memory-card-shared": "Compartida", + "memory-card-updated": "Actualizada {when}", + "memory-card-versions": "Historial de versiones", + "memory-cards": "Tarjetas de memoria", + "memory-cards-empty": "Todavía no tienes ninguna tarjeta de memoria para este emulador.", + "mute": "Silenciar", + "new-memory-card": "Nueva tarjeta", + "no-memory-cards": "Aún no hay tarjetas de memoria", "no-save-selected": "Ningún guardado seleccionado", "no-saves-available": "No hay guardados disponibles", "no-screenshot-available": "Captura no disponible", "no-state-selected": "Ningún estado seleccionado", "no-states-available": "No hay estados disponibles", + "not-supported": "No compatible", "play": "Jugar", + "play-on": "Jugar en {label}", "powered-by": "Con la tecnología de", "quit": "Salir", + "rename-memory-card": "Renombrar tarjeta de memoria", + "resume-failed": "No se pudo cargar el estado seleccionado. La partida comenzó desde cero.", "resume-from-save": "Continuar desde partida guardada", "resume-from-state": "Continuar desde estado", "save-and-quit": "Guardar y salir", + "save-slot": "Ranura de guardado", + "save-slots": "Ranuras de guardado", + "save-state": "Guardar estado", "select-background-color": "Seleccionar color de fondo", "select-save": "Seleccionar guardado", "select-state": "Seleccionar estado", @@ -52,5 +101,6 @@ "stream-try-again": "Intentar de nuevo", "stream-unknown-game": "Juego desconocido", "stream-unmute": "Activar sonido", - "stream-volume": "Volumen" + "stream-volume": "Volumen", + "streaming-description": "El juego se ejecuta en un contenedor {label} dedicado y se transmite directamente a tu navegador." } diff --git a/frontend/src/locales/fr_FR/activity.json b/frontend/src/locales/fr_FR/activity.json index 7beffc28f4..42bb7ed59f 100644 --- a/frontend/src/locales/fr_FR/activity.json +++ b/frontend/src/locales/fr_FR/activity.json @@ -10,5 +10,12 @@ "now-playing": "En cours de jeu", "playing-on": "Joue sur {device}", "playing-since": "Joue depuis {time}", - "total-sessions": "Sessions totales" + "release-failed": "Impossible de libérer la session", + "release-session": "Libérer", + "release-session-body": "Cela arrêtera {game} immédiatement et déconnectera {user}. La progression non sauvegardée sera perdue.", + "release-session-title": "Libérer la session de streaming ?", + "session-released": "Session libérée", + "streaming-sessions": "Sessions de streaming", + "total-sessions": "Sessions totales", + "unknown-user": "Utilisateur inconnu" } diff --git a/frontend/src/locales/fr_FR/play.json b/frontend/src/locales/fr_FR/play.json index cd10864948..120b520e26 100644 --- a/frontend/src/locales/fr_FR/play.json +++ b/frontend/src/locales/fr_FR/play.json @@ -1,29 +1,78 @@ { "all-saves": "Toutes les sauvegardes", "all-states": "Tous les états", + "autosave": "Sauvegarde automatique", "back-to-gallery": "Retour à la galerie", "back-to-game-details": "Retour aux détails du jeu", "background-color": "Couleur d'arrière-plan", + "cancel-launch": "Annuler le lancement", "change-save": "Changer la sauvegarde", "change-state": "Changer l'état", "clear-cache": "Effacer le cache EmulatorJS", "clear-cache-description": "Les sauvegardes ou les états stockés sur le serveur ne seront pas affectés.", "clear-cache-title": "Êtes-vous sûr de vouloir effacer le cache EmulatorJS ?", "clear-cache-warning": "Cela supprimera toutes les sauvegardes et les états stockés dans le navigateur.", + "create-memory-card": "Nouvelle carte mémoire", + "delete-memory-card": "Supprimer la carte mémoire", + "delete-memory-card-body": "Cela supprime définitivement « {name} » et toutes ses versions enregistrées. Cette action est irréversible.", "deselect-save": "Désélectionner la sauvegarde", "deselect-state": "Désélectionner l'état", + "emulator": "Émulateur", + "error-hint-auth": "Vous n'avez peut-être pas l'autorisation de streamer, ou votre session a expiré. Essayez de vous reconnecter.", + "error-hint-broker": "Le conteneur {label} a refusé de lancer le jeu. Consultez les journaux du conteneur pour plus de détails.", + "error-hint-network": "RomM n'a pas pu être contacté. Vérifiez votre connexion réseau et que le serveur est en cours d'exécution.", + "error-hint-not-configured": "Ajoutez un conteneur pour cette plateforme à la configuration de streaming de RomM.", + "error-hint-server": "RomM a rencontré une erreur inattendue au démarrage de la session. Consultez les journaux du serveur RomM.", + "error-hint-unreachable": "Le conteneur {label} n'a pas pu être contacté. Vérifiez que le conteneur est en cours d'exécution et que son broker est à l'écoute.", + "exit-chord-hint": "En jeu, maintenez Select + Start un instant pour ouvrir le menu de sortie.", + "exit-dialog-text": "Le jeu est toujours en cours. Que voulez-vous faire ?", + "exit-dialog-text-loading": "Le jeu est encore en train de démarrer. Annuler le lancement ?", + "exit-dialog-title": "Quitter le jeu ?", + "exit-full-screen": "Quitter le plein écran", + "exit-without-saving": "Quitter sans sauvegarder", "full-screen": "Plein écran", + "keep-playing": "Continuer à jouer", + "load-autosave": "Charger la sauvegarde automatique", + "load-state": "Charger l'état", + "manage-memory-cards": "Gérer les cartes mémoire", + "memory-card": "Carte mémoire", + "memory-card-count": "{count} cartes", + "memory-card-create-failed": "Impossible de créer la carte mémoire", + "memory-card-created": "Carte mémoire créée", + "memory-card-delete-failed": "Impossible de supprimer la carte mémoire", + "memory-card-deleted": "Carte mémoire supprimée", + "memory-card-hint": "La carte la plus récente est chargée par défaut. La progression est synchronisée à la sortie.", + "memory-card-no-versions": "Aucune version enregistrée pour le moment", + "memory-card-rename-failed": "Impossible de renommer la carte mémoire", + "memory-card-renamed": "Carte mémoire renommée", + "memory-card-share-failed": "Impossible de modifier le partage de la carte mémoire", + "memory-card-share-label": "Partagée avec d'autres utilisateurs", + "memory-card-shared": "Partagée", + "memory-card-updated": "Mise à jour {when}", + "memory-card-versions": "Historique des versions", + "memory-cards": "Cartes mémoire", + "memory-cards-empty": "Vous n'avez pas encore de carte mémoire pour cet émulateur.", + "mute": "Couper le son", + "new-memory-card": "Nouvelle carte", + "no-memory-cards": "Aucune carte mémoire pour le moment", "no-save-selected": "Aucune sauvegarde sélectionnée", "no-saves-available": "Aucune sauvegarde disponible", "no-screenshot-available": "Aucune capture d'écran disponible", "no-state-selected": "Aucun état sélectionné", "no-states-available": "Aucun état disponible", + "not-supported": "Non pris en charge", "play": "Jouer", + "play-on": "Jouer sur {label}", "powered-by": "Propulsé par", "quit": "Quitter", + "rename-memory-card": "Renommer la carte mémoire", + "resume-failed": "Impossible de charger l'état sélectionné. La partie a démarré de zéro.", "resume-from-save": "Reprendre depuis une sauvegarde", "resume-from-state": "Reprendre depuis un état", "save-and-quit": "Sauvegarder et quitter", + "save-slot": "Emplacement de sauvegarde", + "save-slots": "Emplacements de sauvegarde", + "save-state": "Sauvegarder l'état", "select-background-color": "Sélectionner la couleur d'arrière-plan", "select-save": "Sélectionner la sauvegarde", "select-state": "Sélectionner l'état", @@ -52,5 +101,6 @@ "stream-try-again": "Réessayer", "stream-unknown-game": "Jeu inconnu", "stream-unmute": "Rétablir le son", - "stream-volume": "Volume" + "stream-volume": "Volume", + "streaming-description": "Le jeu s'exécute dans un conteneur {label} dédié et est diffusé directement dans votre navigateur." } diff --git a/frontend/src/locales/hu_HU/activity.json b/frontend/src/locales/hu_HU/activity.json index 640e2bb0be..4669b63de3 100644 --- a/frontend/src/locales/hu_HU/activity.json +++ b/frontend/src/locales/hu_HU/activity.json @@ -10,5 +10,12 @@ "now-playing": "Most játszik", "playing-on": "Ezen játszik: {device}", "playing-since": "Játszik ekkortól: {time}", - "total-sessions": "Összes munkamenet" + "release-failed": "A munkamenetet nem sikerült felszabadítani", + "release-session": "Felszabadítás", + "release-session-body": "Ez azonnal leállítja a(z) {game} játékot, és lecsatlakoztatja {user} felhasználót. A nem mentett előrehaladás elvész.", + "release-session-title": "Felszabadítod a streaming munkamenetet?", + "session-released": "Munkamenet felszabadítva", + "streaming-sessions": "Streaming munkamenetek", + "total-sessions": "Összes munkamenet", + "unknown-user": "Ismeretlen felhasználó" } diff --git a/frontend/src/locales/hu_HU/play.json b/frontend/src/locales/hu_HU/play.json index df1e8dc716..1fac7e4ee9 100644 --- a/frontend/src/locales/hu_HU/play.json +++ b/frontend/src/locales/hu_HU/play.json @@ -1,29 +1,78 @@ { "all-saves": "Összes mentés", "all-states": "Összes állás", + "autosave": "Automatikus mentés", "back-to-gallery": "Vissza a galériába", "back-to-game-details": "Vissza a játék részleteihez", "background-color": "Háttérszín", + "cancel-launch": "Indítás megszakítása", "change-save": "Mentés cseréje", "change-state": "Állás cseréje", "clear-cache": "EmulatorJS Gyorsítótár Törlése", "clear-cache-description": "A szerveren tárolt mentések és állások nem változnak.", "clear-cache-title": "Biztosan törölni akarod az EmulatorJS gyorsítótárat?", "clear-cache-warning": "Ezzel a böngészőben tárolt összes mentés és állás törlődik.", + "create-memory-card": "Új memóriakártya", + "delete-memory-card": "Memóriakártya törlése", + "delete-memory-card-body": "Ez véglegesen törli a(z) „{name}\" kártyát és annak minden mentett verzióját. A művelet nem vonható vissza.", "deselect-save": "Mentés kiválasztásának törlése", "deselect-state": "Állás kiválasztásának törlése", + "emulator": "Emulátor", + "error-hint-auth": "Lehet, hogy nincs jogosultságod a streameléshez, vagy lejárt a munkameneted. Próbálj meg újra bejelentkezni.", + "error-hint-broker": "A(z) {label} konténer megtagadta a játék indítását. A részletekért ellenőrizd a konténer naplóit.", + "error-hint-network": "A RomM nem érhető el. Ellenőrizd a hálózati kapcsolatot, és hogy a szerver fut-e.", + "error-hint-not-configured": "Adj hozzá egy konténert ehhez a platformhoz a RomM streamelési konfigurációjában.", + "error-hint-server": "A RomM váratlan hibába ütközött a munkamenet indításakor. Ellenőrizd a RomM szerver naplóit.", + "error-hint-unreachable": "A(z) {label} konténer nem érhető el. Ellenőrizd, hogy a konténer fut-e, és hogy a brókere figyel-e.", + "exit-chord-hint": "Játék közben tartsd lenyomva egy pillanatig a Select + Start gombokat a kilépési menü megnyitásához.", + "exit-dialog-text": "A játék még fut. Mit szeretnél tenni?", + "exit-dialog-text-loading": "A játék még indul. Megszakítod az indítást?", + "exit-dialog-title": "Kilépés a játékból?", + "exit-full-screen": "Kilépés a teljes képernyőből", + "exit-without-saving": "Kilépés mentés nélkül", "full-screen": "Teljes képernyő", + "keep-playing": "Játék folytatása", + "load-autosave": "Automatikus mentés betöltése", + "load-state": "Állapot betöltése", + "manage-memory-cards": "Memóriakártyák kezelése", + "memory-card": "Memóriakártya", + "memory-card-count": "{count} kártya", + "memory-card-create-failed": "A memóriakártyát nem sikerült létrehozni", + "memory-card-created": "Memóriakártya létrehozva", + "memory-card-delete-failed": "A memóriakártyát nem sikerült törölni", + "memory-card-deleted": "Memóriakártya törölve", + "memory-card-hint": "Alapértelmezés szerint a legújabb kártya töltődik be. A haladás kilépéskor szinkronizálódik.", + "memory-card-no-versions": "Még nincsenek mentett verziók", + "memory-card-rename-failed": "A memóriakártyát nem sikerült átnevezni", + "memory-card-renamed": "Memóriakártya átnevezve", + "memory-card-share-failed": "A memóriakártya megosztása nem módosítható", + "memory-card-share-label": "Megosztva más felhasználókkal", + "memory-card-shared": "Megosztva", + "memory-card-updated": "Frissítve: {when}", + "memory-card-versions": "Verzióelőzmények", + "memory-cards": "Memóriakártyák", + "memory-cards-empty": "Még nincs memóriakártyád ehhez az emulátorhoz.", + "mute": "Némítás", + "new-memory-card": "Új kártya", + "no-memory-cards": "Még nincsenek memóriakártyák", "no-save-selected": "Nincs kiválasztott mentés", "no-saves-available": "Nincs elérhető mentés", "no-screenshot-available": "Nincs elérhető képernyőkép", "no-state-selected": "Nincs kiválasztott állás", "no-states-available": "Nincs elérhető állás", + "not-supported": "Nem támogatott", "play": "Indítás", + "play-on": "Játék ezen: {label}", "powered-by": "Üzemeltető:", "quit": "Kilépés", + "rename-memory-card": "Memóriakártya átnevezése", + "resume-failed": "A kiválasztott állapot betöltése nem sikerült. A játék tiszta lappal indult.", "resume-from-save": "Folytatás mentésből", "resume-from-state": "Folytatás állásból", "save-and-quit": "Mentés és Kilépés", + "save-slot": "Mentési hely", + "save-slots": "Mentési helyek", + "save-state": "Állapot mentése", "select-background-color": "Háttérszín kiválasztása", "select-save": "Mentés kiválasztása", "select-state": "Állás kiválasztása", @@ -52,5 +101,6 @@ "stream-try-again": "Próbálja újra", "stream-unknown-game": "Ismeretlen játék", "stream-unmute": "Némítás feloldása", - "stream-volume": "Hangerő" + "stream-volume": "Hangerő", + "streaming-description": "A játék egy dedikált {label} konténerben fut, és közvetlenül a böngésződbe streamel." } diff --git a/frontend/src/locales/it_IT/activity.json b/frontend/src/locales/it_IT/activity.json index 424601a2b3..f6b08ec94c 100644 --- a/frontend/src/locales/it_IT/activity.json +++ b/frontend/src/locales/it_IT/activity.json @@ -10,5 +10,12 @@ "now-playing": "In gioco", "playing-on": "Sta giocando su {device}", "playing-since": "Sta giocando da {time}", - "total-sessions": "Sessioni totali" + "release-failed": "Impossibile liberare la sessione", + "release-session": "Libera", + "release-session-body": "Questo fermerà immediatamente {game} e disconnetterà {user}. I progressi non salvati andranno persi.", + "release-session-title": "Liberare la sessione di streaming?", + "session-released": "Sessione liberata", + "streaming-sessions": "Sessioni di streaming", + "total-sessions": "Sessioni totali", + "unknown-user": "Utente sconosciuto" } diff --git a/frontend/src/locales/it_IT/play.json b/frontend/src/locales/it_IT/play.json index 2cf6c33956..605c4ef6a1 100644 --- a/frontend/src/locales/it_IT/play.json +++ b/frontend/src/locales/it_IT/play.json @@ -1,29 +1,78 @@ { "all-saves": "Tutti i salvataggi", "all-states": "Tutti gli stati", + "autosave": "Salvataggio automatico", "back-to-gallery": "Ritorna alla galleria", "back-to-game-details": "Ritorna ai dettagli del gioco", "background-color": "Colore di sfondo", + "cancel-launch": "Annulla avvio", "change-save": "Cambia Salvataggio", "change-state": "Cambia Stato", "clear-cache": "Pulisci cache", "clear-cache-description": "Pulisci la cache del gioco. Questo non rimuoverà i salvataggi o i file di configurazione.", "clear-cache-title": "Pulisci cache del gioco", "clear-cache-warning": "Sei sicuro di voler pulire la cache del gioco?", + "create-memory-card": "Nuova memory card", + "delete-memory-card": "Elimina memory card", + "delete-memory-card-body": "Questa operazione elimina definitivamente \"{name}\" e tutte le sue versioni salvate. L'azione non può essere annullata.", "deselect-save": "Deseleziona Salvataggio", "deselect-state": "Deseleziona Stato", + "emulator": "Emulatore", + "error-hint-auth": "Potresti non avere il permesso di trasmettere in streaming oppure la tua sessione è scaduta. Prova ad accedere di nuovo.", + "error-hint-broker": "Il contenitore {label} ha rifiutato di avviare il gioco. Controlla i log del contenitore per i dettagli.", + "error-hint-network": "Impossibile raggiungere RomM. Controlla la connessione di rete e che il server sia in esecuzione.", + "error-hint-not-configured": "Aggiungi un contenitore per questa piattaforma alla configurazione di streaming di RomM.", + "error-hint-server": "RomM ha riscontrato un errore imprevisto durante l'avvio della sessione. Controlla i log del server RomM.", + "error-hint-unreachable": "Impossibile raggiungere il contenitore {label}. Verifica che il contenitore sia in esecuzione e che il suo broker sia in ascolto.", + "exit-chord-hint": "Durante il gioco, tieni premuti Select + Start per un momento per aprire il menu di uscita.", + "exit-dialog-text": "Il gioco è ancora in esecuzione. Cosa vuoi fare?", + "exit-dialog-text-loading": "Il gioco si sta ancora avviando. Annullare l'avvio?", + "exit-dialog-title": "Uscire dal gioco?", + "exit-full-screen": "Esci dalla modalità a schermo intero", + "exit-without-saving": "Esci senza salvare", "full-screen": "Schermo Intero", + "keep-playing": "Continua a giocare", + "load-autosave": "Carica salvataggio automatico", + "load-state": "Carica stato", + "manage-memory-cards": "Gestisci memory card", + "memory-card": "Memory card", + "memory-card-count": "{count} memory card", + "memory-card-create-failed": "Impossibile creare la memory card", + "memory-card-created": "Memory card creata", + "memory-card-delete-failed": "Impossibile eliminare la memory card", + "memory-card-deleted": "Memory card eliminata", + "memory-card-hint": "La memory card più recente viene caricata per impostazione predefinita. I progressi vengono sincronizzati all'uscita.", + "memory-card-no-versions": "Nessuna versione salvata", + "memory-card-rename-failed": "Impossibile rinominare la memory card", + "memory-card-renamed": "Memory card rinominata", + "memory-card-share-failed": "Impossibile modificare la condivisione della memory card", + "memory-card-share-label": "Condivisa con altri utenti", + "memory-card-shared": "Condivisa", + "memory-card-updated": "Aggiornata {when}", + "memory-card-versions": "Cronologia versioni", + "memory-cards": "Memory card", + "memory-cards-empty": "Non hai ancora nessuna memory card per questo emulatore.", + "mute": "Disattiva audio", + "new-memory-card": "Nuova card", + "no-memory-cards": "Nessuna memory card", "no-save-selected": "Nessun salvataggio selezionato", "no-saves-available": "Nessun salvataggio disponibile", "no-screenshot-available": "Nessuno screenshot disponibile", "no-state-selected": "Nessuno stato selezionato", "no-states-available": "Nessuno stato disponibile", + "not-supported": "Non supportato", "play": "Gioca", + "play-on": "Gioca su {label}", "powered-by": "Powered by", "quit": "Esci", + "rename-memory-card": "Rinomina memory card", + "resume-failed": "Impossibile caricare lo stato selezionato. La partita è iniziata da zero.", "resume-from-save": "Riprendi da salvataggio", "resume-from-state": "Riprendi da stato", "save-and-quit": "Salva e esci", + "save-slot": "Slot di salvataggio", + "save-slots": "Slot di salvataggio", + "save-state": "Salva stato", "select-background-color": "Seleziona colore di sfondo", "select-save": "Seleziona Salvataggio", "select-state": "Seleziona Stato", @@ -52,5 +101,6 @@ "stream-try-again": "Riprova", "stream-unknown-game": "Gioco sconosciuto", "stream-unmute": "Riattiva audio", - "stream-volume": "Volume" + "stream-volume": "Volume", + "streaming-description": "Il gioco viene eseguito in un contenitore {label} dedicato e trasmesso direttamente al tuo browser." } diff --git a/frontend/src/locales/ja_JP/activity.json b/frontend/src/locales/ja_JP/activity.json index 583f651f33..71ae08139b 100644 --- a/frontend/src/locales/ja_JP/activity.json +++ b/frontend/src/locales/ja_JP/activity.json @@ -10,5 +10,12 @@ "now-playing": "プレイ中", "playing-on": "{device}でプレイ中", "playing-since": "{time}からプレイ中", - "total-sessions": "セッション合計" + "release-failed": "セッションを解放できませんでした", + "release-session": "解放", + "release-session-body": "{game} を直ちに停止し、{user} を切断します。保存されていない進行状況は失われます。", + "release-session-title": "ストリーミングセッションを解放しますか?", + "session-released": "セッションを解放しました", + "streaming-sessions": "ストリーミングセッション", + "total-sessions": "セッション合計", + "unknown-user": "不明なユーザー" } diff --git a/frontend/src/locales/ja_JP/play.json b/frontend/src/locales/ja_JP/play.json index dd9f445991..8a6eddf31b 100644 --- a/frontend/src/locales/ja_JP/play.json +++ b/frontend/src/locales/ja_JP/play.json @@ -1,29 +1,78 @@ { "all-saves": "すべてのセーブ", "all-states": "すべてのステートセーブ", + "autosave": "オートセーブ", "back-to-gallery": "ギャラリーへ戻る", "back-to-game-details": "ゲーム詳細へ戻る", "background-color": "背景色", + "cancel-launch": "起動をキャンセル", "change-save": "セーブデータを変更", "change-state": "ステートを変更", "clear-cache": "EmulatorJS キャッシュをクリア", "clear-cache-description": "サーバーに保存されているセーブデータやステートには影響しません。", "clear-cache-title": "EmulatorJS キャッシュをクリアしてもよろしいですか?", "clear-cache-warning": "これにより、ブラウザに保存されているすべてのセーブデータとステートが削除されます。", + "create-memory-card": "新しいメモリーカード", + "delete-memory-card": "メモリーカードを削除", + "delete-memory-card-body": "「{name}」とその保存済みバージョンをすべて完全に削除します。この操作は元に戻せません。", "deselect-save": "セーブデータを解除", "deselect-state": "ステートを解除", + "emulator": "エミュレーター", + "error-hint-auth": "ストリーミングの権限がないか、ログインセッションの有効期限が切れている可能性があります。もう一度サインインしてみてください。", + "error-hint-broker": "{label} コンテナがゲームの起動を拒否しました。詳細はコンテナのログを確認してください。", + "error-hint-network": "RomM に接続できませんでした。ネットワーク接続とサーバーが稼働しているかを確認してください。", + "error-hint-not-configured": "このプラットフォーム用のコンテナを RomM のストリーミング設定に追加してください。", + "error-hint-server": "セッションの開始中に RomM で予期しないエラーが発生しました。RomM サーバーのログを確認してください。", + "error-hint-unreachable": "{label} コンテナに接続できませんでした。コンテナが稼働しており、そのブローカーが待ち受けているかを確認してください。", + "exit-chord-hint": "プレイ中に Select + Start を長押しすると終了メニューが開きます。", + "exit-dialog-text": "ゲームはまだ実行中です。どうしますか?", + "exit-dialog-text-loading": "ゲームはまだ起動中です。起動をキャンセルしますか?", + "exit-dialog-title": "ゲームを終了しますか?", + "exit-full-screen": "全画面表示を終了", + "exit-without-saving": "保存せずに終了", "full-screen": "全画面", + "keep-playing": "プレイを続ける", + "load-autosave": "オートセーブを読み込む", + "load-state": "ステートをロード", + "manage-memory-cards": "メモリーカードを管理", + "memory-card": "メモリーカード", + "memory-card-count": "{count} 枚", + "memory-card-create-failed": "メモリーカードを作成できませんでした", + "memory-card-created": "メモリーカードを作成しました", + "memory-card-delete-failed": "メモリーカードを削除できませんでした", + "memory-card-deleted": "メモリーカードを削除しました", + "memory-card-hint": "デフォルトで最新のカードが読み込まれます。終了時に進行状況が同期されます。", + "memory-card-no-versions": "保存されたバージョンはまだありません", + "memory-card-rename-failed": "メモリーカードの名前を変更できませんでした", + "memory-card-renamed": "メモリーカードの名前を変更しました", + "memory-card-share-failed": "メモリーカードの共有を変更できませんでした", + "memory-card-share-label": "他のユーザーと共有", + "memory-card-shared": "共有中", + "memory-card-updated": "{when} に更新", + "memory-card-versions": "バージョン履歴", + "memory-cards": "メモリーカード", + "memory-cards-empty": "このエミュレーターのメモリーカードはまだありません。", + "mute": "ミュート", + "new-memory-card": "新しいカード", + "no-memory-cards": "メモリーカードはまだありません", "no-save-selected": "セーブデータが選択されていません", "no-saves-available": "利用可能なセーブデータがありません", "no-screenshot-available": "スクリーンショットはありません", "no-state-selected": "ステートが選択されていません", "no-states-available": "利用可能なステートがありません", + "not-supported": "非対応", "play": "プレイ", + "play-on": "{label} でプレイ", "powered-by": "提供:", "quit": "終了", + "rename-memory-card": "メモリーカードの名前を変更", + "resume-failed": "選択したステートを読み込めませんでした。ゲームは最初から開始されました。", "resume-from-save": "セーブから再開", "resume-from-state": "ステートセーブから再開", "save-and-quit": "保存して終了", + "save-slot": "セーブスロット", + "save-slots": "セーブスロット", + "save-state": "ステートをセーブ", "select-background-color": "背景色を選択", "select-save": "セーブデータを選択", "select-state": "ステートを選択", @@ -52,5 +101,6 @@ "stream-try-again": "再試行", "stream-unknown-game": "不明なゲーム", "stream-unmute": "ミュート解除", - "stream-volume": "音量" + "stream-volume": "音量", + "streaming-description": "ゲームは専用の {label} コンテナで実行され、ブラウザに直接ストリーミングされます。" } diff --git a/frontend/src/locales/ko_KR/activity.json b/frontend/src/locales/ko_KR/activity.json index 03a27c81f8..e1122f8cff 100644 --- a/frontend/src/locales/ko_KR/activity.json +++ b/frontend/src/locales/ko_KR/activity.json @@ -10,5 +10,12 @@ "now-playing": "플레이 중", "playing-on": "{device}에서 플레이 중", "playing-since": "{time}부터 플레이 중", - "total-sessions": "전체 세션" + "release-failed": "세션을 해제할 수 없습니다", + "release-session": "해제", + "release-session-body": "{game}이(가) 즉시 중지되고 {user}의 연결이 끊어집니다. 저장되지 않은 진행 상황은 사라집니다.", + "release-session-title": "스트리밍 세션을 해제하시겠습니까?", + "session-released": "세션이 해제되었습니다", + "streaming-sessions": "스트리밍 세션", + "total-sessions": "전체 세션", + "unknown-user": "알 수 없는 사용자" } diff --git a/frontend/src/locales/ko_KR/play.json b/frontend/src/locales/ko_KR/play.json index b651974208..d027c8740b 100644 --- a/frontend/src/locales/ko_KR/play.json +++ b/frontend/src/locales/ko_KR/play.json @@ -1,29 +1,78 @@ { "all-saves": "모든 세이브", "all-states": "모든 상태", + "autosave": "자동 저장", "back-to-gallery": "갤러리로 가기", "back-to-game-details": "게임 설명으로 가기", "background-color": "배경색", + "cancel-launch": "시작 취소", "change-save": "세이브 변경", "change-state": "상태 변경", "clear-cache": "EmulatorJS 캐시 지우기", "clear-cache-description": "서버에 저장된 세이브 및 상태에는 영향을 주지 않습니다.", "clear-cache-title": "EmulatorJS 캐시를 지우시겠습니까?", "clear-cache-warning": "이로 인해 브라우저에 저장된 모든 세이브 및 상태가 제거됩니다.", + "create-memory-card": "새 메모리 카드", + "delete-memory-card": "메모리 카드 삭제", + "delete-memory-card-body": "\"{name}\" 및 저장된 모든 버전을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", "deselect-save": "세이브 선택 해제", "deselect-state": "상태 선택 해제", + "emulator": "에뮬레이터", + "error-hint-auth": "스트리밍 권한이 없거나 로그인 세션이 만료되었을 수 있습니다. 다시 로그인해 보세요.", + "error-hint-broker": "{label} 컨테이너가 게임 실행을 거부했습니다. 자세한 내용은 컨테이너 로그를 확인하세요.", + "error-hint-network": "RomM에 연결할 수 없습니다. 네트워크 연결과 서버 실행 여부를 확인하세요.", + "error-hint-not-configured": "이 플랫폼용 컨테이너를 RomM 스트리밍 구성에 추가하세요.", + "error-hint-server": "세션을 시작하는 중 RomM에서 예기치 않은 오류가 발생했습니다. RomM 서버 로그를 확인하세요.", + "error-hint-unreachable": "{label} 컨테이너에 연결할 수 없습니다. 컨테이너가 실행 중이고 브로커가 수신 대기 중인지 확인하세요.", + "exit-chord-hint": "플레이 중에 Select + Start를 잠시 누르고 있으면 종료 메뉴가 열립니다.", + "exit-dialog-text": "게임이 아직 실행 중입니다. 어떻게 할까요?", + "exit-dialog-text-loading": "게임이 아직 시작 중입니다. 시작을 취소할까요?", + "exit-dialog-title": "게임을 종료할까요?", + "exit-full-screen": "전체 화면 종료", + "exit-without-saving": "저장하지 않고 종료", "full-screen": "전체 화면", + "keep-playing": "계속 플레이", + "load-autosave": "자동 저장 불러오기", + "load-state": "상태 불러오기", + "manage-memory-cards": "메모리 카드 관리", + "memory-card": "메모리 카드", + "memory-card-count": "{count}개", + "memory-card-create-failed": "메모리 카드를 만들 수 없습니다", + "memory-card-created": "메모리 카드가 생성되었습니다", + "memory-card-delete-failed": "메모리 카드를 삭제할 수 없습니다", + "memory-card-deleted": "메모리 카드가 삭제되었습니다", + "memory-card-hint": "기본적으로 최신 카드가 로드됩니다. 종료하면 진행 상황이 동기화됩니다.", + "memory-card-no-versions": "저장된 버전이 아직 없습니다", + "memory-card-rename-failed": "메모리 카드의 이름을 변경할 수 없습니다", + "memory-card-renamed": "메모리 카드 이름이 변경되었습니다", + "memory-card-share-failed": "메모리 카드의 공유를 변경할 수 없습니다", + "memory-card-share-label": "다른 사용자와 공유됨", + "memory-card-shared": "공유됨", + "memory-card-updated": "{when}에 업데이트됨", + "memory-card-versions": "버전 기록", + "memory-cards": "메모리 카드", + "memory-cards-empty": "이 에뮬레이터에 대한 메모리 카드가 아직 없습니다.", + "mute": "음소거", + "new-memory-card": "새 카드", + "no-memory-cards": "메모리 카드가 아직 없습니다", "no-save-selected": "선택된 세이브 없음", "no-saves-available": "사용 가능한 세이브 없음", "no-screenshot-available": "사용 가능한 스크린샷이 없습니다", "no-state-selected": "선택된 상태 없음", "no-states-available": "사용 가능한 상태 없음", + "not-supported": "지원되지 않음", "play": "실행", + "play-on": "{label}에서 플레이", "powered-by": "제공", "quit": "종료", + "rename-memory-card": "메모리 카드 이름 변경", + "resume-failed": "선택한 상태를 불러오지 못했습니다. 게임이 처음부터 시작되었습니다.", "resume-from-save": "세이브에서 이어하기", "resume-from-state": "상태에서 이어하기", "save-and-quit": "저장하고 종료", + "save-slot": "저장 슬롯", + "save-slots": "저장 슬롯", + "save-state": "상태 저장", "select-background-color": "배경색 선택", "select-save": "세이브 선택", "select-state": "상태 선택", @@ -52,5 +101,6 @@ "stream-try-again": "다시 시도", "stream-unknown-game": "알 수 없는 게임", "stream-unmute": "음소거 해제", - "stream-volume": "볼륨" + "stream-volume": "볼륨", + "streaming-description": "게임은 전용 {label} 컨테이너에서 실행되며 브라우저로 바로 스트리밍됩니다." } diff --git a/frontend/src/locales/pl_PL/activity.json b/frontend/src/locales/pl_PL/activity.json index f488389164..3cec8da472 100644 --- a/frontend/src/locales/pl_PL/activity.json +++ b/frontend/src/locales/pl_PL/activity.json @@ -10,5 +10,12 @@ "now-playing": "Teraz gra", "playing-on": "Gra na {device}", "playing-since": "Gra od {time}", - "total-sessions": "Łącznie sesji" + "release-failed": "Nie udało się zwolnić sesji", + "release-session": "Zwolnij", + "release-session-body": "To natychmiast zatrzyma {game} i rozłączy użytkownika {user}. Niezapisany postęp zostanie utracony.", + "release-session-title": "Zwolnić sesję streamingu?", + "session-released": "Sesja zwolniona", + "streaming-sessions": "Sesje streamingu", + "total-sessions": "Łącznie sesji", + "unknown-user": "Nieznany użytkownik" } diff --git a/frontend/src/locales/pl_PL/play.json b/frontend/src/locales/pl_PL/play.json index 7e5f7163e5..dadcd22ae5 100644 --- a/frontend/src/locales/pl_PL/play.json +++ b/frontend/src/locales/pl_PL/play.json @@ -1,29 +1,78 @@ { "all-saves": "Wszystkie zapisy", "all-states": "Wszystkie stany", + "autosave": "Automatyczny zapis", "back-to-gallery": "Powrót do galerii", "back-to-game-details": "Powrót do szczegółów gry", "background-color": "Kolor tła", + "cancel-launch": "Anuluj uruchamianie", "change-save": "Zmień zapis", "change-state": "Zmień stan", "clear-cache": "Wyczyść pamięć podręczną EmulatorJS", "clear-cache-description": "Zapisane stany i pliki przechowywane na serwerze nie zostaną usunięte.", "clear-cache-title": "Czy na pewno chcesz wyczyścić pamięć podręczną EmulatorJS?", "clear-cache-warning": "To usunie wszystkie zapisy i stany przechowywane w przeglądarce.", + "create-memory-card": "Nowa karta pamięci", + "delete-memory-card": "Usuń kartę pamięci", + "delete-memory-card-body": "Spowoduje to trwałe usunięcie „{name}\" oraz wszystkich zapisanych wersji. Tej operacji nie można cofnąć.", "deselect-save": "Odznacz zapis", "deselect-state": "Odznacz stan", + "emulator": "Emulator", + "error-hint-auth": "Możesz nie mieć uprawnień do streamowania lub twoja sesja wygasła. Spróbuj zalogować się ponownie.", + "error-hint-broker": "Kontener {label} odmówił uruchomienia gry. Szczegóły znajdziesz w dziennikach kontenera.", + "error-hint-network": "Nie można połączyć się z RomM. Sprawdź połączenie sieciowe oraz czy serwer działa.", + "error-hint-not-configured": "Dodaj kontener dla tej platformy do konfiguracji streamingu RomM.", + "error-hint-server": "RomM napotkał nieoczekiwany błąd podczas uruchamiania sesji. Sprawdź dzienniki serwera RomM.", + "error-hint-unreachable": "Nie można połączyć się z kontenerem {label}. Sprawdź, czy kontener działa i czy jego broker nasłuchuje.", + "exit-chord-hint": "Podczas gry przytrzymaj przez chwilę Select + Start, aby otworzyć menu wyjścia.", + "exit-dialog-text": "Gra wciąż działa. Co chcesz zrobić?", + "exit-dialog-text-loading": "Gra wciąż się uruchamia. Anulować uruchamianie?", + "exit-dialog-title": "Wyjść z gry?", + "exit-full-screen": "Zamknij tryb pełnoekranowy", + "exit-without-saving": "Wyjdź bez zapisywania", "full-screen": "Pełny ekran", + "keep-playing": "Graj dalej", + "load-autosave": "Wczytaj automatyczny zapis", + "load-state": "Wczytaj stan", + "manage-memory-cards": "Zarządzaj kartami pamięci", + "memory-card": "Karta pamięci", + "memory-card-count": "{count} kart", + "memory-card-create-failed": "Nie udało się utworzyć karty pamięci", + "memory-card-created": "Utworzono kartę pamięci", + "memory-card-delete-failed": "Nie udało się usunąć karty pamięci", + "memory-card-deleted": "Usunięto kartę pamięci", + "memory-card-hint": "Domyślnie ładowana jest najnowsza karta. Postęp jest synchronizowany przy wyjściu.", + "memory-card-no-versions": "Brak zapisanych wersji", + "memory-card-rename-failed": "Nie udało się zmienić nazwy karty pamięci", + "memory-card-renamed": "Zmieniono nazwę karty pamięci", + "memory-card-share-failed": "Nie udało się zmienić udostępniania karty pamięci", + "memory-card-share-label": "Udostępniona innym użytkownikom", + "memory-card-shared": "Udostępniona", + "memory-card-updated": "Zaktualizowano {when}", + "memory-card-versions": "Historia wersji", + "memory-cards": "Karty pamięci", + "memory-cards-empty": "Nie masz jeszcze żadnych kart pamięci dla tego emulatora.", + "mute": "Wycisz", + "new-memory-card": "Nowa karta", + "no-memory-cards": "Brak kart pamięci", "no-save-selected": "Nie wybrano zapisu", "no-saves-available": "Brak dostępnych zapisów", "no-screenshot-available": "Brak dostępnych zrzutów ekranu", "no-state-selected": "Nie wybrano stanu", "no-states-available": "Brak dostępnych stanów", + "not-supported": "Nieobsługiwane", "play": "Graj", + "play-on": "Graj na {label}", "powered-by": "Zasilane przez", "quit": "Zakończ", + "rename-memory-card": "Zmień nazwę karty pamięci", + "resume-failed": "Nie udało się wczytać wybranego stanu. Gra rozpoczęła się od nowa.", "resume-from-save": "Wznów z zapisu", "resume-from-state": "Wznów ze stanu", "save-and-quit": "Zapisz i zakończ", + "save-slot": "Miejsce zapisu", + "save-slots": "Miejsca zapisu", + "save-state": "Zapisz stan", "select-background-color": "Wybierz kolor tła", "select-save": "Wybierz zapis", "select-state": "Wybierz stan", @@ -52,5 +101,6 @@ "stream-try-again": "Spróbuj ponownie", "stream-unknown-game": "Nieznana gra", "stream-unmute": "Wyłącz wyciszenie", - "stream-volume": "Głośność" + "stream-volume": "Głośność", + "streaming-description": "Gra działa w dedykowanym kontenerze {label} i jest strumieniowana bezpośrednio do twojej przeglądarki." } diff --git a/frontend/src/locales/pt_BR/activity.json b/frontend/src/locales/pt_BR/activity.json index 00cf114cb4..5160f4fd89 100644 --- a/frontend/src/locales/pt_BR/activity.json +++ b/frontend/src/locales/pt_BR/activity.json @@ -10,5 +10,12 @@ "now-playing": "Jogando agora", "playing-on": "Jogando em {device}", "playing-since": "Jogando desde {time}", - "total-sessions": "Sessões totais" + "release-failed": "Não foi possível liberar a sessão", + "release-session": "Liberar", + "release-session-body": "Isso interromperá {game} imediatamente e desconectará {user}. O progresso não salvo será perdido.", + "release-session-title": "Liberar a sessão de streaming?", + "session-released": "Sessão liberada", + "streaming-sessions": "Sessões de streaming", + "total-sessions": "Sessões totais", + "unknown-user": "Usuário desconhecido" } diff --git a/frontend/src/locales/pt_BR/play.json b/frontend/src/locales/pt_BR/play.json index f677861bba..7bd81c6f0c 100644 --- a/frontend/src/locales/pt_BR/play.json +++ b/frontend/src/locales/pt_BR/play.json @@ -1,29 +1,78 @@ { "all-saves": "Todos os saves", "all-states": "Todos os states", + "autosave": "Salvamento automático", "back-to-gallery": "Voltar à galeria", "back-to-game-details": "Voltar aos detalhes do jogo", "background-color": "Cor de fundo", + "cancel-launch": "Cancelar inicialização", "change-save": "Alterar save", "change-state": "Alterar estado", "clear-cache": "Limpar cache do EmulatorJS", "clear-cache-description": "Não afetará nenhum save ou estado armazenado no servidor.", "clear-cache-title": "Tem certeza de que deseja limpar o cache do EmulatorJS?", "clear-cache-warning": "Isso removerá todos os saves e estados armazenados no navegador.", + "create-memory-card": "Novo cartão de memória", + "delete-memory-card": "Excluir cartão de memória", + "delete-memory-card-body": "Isso exclui permanentemente \"{name}\" e todas as suas versões salvas. Esta ação não pode ser desfeita.", "deselect-save": "Desmarcar save", "deselect-state": "Desmarcar estado", + "emulator": "Emulador", + "error-hint-auth": "Talvez você não tenha permissão para transmitir ou sua sessão expirou. Tente entrar novamente.", + "error-hint-broker": "O contêiner {label} recusou-se a iniciar o jogo. Verifique os logs do contêiner para mais detalhes.", + "error-hint-network": "Não foi possível contatar o RomM. Verifique sua conexão de rede e se o servidor está em execução.", + "error-hint-not-configured": "Adicione um contêiner para esta plataforma à configuração de streaming do RomM.", + "error-hint-server": "O RomM encontrou um erro inesperado ao iniciar a sessão. Verifique os logs do servidor do RomM.", + "error-hint-unreachable": "Não foi possível contatar o contêiner {label}. Verifique se o contêiner está em execução e se o broker dele está escutando.", + "exit-chord-hint": "Durante o jogo, segure Select + Start por um momento para abrir o menu de saída.", + "exit-dialog-text": "O jogo ainda está em execução. O que você quer fazer?", + "exit-dialog-text-loading": "O jogo ainda está iniciando. Cancelar a inicialização?", + "exit-dialog-title": "Sair do jogo?", + "exit-full-screen": "Sair da tela cheia", + "exit-without-saving": "Sair sem salvar", "full-screen": "Tela cheia", + "keep-playing": "Continuar jogando", + "load-autosave": "Carregar salvamento automático", + "load-state": "Carregar estado", + "manage-memory-cards": "Gerenciar cartões de memória", + "memory-card": "Cartão de memória", + "memory-card-count": "{count} cartões", + "memory-card-create-failed": "Não foi possível criar o cartão de memória", + "memory-card-created": "Cartão de memória criado", + "memory-card-delete-failed": "Não foi possível excluir o cartão de memória", + "memory-card-deleted": "Cartão de memória excluído", + "memory-card-hint": "O cartão mais recente é carregado por padrão. O progresso é sincronizado ao sair.", + "memory-card-no-versions": "Nenhuma versão salva ainda", + "memory-card-rename-failed": "Não foi possível renomear o cartão de memória", + "memory-card-renamed": "Cartão de memória renomeado", + "memory-card-share-failed": "Não foi possível alterar o compartilhamento do cartão de memória", + "memory-card-share-label": "Compartilhado com outros usuários", + "memory-card-shared": "Compartilhado", + "memory-card-updated": "Atualizado {when}", + "memory-card-versions": "Histórico de versões", + "memory-cards": "Cartões de memória", + "memory-cards-empty": "Você ainda não tem nenhum cartão de memória para este emulador.", + "mute": "Silenciar", + "new-memory-card": "Novo cartão", + "no-memory-cards": "Nenhum cartão de memória ainda", "no-save-selected": "Nenhum save selecionado", "no-saves-available": "Nenhum save disponível", "no-screenshot-available": "Nenhuma captura de tela disponível", "no-state-selected": "Nenhum estado selecionado", "no-states-available": "Nenhum estado disponível", + "not-supported": "Não compatível", "play": "Jogar", + "play-on": "Jogar no {label}", "powered-by": "Desenvolvido com", "quit": "Sair", + "rename-memory-card": "Renomear cartão de memória", + "resume-failed": "Não foi possível carregar o estado selecionado. O jogo começou do zero.", "resume-from-save": "Retomar a partir do save", "resume-from-state": "Retomar a partir do state", "save-and-quit": "Salvar e sair", + "save-slot": "Slot de salvamento", + "save-slots": "Slots de salvamento", + "save-state": "Salvar estado", "select-background-color": "Selecionar cor de fundo", "select-save": "Selecionar save", "select-state": "Selecionar estado", @@ -52,5 +101,6 @@ "stream-try-again": "Tentar novamente", "stream-unknown-game": "Jogo desconhecido", "stream-unmute": "Ativar som", - "stream-volume": "Volume" + "stream-volume": "Volume", + "streaming-description": "O jogo é executado em um contêiner {label} dedicado e transmitido diretamente para o seu navegador." } diff --git a/frontend/src/locales/ro_RO/activity.json b/frontend/src/locales/ro_RO/activity.json index 80cecf05ad..8600717380 100644 --- a/frontend/src/locales/ro_RO/activity.json +++ b/frontend/src/locales/ro_RO/activity.json @@ -10,5 +10,12 @@ "now-playing": "Se joacă acum", "playing-on": "Joacă pe {device}", "playing-since": "Joacă din {time}", - "total-sessions": "Total sesiuni" + "release-failed": "Sesiunea nu a putut fi eliberată", + "release-session": "Eliberează", + "release-session-body": "Aceasta va opri imediat {game} și va deconecta {user}. Progresul nesalvat va fi pierdut.", + "release-session-title": "Eliberezi sesiunea de streaming?", + "session-released": "Sesiune eliberată", + "streaming-sessions": "Sesiuni de streaming", + "total-sessions": "Total sesiuni", + "unknown-user": "Utilizator necunoscut" } diff --git a/frontend/src/locales/ro_RO/play.json b/frontend/src/locales/ro_RO/play.json index b27fe688a3..27892ae13d 100644 --- a/frontend/src/locales/ro_RO/play.json +++ b/frontend/src/locales/ro_RO/play.json @@ -1,29 +1,78 @@ { "all-saves": "Toate salvările", "all-states": "Toate stările", + "autosave": "Salvare automată", "back-to-gallery": "Înapoi la galerie", "back-to-game-details": "Înapoi la detaliile jocului", "background-color": "Culoare de fundal", + "cancel-launch": "Anulează pornirea", "change-save": "Schimbă salvare", "change-state": "Schimbă stare", "clear-cache": "Șterge cache-ul EmulatorJS", "clear-cache-description": "Nu va afecta nicio salvare sau stare stocată pe server.", "clear-cache-title": "Ești sigur că vrei să ștergi cache-ul EmulatorJS?", "clear-cache-warning": "Acest lucru va elimina toate salvările și stările stocate în browser.", + "create-memory-card": "Card de memorie nou", + "delete-memory-card": "Șterge cardul de memorie", + "delete-memory-card-body": "Această acțiune șterge definitiv „{name}\" și toate versiunile salvate ale acestuia. Acțiunea nu poate fi anulată.", "deselect-save": "Deselectează salvare", "deselect-state": "Deselectează stare", + "emulator": "Emulator", + "error-hint-auth": "Este posibil să nu ai permisiunea de a transmite sau sesiunea ta a expirat. Încearcă să te autentifici din nou.", + "error-hint-broker": "Containerul {label} a refuzat să pornească jocul. Verifică jurnalele containerului pentru detalii.", + "error-hint-network": "RomM nu a putut fi contactat. Verifică conexiunea la rețea și dacă serverul rulează.", + "error-hint-not-configured": "Adaugă un container pentru această platformă în configurația de streaming a RomM.", + "error-hint-server": "RomM a întâmpinat o eroare neașteptată la pornirea sesiunii. Verifică jurnalele serverului RomM.", + "error-hint-unreachable": "Containerul {label} nu a putut fi contactat. Verifică dacă containerul rulează și dacă brokerul său ascultă.", + "exit-chord-hint": "În timpul jocului, ține apăsat Select + Start un moment pentru a deschide meniul de ieșire.", + "exit-dialog-text": "Jocul încă rulează. Ce vrei să faci?", + "exit-dialog-text-loading": "Jocul încă pornește. Anulezi pornirea?", + "exit-dialog-title": "Ieși din joc?", + "exit-full-screen": "Ieși din ecran complet", + "exit-without-saving": "Ieși fără a salva", "full-screen": "Ecran complet", + "keep-playing": "Continuă să joci", + "load-autosave": "Încarcă salvarea automată", + "load-state": "Încarcă starea", + "manage-memory-cards": "Gestionează cardurile de memorie", + "memory-card": "Card de memorie", + "memory-card-count": "{count} carduri", + "memory-card-create-failed": "Cardul de memorie nu a putut fi creat", + "memory-card-created": "Card de memorie creat", + "memory-card-delete-failed": "Cardul de memorie nu a putut fi șters", + "memory-card-deleted": "Card de memorie șters", + "memory-card-hint": "În mod implicit se încarcă cel mai recent card. Progresul se sincronizează la ieșire.", + "memory-card-no-versions": "Încă nu există versiuni salvate", + "memory-card-rename-failed": "Cardul de memorie nu a putut fi redenumit", + "memory-card-renamed": "Card de memorie redenumit", + "memory-card-share-failed": "Partajarea cardului de memorie nu a putut fi modificată", + "memory-card-share-label": "Partajat cu alți utilizatori", + "memory-card-shared": "Partajat", + "memory-card-updated": "Actualizat {when}", + "memory-card-versions": "Istoricul versiunilor", + "memory-cards": "Carduri de memorie", + "memory-cards-empty": "Nu ai încă niciun card de memorie pentru acest emulator.", + "mute": "Dezactivează sunetul", + "new-memory-card": "Card nou", + "no-memory-cards": "Încă niciun card de memorie", "no-save-selected": "Nicio salvare selectată", "no-saves-available": "Nicio salvare disponibilă", "no-screenshot-available": "Nicio captură disponibilă", "no-state-selected": "Nicio stare selectată", "no-states-available": "Nicio stare disponibilă", + "not-supported": "Neacceptat", "play": "Joacă", + "play-on": "Joacă pe {label}", "powered-by": "Susținut de", "quit": "Ieși", + "rename-memory-card": "Redenumește cardul de memorie", + "resume-failed": "Starea selectată nu a putut fi încărcată. Jocul a pornit de la zero.", "resume-from-save": "Reia din salvare", "resume-from-state": "Reia din stare", "save-and-quit": "Salvează și ieși", + "save-slot": "Slot de salvare", + "save-slots": "Sloturi de salvare", + "save-state": "Salvează starea", "select-background-color": "Selectează culoarea de fundal", "select-save": "Selectează salvare", "select-state": "Selectează stare", @@ -52,5 +101,6 @@ "stream-try-again": "Încearcă din nou", "stream-unknown-game": "Joc necunoscut", "stream-unmute": "Activează sunetul", - "stream-volume": "Volum" + "stream-volume": "Volum", + "streaming-description": "Jocul rulează într-un container {label} dedicat și este transmis direct în browserul tău." } diff --git a/frontend/src/locales/ru_RU/activity.json b/frontend/src/locales/ru_RU/activity.json index 02c1acd5cf..30cde2f801 100644 --- a/frontend/src/locales/ru_RU/activity.json +++ b/frontend/src/locales/ru_RU/activity.json @@ -10,5 +10,12 @@ "now-playing": "Сейчас играют", "playing-on": "Играет на {device}", "playing-since": "Играет с {time}", - "total-sessions": "Всего сессий" + "release-failed": "Не удалось освободить сессию", + "release-session": "Освободить", + "release-session-body": "Это немедленно остановит {game} и отключит {user}. Несохранённый прогресс будет потерян.", + "release-session-title": "Освободить стриминговую сессию?", + "session-released": "Сессия освобождена", + "streaming-sessions": "Стриминговые сессии", + "total-sessions": "Всего сессий", + "unknown-user": "Неизвестный пользователь" } diff --git a/frontend/src/locales/ru_RU/play.json b/frontend/src/locales/ru_RU/play.json index 4a911a638e..00d3eea749 100644 --- a/frontend/src/locales/ru_RU/play.json +++ b/frontend/src/locales/ru_RU/play.json @@ -1,29 +1,78 @@ { "all-saves": "Все сохранения", "all-states": "Все состояния", + "autosave": "Автосохранение", "back-to-gallery": "Вернуться в галерею", "back-to-game-details": "Вернуться к деталям игры", "background-color": "Цвет фона", + "cancel-launch": "Отменить запуск", "change-save": "Изменить сохранение", "change-state": "Изменить состояние", "clear-cache": "Очистить кэш EmulatorJS", "clear-cache-description": "Любые сохранения или состояния, хранящиеся на сервере, не будут затронуты.", "clear-cache-title": "Вы уверены, что хотите очистить кэш EmulatorJS?", "clear-cache-warning": "Это удалит все сохранения и состояния, хранящиеся в браузере.", + "create-memory-card": "Новая карта памяти", + "delete-memory-card": "Удалить карту памяти", + "delete-memory-card-body": "Это навсегда удалит «{name}» и все её сохранённые версии. Это действие нельзя отменить.", "deselect-save": "Снять выбор сохранения", "deselect-state": "Снять выбор состояния", + "emulator": "Эмулятор", + "error-hint-auth": "Возможно, у вас нет прав на стриминг или ваша сессия истекла. Попробуйте войти снова.", + "error-hint-broker": "Контейнер {label} отказался запустить игру. Подробности смотрите в журналах контейнера.", + "error-hint-network": "Не удалось связаться с RomM. Проверьте сетевое подключение и работает ли сервер.", + "error-hint-not-configured": "Добавьте контейнер для этой платформы в конфигурацию стриминга RomM.", + "error-hint-server": "В RomM произошла непредвиденная ошибка при запуске сессии. Проверьте журналы сервера RomM.", + "error-hint-unreachable": "Не удалось связаться с контейнером {label}. Убедитесь, что контейнер запущен и его брокер принимает подключения.", + "exit-chord-hint": "Во время игры удерживайте Select + Start, чтобы открыть меню выхода.", + "exit-dialog-text": "Игра всё ещё запущена. Что вы хотите сделать?", + "exit-dialog-text-loading": "Игра ещё запускается. Отменить запуск?", + "exit-dialog-title": "Выйти из игры?", + "exit-full-screen": "Выйти из полноэкранного режима", + "exit-without-saving": "Выйти без сохранения", "full-screen": "Полный экран", + "keep-playing": "Продолжить игру", + "load-autosave": "Загрузить автосохранение", + "load-state": "Загрузить состояние", + "manage-memory-cards": "Управление картами памяти", + "memory-card": "Карта памяти", + "memory-card-count": "{count} карт", + "memory-card-create-failed": "Не удалось создать карту памяти", + "memory-card-created": "Карта памяти создана", + "memory-card-delete-failed": "Не удалось удалить карту памяти", + "memory-card-deleted": "Карта памяти удалена", + "memory-card-hint": "По умолчанию загружается новейшая карта. Прогресс синхронизируется при выходе.", + "memory-card-no-versions": "Пока нет сохранённых версий", + "memory-card-rename-failed": "Не удалось переименовать карту памяти", + "memory-card-renamed": "Карта памяти переименована", + "memory-card-share-failed": "Не удалось изменить общий доступ к карте памяти", + "memory-card-share-label": "Доступна другим пользователям", + "memory-card-shared": "Общий доступ", + "memory-card-updated": "Обновлено {when}", + "memory-card-versions": "История версий", + "memory-cards": "Карты памяти", + "memory-cards-empty": "У вас пока нет карт памяти для этого эмулятора.", + "mute": "Отключить звук", + "new-memory-card": "Новая карта", + "no-memory-cards": "Пока нет карт памяти", "no-save-selected": "Сохранение не выбрано", "no-saves-available": "Нет доступных сохранений", "no-screenshot-available": "Скриншот недоступен", "no-state-selected": "Состояние не выбрано", "no-states-available": "Нет доступных состояний", + "not-supported": "Не поддерживается", "play": "Играть", + "play-on": "Играть на {label}", "powered-by": "На базе", "quit": "Выйти", + "rename-memory-card": "Переименовать карту памяти", + "resume-failed": "Не удалось загрузить выбранное состояние. Игра началась заново.", "resume-from-save": "Продолжить с сохранения", "resume-from-state": "Продолжить с состояния", "save-and-quit": "Сохранить и выйти", + "save-slot": "Слот сохранения", + "save-slots": "Слоты сохранения", + "save-state": "Сохранить состояние", "select-background-color": "Выбрать цвет фона", "select-save": "Выбрать сохранение", "select-state": "Выбрать состояние", @@ -52,5 +101,6 @@ "stream-try-again": "Повторить попытку", "stream-unknown-game": "Неизвестная игра", "stream-unmute": "Включить звук", - "stream-volume": "Громкость" + "stream-volume": "Громкость", + "streaming-description": "Игра запускается в выделенном контейнере {label} и транслируется прямо в ваш браузер." } diff --git a/frontend/src/locales/tr_TR/activity.json b/frontend/src/locales/tr_TR/activity.json index b665183abc..8f9c687a77 100644 --- a/frontend/src/locales/tr_TR/activity.json +++ b/frontend/src/locales/tr_TR/activity.json @@ -10,5 +10,12 @@ "now-playing": "Şu An Oynanıyor", "playing-on": "{device} üzerinde oynuyor", "playing-since": "{time} dan beri oynanıyor", - "total-sessions": "Toplam oturum" + "release-failed": "Oturum serbest bırakılamadı", + "release-session": "Serbest bırak", + "release-session-body": "Bu, {game} oyununu hemen durduracak ve {user} bağlantısını kesecek. Kaydedilmemiş ilerleme kaybolacak.", + "release-session-title": "Yayın oturumu serbest bırakılsın mı?", + "session-released": "Oturum serbest bırakıldı", + "streaming-sessions": "Yayın oturumları", + "total-sessions": "Toplam oturum", + "unknown-user": "Bilinmeyen kullanıcı" } diff --git a/frontend/src/locales/tr_TR/play.json b/frontend/src/locales/tr_TR/play.json index d39fbc5dcd..1d283110bb 100644 --- a/frontend/src/locales/tr_TR/play.json +++ b/frontend/src/locales/tr_TR/play.json @@ -1,29 +1,78 @@ { "all-saves": "Tüm kayıtlar", "all-states": "Tüm durum kayıtları", + "autosave": "Otomatik kayıt", "back-to-gallery": "Galeriye dön", "back-to-game-details": "Oyun ayrıntılarına dön", "background-color": "Arka plan rengi", + "cancel-launch": "Başlatmayı iptal et", "change-save": "Kaydı değiştir", "change-state": "Durum kaydını değiştir", "clear-cache": "EmulatorJS Önbelleğini Temizle", "clear-cache-description": "Sunucuda depolanan kayıtlar veya durum kayıtları etkilenmez.", "clear-cache-title": "EmulatorJS önbelleğini temizlemek istediğinizden emin misiniz?", "clear-cache-warning": "Bu işlem tarayıcıda depolanan tüm kayıtları ve durum kayıtlarını siler.", + "create-memory-card": "Yeni hafıza kartı", + "delete-memory-card": "Hafıza kartını sil", + "delete-memory-card-body": "Bu işlem \"{name}\" öğesini ve tüm kayıtlı sürümlerini kalıcı olarak siler. Bu işlem geri alınamaz.", "deselect-save": "Kaydın seçimini kaldır", "deselect-state": "Durum kaydının seçimini kaldır", + "emulator": "Emülatör", + "error-hint-auth": "Yayın yapma izniniz olmayabilir veya oturumunuzun süresi dolmuş olabilir. Yeniden oturum açmayı deneyin.", + "error-hint-broker": "{label} konteyneri oyunu başlatmayı reddetti. Ayrıntılar için konteyner günlüklerini kontrol edin.", + "error-hint-network": "RomM'a ulaşılamadı. Ağ bağlantınızı ve sunucunun çalışıp çalışmadığını kontrol edin.", + "error-hint-not-configured": "Bu platform için RomM'un yayın yapılandırmasına bir konteyner ekleyin.", + "error-hint-server": "Oturum başlatılırken RomM'da beklenmeyen bir hata oluştu. RomM sunucu günlüklerini kontrol edin.", + "error-hint-unreachable": "{label} konteynerine ulaşılamadı. Konteynerin çalıştığını ve broker'ının dinlediğini kontrol edin.", + "exit-chord-hint": "Oyun sırasında çıkış menüsünü açmak için Select + Start tuşlarını bir süre basılı tutun.", + "exit-dialog-text": "Oyun hâlâ çalışıyor. Ne yapmak istersiniz?", + "exit-dialog-text-loading": "Oyun hâlâ başlatılıyor. Başlatma iptal edilsin mi?", + "exit-dialog-title": "Oyundan çıkılsın mı?", + "exit-full-screen": "Tam ekrandan çık", + "exit-without-saving": "Kaydetmeden çık", "full-screen": "Tam ekran", + "keep-playing": "Oynamaya devam et", + "load-autosave": "Otomatik kaydı yükle", + "load-state": "Durumu yükle", + "manage-memory-cards": "Hafıza kartlarını yönet", + "memory-card": "Hafıza kartı", + "memory-card-count": "{count} kart", + "memory-card-create-failed": "Hafıza kartı oluşturulamadı", + "memory-card-created": "Hafıza kartı oluşturuldu", + "memory-card-delete-failed": "Hafıza kartı silinemedi", + "memory-card-deleted": "Hafıza kartı silindi", + "memory-card-hint": "Varsayılan olarak en yeni kart yüklenir. İlerleme, çıkışta senkronize edilir.", + "memory-card-no-versions": "Henüz kayıtlı sürüm yok", + "memory-card-rename-failed": "Hafıza kartı yeniden adlandırılamadı", + "memory-card-renamed": "Hafıza kartı yeniden adlandırıldı", + "memory-card-share-failed": "Hafıza kartının paylaşımı değiştirilemedi", + "memory-card-share-label": "Diğer kullanıcılarla paylaşıldı", + "memory-card-shared": "Paylaşıldı", + "memory-card-updated": "{when} güncellendi", + "memory-card-versions": "Sürüm geçmişi", + "memory-cards": "Hafıza kartları", + "memory-cards-empty": "Bu emülatör için henüz hiç hafıza kartınız yok.", + "mute": "Sessize al", + "new-memory-card": "Yeni kart", + "no-memory-cards": "Henüz hafıza kartı yok", "no-save-selected": "Kayıt seçilmedi", "no-saves-available": "Mevcut kayıt yok", "no-screenshot-available": "Ekran görüntüsü yok", "no-state-selected": "Durum kaydı seçilmedi", "no-states-available": "Mevcut durum kaydı yok", + "not-supported": "Desteklenmiyor", "play": "Oyna", + "play-on": "{label} üzerinde oyna", "powered-by": "Tarafından desteklenmektedir", "quit": "Çık", + "rename-memory-card": "Hafıza kartını yeniden adlandır", + "resume-failed": "Seçilen durum yüklenemedi. Oyun sıfırdan başladı.", "resume-from-save": "Kayıttan devam et", "resume-from-state": "Durum kaydından devam et", "save-and-quit": "Kaydet ve çık", + "save-slot": "Kayıt yuvası", + "save-slots": "Kayıt yuvaları", + "save-state": "Durumu kaydet", "select-background-color": "Arka plan rengi seç", "select-save": "Kayıt seç", "select-state": "Durum kaydı seç", @@ -52,5 +101,6 @@ "stream-try-again": "Tekrar dene", "stream-unknown-game": "Bilinmeyen oyun", "stream-unmute": "Sesi aç", - "stream-volume": "Ses düzeyi" + "stream-volume": "Ses düzeyi", + "streaming-description": "Oyun, özel bir {label} konteynerinde çalışır ve doğrudan tarayıcınıza yayınlanır." } diff --git a/frontend/src/locales/zh_CN/activity.json b/frontend/src/locales/zh_CN/activity.json index 49dc48ccdc..4293a212fd 100644 --- a/frontend/src/locales/zh_CN/activity.json +++ b/frontend/src/locales/zh_CN/activity.json @@ -10,5 +10,12 @@ "now-playing": "正在游玩", "playing-on": "正在 {device} 上游玩", "playing-since": "自 {time} 起游玩", - "total-sessions": "总会话数" + "release-failed": "无法释放会话", + "release-session": "释放", + "release-session-body": "这将立即停止 {game} 并断开 {user} 的连接。未保存的进度将丢失。", + "release-session-title": "释放串流会话?", + "session-released": "会话已释放", + "streaming-sessions": "串流会话", + "total-sessions": "总会话数", + "unknown-user": "未知用户" } diff --git a/frontend/src/locales/zh_CN/play.json b/frontend/src/locales/zh_CN/play.json index 781b42a3fc..b7914e2f6b 100644 --- a/frontend/src/locales/zh_CN/play.json +++ b/frontend/src/locales/zh_CN/play.json @@ -1,29 +1,78 @@ { "all-saves": "所有存档", "all-states": "所有状态", + "autosave": "自动存档", "back-to-gallery": "返回游戏库", "back-to-game-details": "返回游戏详情", "background-color": "背景颜色", + "cancel-launch": "取消启动", "change-save": "更改存档", "change-state": "更改状态", "clear-cache": "清除 EmulatorJS 缓存", "clear-cache-description": "这不会影响服务器上存储的任何保存或状态。", "clear-cache-title": "您确定要清除 EmulatorJS 缓存吗?", "clear-cache-warning": "这将删除浏览器中存储的所有保存和状态。", + "create-memory-card": "新建记忆卡", + "delete-memory-card": "删除记忆卡", + "delete-memory-card-body": "这将永久删除\"{name}\"及其所有已保存的版本。此操作无法撤消。", "deselect-save": "取消选择存档", "deselect-state": "取消选择状态", + "emulator": "模拟器", + "error-hint-auth": "您可能没有串流权限,或者登录会话已过期。请尝试重新登录。", + "error-hint-broker": "{label} 容器拒绝启动游戏。请查看容器日志以了解详情。", + "error-hint-network": "无法连接到 RomM。请检查您的网络连接以及服务器是否正在运行。", + "error-hint-not-configured": "请为此平台在 RomM 的串流配置中添加一个容器。", + "error-hint-server": "RomM 在启动会话时遇到意外错误。请查看 RomM 服务器日志。", + "error-hint-unreachable": "无法连接到 {label} 容器。请检查该容器是否正在运行,以及其代理是否正在监听。", + "exit-chord-hint": "游戏中按住 Select + Start 片刻即可打开退出菜单。", + "exit-dialog-text": "游戏仍在运行。您想做什么?", + "exit-dialog-text-loading": "游戏仍在启动中。要取消启动吗?", + "exit-dialog-title": "退出游戏?", + "exit-full-screen": "退出全屏", + "exit-without-saving": "不保存退出", "full-screen": "全屏", + "keep-playing": "继续游戏", + "load-autosave": "加载自动存档", + "load-state": "加载即时存档", + "manage-memory-cards": "管理记忆卡", + "memory-card": "记忆卡", + "memory-card-count": "{count} 张", + "memory-card-create-failed": "无法创建记忆卡", + "memory-card-created": "已创建记忆卡", + "memory-card-delete-failed": "无法删除记忆卡", + "memory-card-deleted": "已删除记忆卡", + "memory-card-hint": "默认加载最新的记忆卡。退出时会同步进度。", + "memory-card-no-versions": "尚无已保存的版本", + "memory-card-rename-failed": "无法重命名记忆卡", + "memory-card-renamed": "已重命名记忆卡", + "memory-card-share-failed": "无法更改记忆卡的共享设置", + "memory-card-share-label": "与其他用户共享", + "memory-card-shared": "已共享", + "memory-card-updated": "更新于 {when}", + "memory-card-versions": "版本历史", + "memory-cards": "记忆卡", + "memory-cards-empty": "您还没有此模拟器的记忆卡。", + "mute": "静音", + "new-memory-card": "新建卡", + "no-memory-cards": "尚无记忆卡", "no-save-selected": "未选择存档", "no-saves-available": "无可用存档", "no-screenshot-available": "无可用截图", "no-state-selected": "未选择状态", "no-states-available": "无可用状态", + "not-supported": "不支持", "play": "游玩", + "play-on": "在 {label} 上游玩", "powered-by": "技术支持", "quit": "退出", + "rename-memory-card": "重命名记忆卡", + "resume-failed": "无法加载所选的存档状态。游戏已重新开始。", "resume-from-save": "从存档继续", "resume-from-state": "从状态继续", "save-and-quit": "保存并退出", + "save-slot": "存档位", + "save-slots": "存档位", + "save-state": "保存即时存档", "select-background-color": "选择背景颜色", "select-save": "选择存档", "select-state": "选择状态", @@ -52,5 +101,6 @@ "stream-try-again": "重试", "stream-unknown-game": "未知游戏", "stream-unmute": "取消静音", - "stream-volume": "音量" + "stream-volume": "音量", + "streaming-description": "游戏在专用的 {label} 容器中运行,并直接串流到您的浏览器。" } diff --git a/frontend/src/locales/zh_TW/activity.json b/frontend/src/locales/zh_TW/activity.json index a7b951074c..f38ebc3d82 100644 --- a/frontend/src/locales/zh_TW/activity.json +++ b/frontend/src/locales/zh_TW/activity.json @@ -10,5 +10,12 @@ "now-playing": "正在遊玩", "playing-on": "正在 {device} 上遊玩", "playing-since": "自 {time} 起遊玩", - "total-sessions": "工作階段總數" + "release-failed": "無法釋放工作階段", + "release-session": "釋放", + "release-session-body": "這將立即停止 {game} 並中斷 {user} 的連線。未儲存的進度將會遺失。", + "release-session-title": "釋放串流工作階段?", + "session-released": "工作階段已釋放", + "streaming-sessions": "串流工作階段", + "total-sessions": "工作階段總數", + "unknown-user": "未知使用者" } diff --git a/frontend/src/locales/zh_TW/play.json b/frontend/src/locales/zh_TW/play.json index 7014091185..5dde59627f 100644 --- a/frontend/src/locales/zh_TW/play.json +++ b/frontend/src/locales/zh_TW/play.json @@ -1,29 +1,78 @@ { "all-saves": "所有存檔", "all-states": "所有即時存檔", + "autosave": "自動存檔", "back-to-gallery": "返回遊戲庫", "back-to-game-details": "返回遊戲詳情", "background-color": "背景顏色", + "cancel-launch": "取消啟動", "change-save": "更改存檔", "change-state": "更改即時存檔", "clear-cache": "清除 EmulatorJS 快取", "clear-cache-description": "不會影響伺服器上儲存的任何存檔和狀態。", "clear-cache-title": "您確定要清除 EmulatorJS 快取嗎?", "clear-cache-warning": "將刪除瀏覽器中儲存的所有存檔和狀態。", + "create-memory-card": "新增記憶卡", + "delete-memory-card": "刪除記憶卡", + "delete-memory-card-body": "這將永久刪除「{name}」及其所有已儲存的版本。此操作無法復原。", "deselect-save": "取消選擇存檔", "deselect-state": "取消選擇即時存檔", + "emulator": "模擬器", + "error-hint-auth": "您可能沒有串流權限,或登入工作階段已過期。請嘗試重新登入。", + "error-hint-broker": "{label} 容器拒絕啟動遊戲。請查看容器記錄以瞭解詳情。", + "error-hint-network": "無法連線到 RomM。請檢查您的網路連線以及伺服器是否正在執行。", + "error-hint-not-configured": "請為此平台在 RomM 的串流設定中新增一個容器。", + "error-hint-server": "RomM 在啟動工作階段時發生意外錯誤。請查看 RomM 伺服器記錄。", + "error-hint-unreachable": "無法連線到 {label} 容器。請檢查該容器是否正在執行,以及其代理是否正在監聽。", + "exit-chord-hint": "遊戲中按住 Select + Start 片刻即可開啟退出選單。", + "exit-dialog-text": "遊戲仍在執行中。您想怎麼做?", + "exit-dialog-text-loading": "遊戲仍在啟動中。要取消啟動嗎?", + "exit-dialog-title": "退出遊戲?", + "exit-full-screen": "結束全螢幕", + "exit-without-saving": "不儲存退出", "full-screen": "全螢幕", + "keep-playing": "繼續遊戲", + "load-autosave": "載入自動存檔", + "load-state": "載入即時存檔", + "manage-memory-cards": "管理記憶卡", + "memory-card": "記憶卡", + "memory-card-count": "{count} 張", + "memory-card-create-failed": "無法建立記憶卡", + "memory-card-created": "已建立記憶卡", + "memory-card-delete-failed": "無法刪除記憶卡", + "memory-card-deleted": "已刪除記憶卡", + "memory-card-hint": "預設載入最新的記憶卡。離開時會同步進度。", + "memory-card-no-versions": "尚無已儲存的版本", + "memory-card-rename-failed": "無法重新命名記憶卡", + "memory-card-renamed": "已重新命名記憶卡", + "memory-card-share-failed": "無法變更記憶卡的分享設定", + "memory-card-share-label": "與其他使用者分享", + "memory-card-shared": "已分享", + "memory-card-updated": "更新於 {when}", + "memory-card-versions": "版本歷史", + "memory-cards": "記憶卡", + "memory-cards-empty": "您還沒有此模擬器的記憶卡。", + "mute": "靜音", + "new-memory-card": "新增卡", + "no-memory-cards": "尚無記憶卡", "no-save-selected": "未選擇存檔", "no-saves-available": "無可用存檔", "no-screenshot-available": "沒有可用的截圖", "no-state-selected": "未選擇即時存檔", "no-states-available": "無可用即時存檔", + "not-supported": "不支援", "play": "遊玩", + "play-on": "在 {label} 上遊玩", "powered-by": "技術提供", "quit": "退出", + "rename-memory-card": "重新命名記憶卡", + "resume-failed": "無法載入所選的存檔狀態。遊戲已重新開始。", "resume-from-save": "從存檔繼續", "resume-from-state": "從即時存檔繼續", "save-and-quit": "保存並退出", + "save-slot": "存檔格", + "save-slots": "存檔格", + "save-state": "儲存即時存檔", "select-background-color": "選擇背景顏色", "select-save": "選擇存檔", "select-state": "選擇即時存檔", @@ -52,5 +101,6 @@ "stream-try-again": "重試", "stream-unknown-game": "未知遊戲", "stream-unmute": "取消靜音", - "stream-volume": "音量" + "stream-volume": "音量", + "streaming-description": "遊戲在專用的 {label} 容器中執行,並直接串流到您的瀏覽器。" } diff --git a/frontend/src/services/api/memory-card.ts b/frontend/src/services/api/memory-card.ts new file mode 100644 index 0000000000..0f5e921ab2 --- /dev/null +++ b/frontend/src/services/api/memory-card.ts @@ -0,0 +1,65 @@ +import type { + MemoryCardCreatePayload, + MemoryCardSchema, + MemoryCardVersionSchema, + UserMemoryCardSchema, +} from "@/__generated__"; +import api from "@/services/api"; + +export const memoryCardApi = api; + +// A user's own cards, newest-synced first, optionally scoped to one emulator. +async function getMemoryCards({ emulator }: { emulator?: string } = {}) { + return api.get("/memory-cards", { + params: { emulator }, + }); +} + +// Cards visible to the caller for an emulator: their own plus other users' +// public ones, each enriched with the owner's username. +async function getSharedMemoryCards({ emulator }: { emulator: string }) { + return api.get("/memory-cards/shared", { + params: { emulator }, + }); +} + +// A card's snapshot history, newest first. +async function getMemoryCardVersions({ id }: { id: number }) { + return api.get(`/memory-cards/${id}/versions`); +} + +async function createMemoryCard(payload: MemoryCardCreatePayload) { + return api.post("/memory-cards", payload); +} + +async function renameMemoryCard({ id, name }: { id: number; name: string }) { + return api.put(`/memory-cards/${id}`, { name }); +} + +async function setMemoryCardVisibility({ + id, + isPublic, +}: { + id: number; + isPublic: boolean; +}) { + return api.put(`/memory-cards/${id}/visibility`, { + is_public: isPublic, + }); +} + +async function deleteMemoryCards({ cards }: { cards: MemoryCardSchema[] }) { + return api.post("/memory-cards/delete", { + cards: cards.map((c) => c.id), + }); +} + +export default { + getMemoryCards, + getSharedMemoryCards, + getMemoryCardVersions, + createMemoryCard, + renameMemoryCard, + setMemoryCardVisibility, + deleteMemoryCards, +}; diff --git a/frontend/src/services/api/streaming.ts b/frontend/src/services/api/streaming.ts index 147a53a44e..49a8c49c12 100644 --- a/frontend/src/services/api/streaming.ts +++ b/frontend/src/services/api/streaming.ts @@ -1,3 +1,4 @@ +import { default as Cookies } from "js-cookie"; import api from "@/services/api"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -12,7 +13,11 @@ export interface StreamingContainer { platform: string; // "ps2" host: string; // "http://192.168.1.50:3000" label: string; // "PCSX2" + emulator: string; // state namespace, e.g. "pcsx2", matches State.emulator capabilities: PlatformCapabilities; + // Whether this container syncs whole memory cards (whole-card sync). Gates + // the memory-card picker; false/absent for containers without it. + supports_memory_cards?: boolean; } export interface StreamingConfig { @@ -26,6 +31,23 @@ export interface ActiveSession { label: string; rom_name: string; claimed_at: string; + // true: resume state delivered to the broker; false: resume requested but + // the push failed (fresh launch); null: no resume requested. + resume: boolean | null; +} + +// Entry of the admin-only GET /streaming/sessions list. Nullable fields +// cover sessions claimed before a config change (container removed) or +// records written by an older backend (no platform stored). +export interface AdminStreamingSession { + container: string; + label: string | null; + platform: string | null; + rom_id: number | null; + rom_name: string | null; + claimed_at: string | null; + user_id: number | null; + username: string | null; } // ── Requests ────────────────────────────────────────────────────────────────── @@ -36,8 +58,16 @@ async function fetchConfig() { }); } -async function claimSession(romId: number) { - return api.post("/streaming/sessions", { rom_id: romId }); +async function claimSession( + romId: number, + stateId?: number, + memoryCardId?: number, +) { + return api.post("/streaming/sessions", { + rom_id: romId, + ...(stateId !== undefined ? { state_id: stateId } : {}), + ...(memoryCardId !== undefined ? { memory_card_id: memoryCardId } : {}), + }); } async function releaseSession(platform: string) { @@ -51,6 +81,10 @@ async function saveAndExit(platform: string, slot = 0, wait = true) { }); } +async function heartbeatSession(platform: string) { + return api.post(`/streaming/sessions/${platform}/heartbeat`); +} + async function setVolume(platform: string, level: number) { return api.post(`/streaming/sessions/${platform}/volume`, { level: Math.round(level), @@ -72,13 +106,53 @@ async function loadState(platform: string, slot = 1) { return api.post(`/streaming/sessions/${platform}/load-state`, { slot }); } +async function adminListSessions() { + return api.get<{ sessions: AdminStreamingSession[] }>("/streaming/sessions"); +} + +// ── Unload-path requests ────────────────────────────────────────────────────── +// On pagehide the page may die before an axios request leaves, so these use +// fetch keepalive, which the browser completes after the page is gone. +// sendBeacon cannot carry the CSRF header, so the cookie-sourced header is +// set by hand (mirrors the axios interceptor). + +function keepaliveHeaders(): Record { + return { + "Content-Type": "application/json", + "x-csrftoken": Cookies.get("romm_csrftoken") ?? "", + }; +} + +function saveAndExitKeepalive(platform: string, slot = 0): void { + void fetch(`/api/streaming/sessions/${platform}/save-and-exit`, { + method: "POST", + keepalive: true, + credentials: "same-origin", + headers: keepaliveHeaders(), + body: JSON.stringify({ slot, wait: false }), + }); +} + +function releaseSessionKeepalive(platform: string): void { + void fetch(`/api/streaming/sessions/${platform}`, { + method: "DELETE", + keepalive: true, + credentials: "same-origin", + headers: keepaliveHeaders(), + }); +} + export default { fetchConfig, claimSession, releaseSession, saveAndExit, + heartbeatSession, setVolume, setMute, saveState, loadState, + adminListSessions, + saveAndExitKeepalive, + releaseSessionKeepalive, }; diff --git a/frontend/src/stores/streaming.ts b/frontend/src/stores/streaming.ts index bb408b79e7..94acd018b1 100644 --- a/frontend/src/stores/streaming.ts +++ b/frontend/src/stores/streaming.ts @@ -3,12 +3,14 @@ import { ref, computed } from "vue"; import streamingApi from "@/services/api/streaming"; import type { ActiveSession, + AdminStreamingSession, StreamingConfig, StreamingContainer, } from "@/services/api/streaming"; export type { ActiveSession, + AdminStreamingSession, PlatformCapabilities, StreamingConfig, StreamingContainer, @@ -96,14 +98,28 @@ export const useStreamingStore = defineStore("streaming", () => { * Claim a streaming session for a ROM. The backend derives the platform, * filesystem path, and display name from the ROM id - the client never * sends a path. + * Pass stateId to resume from a specific save state: the backend pushes + * its file to the broker and the emulator loads it once the game is up. + * The response's `resume` field reports whether that succeeded. + * Pass memoryCardId to hydrate a specific memory card (else the backend + * picks the user's newest card for the emulator, or auto-creates a blank + * one). The chosen card is wiped-then-replaced onto the container at claim. * Returns the session data (including the container host URL) on success. - * Throws an error with a `status` property on failure: - * 409 session in use - error has who/what is playing + * Throws the raw axios error on failure: + * 409 session in use - response detail has who/what is playing * 404 - ROM or platform container not configured * 503 - broker/unreachable */ - async function claimSession(romId: number): Promise { - const { data } = await streamingApi.claimSession(romId); + async function claimSession( + romId: number, + stateId?: number, + memoryCardId?: number, + ): Promise { + const { data } = await streamingApi.claimSession( + romId, + stateId, + memoryCardId, + ); activeSession.value = data; return data; } @@ -131,21 +147,97 @@ export const useStreamingStore = defineStore("streaming", () => { * Save game state then release the session. * wait=true (default): blocks until broker confirms save+kill - use for explicit button press. * wait=false: broker fires save+kill in background, returns immediately - use for navigation away. - * Drops the local session record only on a confirmed release so a failed - * call doesn't leave the user wedged behind their own still-held session. + * released: the request succeeded, so the backend dropped the claim + * (it always releases on success, even when the save itself failed). + * saved: the broker confirmed the state save. + * released=false means the request failed and the claim may still be live - + * callers should fall back to releaseSession. */ async function saveAndExit( platform: string, slot = 0, wait = true, - ): Promise { - if (!platform) return false; + ): Promise<{ released: boolean; saved: boolean }> { + if (!platform) return { released: false, saved: false }; try { const { data } = await streamingApi.saveAndExit(platform, slot, wait); activeSession.value = null; - return data.saved ?? false; + return { released: true, saved: data.saved ?? false }; } catch (err) { console.warn("[streaming] Could not save-and-exit:", err); + return { released: false, saved: false }; + } + } + + /** + * Refresh the session's liveness stamp so the backend does not treat it as + * abandoned. Called periodically while playing. Best-effort, never throws. + */ + async function heartbeatSession(platform: string): Promise { + if (!platform) return; + try { + await streamingApi.heartbeatSession(platform); + } catch (err) { + console.warn("[streaming] Could not heartbeat session:", err); + } + } + + /** + * saveAndExit for the pagehide path. Fire-and-forget via fetch keepalive: + * the broker save+kill runs server-side to completion even though the page + * is gone (wait=false; the backend forces a blocking save for card-sync + * containers anyway). Best-effort, never throws. + */ + function saveAndExitKeepalive(platform: string, slot = 0): void { + if (!platform) return; + activeSession.value = null; + try { + streamingApi.saveAndExitKeepalive(platform, slot); + } catch (err) { + console.warn("[streaming] Could not save-and-exit (keepalive):", err); + } + } + + /** + * releaseSession for the pagehide path. Fire-and-forget via fetch + * keepalive. Best-effort, never throws. + */ + function releaseSessionKeepalive(platform: string): void { + if (!platform) return; + activeSession.value = null; + try { + streamingApi.releaseSessionKeepalive(platform); + } catch (err) { + console.warn("[streaming] Could not release session (keepalive):", err); + } + } + + /** + * List all active streaming sessions across every container. Admin only. + * Best-effort, never throws, returns [] on failure. + */ + async function adminListSessions(): Promise { + try { + const { data } = await streamingApi.adminListSessions(); + return data.sessions ?? []; + } catch (err) { + console.warn("[streaming] Could not list sessions:", err); + return []; + } + } + + /** + * Force-release another user's session by platform. Admin only. + * Does not touch local activeSession state - the target session belongs + * to someone else. Returns whether the release succeeded. + */ + async function adminReleaseSession(platform: string): Promise { + if (!platform) return false; + try { + await streamingApi.releaseSession(platform); + return true; + } catch (err) { + console.warn("[streaming] Could not release session:", err); return false; } } @@ -162,5 +254,10 @@ export const useStreamingStore = defineStore("streaming", () => { claimSession, releaseSession, saveAndExit, + heartbeatSession, + saveAndExitKeepalive, + releaseSessionKeepalive, + adminListSessions, + adminReleaseSession, }; }); diff --git a/frontend/src/v2/components/Player/AssetPreview.vue b/frontend/src/v2/components/Player/AssetPreview.vue index 06675b142d..adcb492424 100644 --- a/frontend/src/v2/components/Player/AssetPreview.vue +++ b/frontend/src/v2/components/Player/AssetPreview.vue @@ -70,11 +70,19 @@ const emptyText = computed(() => > @@ -243,4 +347,61 @@ function elapsedLabel(startedAt: string): string { html[data-bp~="xs"] .r-v2-activity__grid { gap: 16px 10px; } + +/* Admin streaming-session panel: plain rows, not cards; these are + operational controls, not part of the presence board. */ +.r-v2-activity__streaming { + margin-top: 32px; +} + +.r-v2-activity__streaming-title { + margin-bottom: 12px; + font-size: var(--r-font-size-md); + font-weight: var(--r-font-weight-bold); + color: var(--r-color-fg); +} + +.r-v2-activity__streaming-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.r-v2-activity__session { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-radius: var(--r-radius-card); + background: var(--r-color-surface); + border: 1px solid var(--r-color-border); +} + +.r-v2-activity__session-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.r-v2-activity__session-title { + font-weight: var(--r-font-weight-bold); + color: var(--r-color-fg); +} + +.r-v2-activity__session-rom { + color: var(--r-color-fg-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.r-v2-activity__session-meta { + font-size: var(--r-font-size-sm); + color: var(--r-color-fg-faint); +} diff --git a/frontend/src/v2/views/Gallery/Platform.vue b/frontend/src/v2/views/Gallery/Platform.vue index a265ce4d63..bf1a764d2a 100644 --- a/frontend/src/v2/views/Gallery/Platform.vue +++ b/frontend/src/v2/views/Gallery/Platform.vue @@ -25,12 +25,14 @@ import { ROUTES } from "@/plugins/router"; import platformApi from "@/services/api/platform"; import romApi from "@/services/api/rom"; import storePlatforms, { type Platform } from "@/stores/platforms"; +import { useStreamingStore } from "@/stores/streaming"; import { formatBytes } from "@/utils"; import FirmwareTab from "@/v2/components/Gallery/FirmwareTab.vue"; import GalleryShell from "@/v2/components/Gallery/GalleryShell.vue"; import PlatformHead from "@/v2/components/Gallery/PlatformHead.vue"; import ScanPlatformDialog from "@/v2/components/Gallery/ScanPlatformDialog.vue"; import SettingsTab from "@/v2/components/Gallery/SettingsTab.vue"; +import MemoryCardManager from "@/v2/components/Player/MemoryCardManager.vue"; import { useCan } from "@/v2/composables/useCan"; import { useConfirm } from "@/v2/composables/useConfirm"; import { useSnackbar } from "@/v2/composables/useSnackbar"; @@ -43,6 +45,7 @@ const platformsStore = storePlatforms(); const galleryRoms = storeGalleryRoms(); const snackbar = useSnackbar(); const confirm = useConfirm(); +const streamingStore = useStreamingStore(); const { currentPlatform, total } = storeToRefs(galleryRoms); const notFound = ref(false); @@ -60,8 +63,13 @@ const canDownload = useCan("rom.download"); // ── Tabs ───────────────────────────────────────────────────────── // URL-persistent via `?tab=` (mirrors the GameDetails pattern). The // default tab is `library`. -type TabId = "library" | "firmware" | "settings"; -const VALID_TABS = new Set(["library", "firmware", "settings"]); +type TabId = "library" | "firmware" | "settings" | "memory-cards"; +const VALID_TABS = new Set([ + "library", + "firmware", + "settings", + "memory-cards", +]); function parseTab(v: unknown): TabId { return typeof v === "string" && VALID_TABS.has(v as TabId) @@ -69,6 +77,14 @@ function parseTab(v: unknown): TabId { : "library"; } +// The memory-card tab only exists for platforms whose streaming container +// syncs whole cards (PCSX2 today). `emulator` is the hard key the manager +// fetches by; null means "no card tab for this platform". +const memoryCardEmulator = computed(() => { + const c = streamingStore.containerForPlatform(currentPlatform.value?.slug); + return c?.supports_memory_cards ? c.emulator : null; +}); + const tab = ref(parseTab(route.query.tab)); watch(tab, (value) => { if (route.query.tab !== value) { @@ -89,9 +105,26 @@ watch( const tabs = computed(() => [ { id: "library", label: t("common.library") }, { id: "firmware", label: t("platform.firmware-bios") }, + ...(memoryCardEmulator.value + ? [{ id: "memory-cards", label: t("play.memory-cards") }] + : []), { id: "settings", label: t("platform.settings") }, ]); +// Guard a stale `?tab=memory-cards` deep link on a platform that doesn't +// support cards (or once its container is removed): fall back to library. +// Wait for the streaming config to finish loading first, so a hard-refresh +// deep link isn't bounced before `memoryCardEmulator` can resolve. +watch( + [tab, memoryCardEmulator, () => streamingStore.loading], + ([current, emulator, loadingConfig]) => { + if (!loadingConfig && current === "memory-cards" && !emulator) { + tab.value = "library"; + } + }, + { immediate: true }, +); + const headLabels = computed(() => ({ upload: t("platform.upload-roms"), scan: t("platform.scan-platform"), @@ -471,6 +504,11 @@ async function onDelete() {
+ -// Player for the emulator streaming framework. A native -// emulator (PCSX2 / Dolphin / xemu / Eden) runs in a separate container -// with a Selkies WebRTC stream. RomM claims a session through the -// backend `/api/streaming` endpoints and displays the stream in an -// iframe pointed at the container's web UI. Session lifecycle, save -// state control, and volume are proxied to a broker sidecar inside -// that container via the shared `useStreamingStore`. +// Stream: v2 player for containerized emulator streaming. A native +// emulator runs in a separate container with a Selkies WebRTC stream; +// RomM claims a session through the `/api/streaming` endpoints and +// shows the stream in an iframe pointed at the container's web UI. // -// Layout — two states, mirroring the Ruffle/EmulatorJS players: -// 1. Launch screen: hero cover + title + Play CTA + back links, plus -// an in-use / error alert when the session can't be claimed. -// 2. Active player: full-bleed iframe with an auto-hiding control -// bar (volume, save/load state, fullscreen, save-and-exit, stop). +// Layout mirrors the EmulatorJS view, three columns pre-game: +// 1. Hero: cover + title + "Play on " CTA + back links. +// 2. Session: where the game runs, save-slot capabilities, and any +// claim errors (occupied / not configured / server). +// 3. Setup: default save slot + fullscreen-on-play. // -// The streaming store owns the session state. This view only owns the -// local player chrome (UI visibility, fullscreen, pending flags). -import { RAlert, RBtn, RIcon, RSpinner } from "@v2/lib"; -import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; +// The running state is a fixed stage hosting the Selkies iframe with an +// auto-hiding control bar (volume, save/load state, fullscreen, exit). +import { + RAlert, + RBtn, + RCard, + RDialog, + RIcon, + RSelect, + RSlider, + RSwitch, +} from "@v2/lib"; +import { isAxiosError } from "axios"; +import { + computed, + nextTick, + onBeforeUnmount, + onMounted, + ref, + watch, +} from "vue"; import { useI18n } from "vue-i18n"; -import { useRoute, useRouter } from "vue-router"; +import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router"; +import type { UserStateSchema } from "@/__generated__"; import { ROUTES } from "@/plugins/router"; import romApi from "@/services/api/rom"; import streamingApi from "@/services/api/streaming"; +import socket from "@/services/socket"; +import storeAuth from "@/stores/auth"; import storePlaying from "@/stores/playing"; import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms"; import { useStreamingStore } from "@/stores/streaming"; +import AssetPreview from "@/v2/components/Player/AssetPreview.vue"; +import MemoryCardPicker from "@/v2/components/Player/MemoryCardPicker.vue"; +import AssetStrip from "@/v2/components/shared/AssetStrip.vue"; import GameCover from "@/v2/components/shared/GameCover.vue"; import { useBackgroundArt } from "@/v2/composables/useBackgroundArt"; +import { useCoverArt } from "@/v2/composables/useCoverArt"; +import { useFullscreenPref } from "@/v2/composables/useFullscreenPref"; +import { useInputModality } from "@/v2/composables/useInputModality"; import { useSnackbar } from "@/v2/composables/useSnackbar"; import storeGalleryRoms from "@/v2/stores/galleryRoms"; @@ -37,18 +60,18 @@ type ErrorType = const { t } = useI18n(); const route = useRoute(); const router = useRouter(); +const auth = storeAuth(); +const playingStore = storePlaying(); const streamingStore = useStreamingStore(); const snackbar = useSnackbar(); -const setBgArt = useBackgroundArt(); - -const romsStore = storeRoms(); -const galleryRoms = storeGalleryRoms(); -const playingStore = storePlaying(); +const { fullscreenOnPlay } = useFullscreenPref(); +const { modality } = useInputModality(); const rom = ref(null); const playerState = ref("idle"); const errorType = ref(null); const errorMessage = ref(""); +const errorHint = ref(""); const occupiedBy = ref<{ rom_name: string; claimed_at: string } | null>(null); const containerHost = ref(""); const isFullscreen = ref(false); @@ -58,33 +81,73 @@ const isSavingState = ref(false); const isLoadingState = ref(false); const isLoadingAutosave = ref(false); const selectedSlot = ref(1); -const volume = ref(1); +const volume = ref(100); const isMuted = ref(false); -const playerWrapper = ref(null); -const streamFrame = ref(null); +const gameRunning = computed(() => playerState.value === "playing"); -const romId = computed(() => Number(route.params.rom)); +// While a session is active (launching or playing) the emulator owns the +// controller: the global playing flag mutes useGamepad's UI translation, +// which would otherwise treat B as history-back (ending the session) and +// Start as the user menu. The exit dialog is the sanctioned way out. +const sessionActive = computed( + () => playerState.value === "playing" || playerState.value === "loading", +); +watch(sessionActive, (active) => playingStore.setPlaying(active)); -// Seed synchronously so the hero cover is in the DOM when the view -// transition captures this view and the morph pairs on entry. From -// GameDetails the full DetailedRom is in `currentRom`; on a direct -// gallery→play only a SimpleRom exists, so seed a cover-only `heroSeed` -// (`rom` stays null until `onMounted` refetches). See EmulatorJS / Ruffle -// for the same pattern. -if (romsStore.currentRom && romsStore.currentRom.id === romId.value) { - rom.value = romsStore.currentRom; -} +// Rom id straight from the route param (available before `rom` resolves), +// so the hero cover paints its `view-transition-name` immediately and the +// shared-element morph from the gallery / details cover pairs on entry. +const morphRomId = computed(() => { + const r = route.params.rom; + return typeof r === "string" ? r : null; +}); +// Seed synchronously so the hero cover is already in the DOM when the view +// transition captures this view (same pattern as the EmulatorJS view). +const seededRom = storeRoms().currentRom; +if (seededRom && String(seededRom.id) === morphRomId.value) { + rom.value = seededRom; +} const heroSeed = ref(null); -if (!rom.value && romId.value != null) { - heroSeed.value = galleryRoms.getRomById(romId.value); +if (!rom.value && morphRomId.value != null) { + heroSeed.value = storeGalleryRoms().getRomById(Number(morphRomId.value)); } - const heroRom = computed( () => rom.value ?? heroSeed.value, ); +const setBgArt = useBackgroundArt(); + +// Alt-art detection only drives the purple glow: a floating disc / +// cartridge reads better without a frame (same rule as EmulatorJS). +const art = useCoverArt(() => heroRom.value); +const heroIsAlt = computed( + () => + art.style.value !== "cover_path" && + !!(art.coverUrl.value ?? art.fallbackUrl.value), +); +const coverRef = ref | null>(null); + +const bgCoverUrl = computed(() => { + const r = rom.value; + if (!r) return null; + return r.path_cover_large ?? r.path_cover_small ?? r.url_cover ?? null; +}); + +// Background art keeps the plain 2D cover while the launch screen is up; +// clear it once the player goes full-bleed so the stream isn't fought +// by a blurred backdrop behind the iframe. +watch( + bgCoverUrl, + (url) => setBgArt(playerState.value === "playing" ? null : url), + { immediate: true }, +); + +watch(playerState, (state) => + setBgArt(state === "playing" ? null : bgCoverUrl.value), +); + const container = computed(() => rom.value ? streamingStore.containerForPlatform(rom.value.platform_slug) @@ -95,8 +158,50 @@ const capabilities = computed(() => streamingStore.platformCapabilities(rom.value?.platform_slug), ); -// If the platform changes mid-session and the new one has fewer slots, -// clamp selectedSlot so we never send an out-of-range value to the broker. +// ── Resume-from-state picker ──────────────────────────────────────── +// States the container's emulator can resume from: the user's own plus +// other users' public ones (that is what all_user_states carries), kept +// to this emulator's namespace so EmulatorJS states stay out. The list +// arrives newest-first from the backend. +const selectedState = ref(null); + +const streamStates = computed(() => { + const emulator = container.value?.emulator?.toLowerCase(); + if (!rom.value || !emulator) return []; + return (rom.value.all_user_states ?? []).filter( + (s) => (s.emulator ?? "").toLowerCase() === emulator, + ); +}); + +// Preselect the newest state so Play resumes where the user left off; +// clearing the preview (start fresh) is one click away. +watch( + streamStates, + (states) => { + const current = selectedState.value; + if (current && !states.some((s) => s.id === current.id)) { + selectedState.value = null; + } + if (!selectedState.value && states.length > 0) { + selectedState.value = states[0]; + } + }, + { immediate: true }, +); + +function pickState(state: UserStateSchema): void { + selectedState.value = state; +} + +// ── Memory card picker (whole-card sync) ──────────────────────────── +// Which card to hydrate onto the container at claim. Only shown for +// containers that sync whole cards (PCSX2). Null means "let the backend +// pick the newest / auto-create a blank one". MemoryCardPicker owns the +// fetch + default-newest selection; we just carry the id to claim. +const selectedMemoryCardId = ref(null); + +// Clamp so a platform switch to fewer slots never sends an +// out-of-range slot to the broker. watch( () => capabilities.value.maxSlots, (max) => { @@ -104,52 +209,101 @@ watch( }, ); -const title = computed(() => heroRom.value?.name ?? ""); -const platformLabel = computed( - () => container.value?.label ?? rom.value?.platform_slug?.toUpperCase() ?? "", +const slotItems = computed(() => + Array.from({ length: capabilities.value.maxSlots }, (_, i) => ({ + title: t("play.stream-slot-n", { n: i + 1 }), + value: i + 1, + })), ); -const backRoute = computed(() => - rom.value - ? { name: ROUTES.ROM, params: { rom: rom.value.id } } - : { name: ROUTES.HOME }, +const title = computed( + () => heroRom.value?.name || heroRom.value?.fs_name_no_ext || "", ); -// Background art keeps the plain 2D cover while the launch screen is up; -// clear it once the player goes full-bleed so the stream isn't fought -// by a blurred backdrop behind the iframe. -const bgCoverUrl = computed(() => { - if (!rom.value) return null; - return ( - rom.value.path_cover_large ?? - rom.value.path_cover_small ?? - rom.value.url_cover ?? - null - ); -}); - -watch( - bgCoverUrl, - (url) => setBgArt(playerState.value === "playing" ? null : url), - { - immediate: true, - }, +const platformLabel = computed( + () => + heroRom.value?.platform_custom_name || + heroRom.value?.platform_display_name || + rom.value?.platform_slug?.toUpperCase() || + "", ); -watch(playerState, (state) => - setBgArt(state === "playing" ? null : bgCoverUrl.value), +const emulatorLabel = computed( + () => container.value?.label ?? platformLabel.value, ); -// While a session is launching or playing the emulator owns the controller: -// this flag mutes useGamepad's pad->UI translation so button presses reach -// the stream instead of navigating (or closing) the RomM UI. -const sessionActive = computed( - () => playerState.value === "playing" || playerState.value === "loading", -); -watch(sessionActive, (active) => playingStore.setPlaying(active)); +function focusPlayButton() { + const btn = document.querySelector(".r-v2-stream__play"); + btn?.focus({ preventScroll: true }); +} + +// ── Live activity ("now playing") ────────────────────────────────── +const ACTIVITY_HEARTBEAT_MS = 30_000; +let activityHeartbeatTimer: ReturnType | null = null; + +function activityDeviceId(): string { + return auth.user?.current_device_id ?? "web"; +} + +function emitActivityStart() { + if (!auth.user || !rom.value) return; + if (!socket.connected) socket.connect(); + socket.emit("activity:start", { + rom_id: rom.value.id, + device_id: activityDeviceId(), + }); +} + +function emitActivityHeartbeat() { + if (!auth.user || !rom.value) return; + socket.emit("activity:heartbeat", { + rom_id: rom.value.id, + device_id: activityDeviceId(), + }); + // Also refresh the backend claim's liveness stamp: a session whose + // heartbeat stops long enough counts as abandoned and can be taken over. + if (sessionActive.value) { + void streamingStore.heartbeatSession(rom.value.platform_slug); + } +} + +function emitActivityStop() { + if (!auth.user) return; + socket.emit("activity:stop", { + device_id: activityDeviceId(), + }); +} + +function startActivityHeartbeat() { + if (activityHeartbeatTimer) return; + activityHeartbeatTimer = setInterval( + emitActivityHeartbeat, + ACTIVITY_HEARTBEAT_MS, + ); +} -// Sync volume slider (0-1) and mute button to the broker in real time. -// Debounced via watch — only fires after the value settles for 150ms. +function stopActivityHeartbeat() { + if (activityHeartbeatTimer) { + clearInterval(activityHeartbeatTimer); + activityHeartbeatTimer = null; + } +} + +watch(gameRunning, (running, prev) => { + if (running && !prev) { + emitActivityStart(); + startActivityHeartbeat(); + nextTick(focusStream); + } + if (prev && !running) { + stopActivityHeartbeat(); + emitActivityStop(); + nextTick(focusPlayButton); + } +}); + +// ── Volume / mute ─────────────────────────────────────────────────── +// Debounced so the broker only hears the value once it settles. let volumeDebounce: ReturnType | null = null; watch(volume, (val) => { if (volumeDebounce) clearTimeout(volumeDebounce); @@ -157,99 +311,85 @@ watch(volume, (val) => { const platform = rom.value?.platform_slug; if (platform) streamingApi - .setVolume(platform, Math.round(val * 100)) + .setVolume(platform, Math.round(val)) .catch((err) => console.warn("[streaming] Could not set volume:", err)); }, 150); }); +function toggleMute(): void { + isMuted.value = !isMuted.value; + const platform = rom.value?.platform_slug; + if (platform) + streamingApi + .setMute(platform, isMuted.value) + .catch((err) => console.warn("[streaming] Could not set mute:", err)); +} + +// ── Auto-hiding control bar ──────────────────────────────────────── let uiTimeout: ReturnType | null = null; +const stageRef = ref(null); +const streamFrame = ref(null); -// Cleanup refs for iframe listener management. let attachTimeouts: ReturnType[] = []; let iframeLoadCleanup: (() => void) | null = null; let contentWindowCleanup: (() => void) | null = null; -onMounted(async () => { - await fetchRom(); - document.addEventListener("fullscreenchange", onFullscreenChange); - showUI(); -}); - -onBeforeUnmount(() => { - document.removeEventListener("fullscreenchange", onFullscreenChange); - if (uiTimeout) clearTimeout(uiTimeout); - if (volumeDebounce) clearTimeout(volumeDebounce); - attachTimeouts.forEach((id) => clearTimeout(id)); - attachTimeouts = []; - iframeLoadCleanup?.(); - iframeLoadCleanup = null; - contentWindowCleanup?.(); - contentWindowCleanup = null; - // Hand controller input back to the UI regardless of how we leave. - playingStore.setPlaying(false); - if (playerState.value === "exited") { - // handleSaveAndExit already released the session, nothing to do. - return; - } - if (playerState.value === "playing") { - // Navigation away while a game is active. Fire save+kill in the - // broker background and return immediately so navigation is never - // held up. - void streamingStore.saveAndExit( - rom.value?.platform_slug ?? "", - capabilities.value.autosaveSlot, - false, - ); - } else { - // No active game (or handleSaveAndExit already ran) — plain release. - void streamingStore.releaseSession(rom.value?.platform_slug ?? ""); - } -}); - -async function fetchRom(): Promise { - try { - const { data } = await romApi.getRom({ romId: romId.value }); - rom.value = data; - } catch { - playerState.value = "error"; - errorType.value = "server"; - errorMessage.value = t("play.stream-error-load-rom"); - } -} - function showUI(): void { isUIVisible.value = true; if (uiTimeout) clearTimeout(uiTimeout); uiTimeout = setTimeout(() => { isUIVisible.value = false; - }, 1500); + focusStream(); + }, 2500); +} + +// Browsers only deliver gamepad input to the focused frame, so the Selkies +// iframe must hold focus for the emulator to see the controller. Called on +// game start, iframe load, and whenever the control bar hides (returning +// focus taken by a toolbar click). +function focusStream(): void { + if (!gameRunning.value) return; + streamFrame.value?.focus(); +} + +function handleMouseMove(): void { + showUI(); } -/** Attach mousemove listener to iframe contentWindow if same-origin. - * Cleans up any previous load listener before adding a new one. Guards - * against double-attachment across repeated calls. */ +// Attach pointer listeners inside the iframe when same-origin, so the +// control bar reappears while the pointer is over the stream. Cross- +// origin containers fall back to the bottom hover sensor. function attachIframeListeners(): void { const frame = streamFrame.value; if (!frame) return; - // Remove stale load listener from a prior call. iframeLoadCleanup?.(); iframeLoadCleanup = null; const tryAttach = (): void => { + focusStream(); if (contentWindowCleanup) return; try { if (frame.contentWindow) { - frame.contentWindow.addEventListener("mousemove", showUI); - frame.contentWindow.addEventListener("mousedown", showUI); - frame.contentWindow.addEventListener("touchstart", showUI); + frame.contentWindow.addEventListener("mousemove", handleMouseMove); + frame.contentWindow.addEventListener("mousedown", handleMouseMove); + frame.contentWindow.addEventListener("touchstart", handleMouseMove); contentWindowCleanup = () => { try { - frame.contentWindow?.removeEventListener("mousemove", showUI); - frame.contentWindow?.removeEventListener("mousedown", showUI); - frame.contentWindow?.removeEventListener("touchstart", showUI); + frame.contentWindow?.removeEventListener( + "mousemove", + handleMouseMove, + ); + frame.contentWindow?.removeEventListener( + "mousedown", + handleMouseMove, + ); + frame.contentWindow?.removeEventListener( + "touchstart", + handleMouseMove, + ); } catch { - // Cross-origin — listeners were never added, nothing to remove. + // Cross-origin: listeners were never added, nothing to remove. } }; } @@ -263,7 +403,21 @@ function attachIframeListeners(): void { tryAttach(); } -async function handlePlay(): Promise { +// ── Session lifecycle ────────────────────────────────────────────── + +// Plain-language "what could be wrong" hint for the claim error alert. +// Statuses mirror the backend contract: 502 broker rejected the launch, +// 503 broker unreachable, 401/403 auth, no status = RomM unreachable. +function hintForStatus(status?: number): string { + const label = emulatorLabel.value; + if (status === 503) return t("play.error-hint-unreachable", { label }); + if (status === 502) return t("play.error-hint-broker", { label }); + if (status === 401 || status === 403) return t("play.error-hint-auth"); + if (status === undefined) return t("play.error-hint-network"); + return t("play.error-hint-server"); +} + +async function onPlay(): Promise { if (!rom.value) return; if (!container.value) { playerState.value = "error"; @@ -271,88 +425,170 @@ async function handlePlay(): Promise { errorMessage.value = t("play.stream-error-not-configured", { platform: rom.value.platform_slug, }); + errorHint.value = t("play.error-hint-not-configured"); return; } playerState.value = "loading"; errorType.value = null; + errorHint.value = ""; occupiedBy.value = null; + // Launch flourish on the cover (disc drop / cartridge slot-in) while + // the session claim is in flight. + const insertMs = coverRef.value?.playLoad() ?? 0; + const flourish = + insertMs > 0 + ? new Promise((resolve) => setTimeout(resolve, insertMs)) + : Promise.resolve(); + + if (auth.scopes.includes("roms.user.write")) { + // Best-effort metadata update; a failure must not surface as an + // unhandled rejection or block the launch. + romApi + .updateUserRomProps({ + romId: rom.value.id, + data: rom.value.rom_user, + updateLastPlayed: true, + }) + .catch((err) => { + console.warn("[stream] Could not update last-played:", err); + }); + } + try { // The backend derives the ROM's filesystem path and platform from the id. - const session = await streamingStore.claimSession(rom.value.id); + // A selected state rides along: its file is pushed to the broker and the + // emulator loads it once the game is up. + const session = await streamingStore.claimSession( + rom.value.id, + selectedState.value?.id, + container.value?.supports_memory_cards + ? (selectedMemoryCardId.value ?? undefined) + : undefined, + ); + if (session.resume === false) { + snackbar.warning(t("play.resume-failed")); + } + await flourish; + // Widen past TS's "loading" narrowing: the exit dialog can flip the + // state to "exited" while the claim is awaited. + const stateAfterClaim = playerState.value as PlayerState; + if (stateAfterClaim === "exited") { + // The launch was cancelled from the exit dialog while the claim was + // in flight; the claim that just resolved re-acquired the session, + // so release it again instead of entering the playing state. + void streamingStore.releaseSession(rom.value.platform_slug); + return; + } containerHost.value = session.host; playerState.value = "playing"; + showUI(); - // Wait for the DOM to update and the iframe to exist. attachTimeouts.forEach((id) => clearTimeout(id)); attachTimeouts = []; attachTimeouts.push(setTimeout(attachIframeListeners, 100)); - // Some frames are slow to initialize their window — retry once more. + // Some frames are slow to initialize their window; try again later. attachTimeouts.push(setTimeout(attachIframeListeners, 500)); + + if (fullscreenOnPlay.value) { + await nextTick(); + try { + await stageRef.value?.requestFullscreen(); + } catch { + // Fullscreen denied (permissions policy / gesture requirement). + } + } } catch (err: unknown) { playerState.value = "error"; - const error = err as { - status?: number; - detail?: string | { rom_name: string; claimed_at: string } | null; - message?: string; - }; + // The store propagates the raw axios error; the status and the + // backend's detail payload live on its response. + const status = isAxiosError(err) ? err.response?.status : undefined; + const detail: unknown = isAxiosError(err) + ? err.response?.data?.detail + : undefined; - if (error.status === 409) { + if (status === 409) { errorType.value = "occupied"; - occupiedBy.value = typeof error.detail === "object" ? error.detail : null; - } else if (error.status === 404) { - // The backend raises 404 for two distinct cases: a missing streaming - // container for the platform, and the ROM itself not being found - // (e.g. deleted between fetchRom and the claim). Disambiguate by the - // detail string so the message matches the real failure. - const detail = typeof error.detail === "string" ? error.detail : ""; - if (detail.includes("ROM not found")) { + occupiedBy.value = + detail && typeof detail === "object" + ? (detail as { rom_name: string; claimed_at: string }) + : null; + } else if (status === 404) { + // 404 covers two cases: no container configured for the platform, + // and the ROM itself missing (deleted between fetch and claim). + // The detail string disambiguates. + if (typeof detail === "string" && detail.includes("ROM not found")) { errorType.value = "rom_not_found"; errorMessage.value = t("play.stream-error-rom-not-found"); + errorHint.value = ""; } else { errorType.value = "not_configured"; errorMessage.value = t("play.stream-error-not-configured", { - platform: rom.value.platform_slug, + platform: rom.value?.platform_slug ?? "", }); + errorHint.value = t("play.error-hint-not-configured"); } } else { errorType.value = "server"; - errorMessage.value = error.message ?? t("play.stream-error-generic"); + // Without a status the request never got a response, so the axios + // message is meaningless - show a generic title and let the hint + // explain the likely cause. + errorMessage.value = + (status !== undefined && err instanceof Error ? err.message : null) ?? + t("play.stream-error-generic"); + errorHint.value = hintForStatus(status); } } } -async function handleStop(): Promise { +async function performStop(): Promise { await streamingStore.releaseSession(rom.value?.platform_slug ?? ""); // "exited" tells onBeforeUnmount the session is already released, so - // the navigation below doesn't trigger a second DELETE. + // navigating away afterwards doesn't trigger a second DELETE. playerState.value = "exited"; containerHost.value = ""; - router.push(backRoute.value); } -async function handleSaveAndExit(): Promise { +async function handleStop(): Promise { + await performStop(); + backToRom(); +} + +async function performSaveAndExit(): Promise { if (!rom.value || playerState.value !== "playing") return; isSavingAndExiting.value = true; let saved = false; try { - saved = await streamingStore.saveAndExit( + const result = await streamingStore.saveAndExit( rom.value.platform_slug, capabilities.value.autosaveSlot, true, ); + saved = result.saved; + if (!result.released) { + // The save-and-exit request failed, so the claim may still be held; + // fall back to a plain release so the container is freed before the + // player is marked exited. + await streamingStore.releaseSession(rom.value.platform_slug); + } } finally { isSavingAndExiting.value = false; playerState.value = "exited"; containerHost.value = ""; } if (!saved) { - snackbar.warning(t("play.stream-save-unconfirmed"), { timeout: 6000 }); + snackbar.warning(t("play.stream-save-unconfirmed"), { + timeout: 6000, + icon: "mdi-alert", + }); } - // Outside finally so we always navigate away, even if the save failed. - router.push(backRoute.value); +} + +async function handleSaveAndExit(): Promise { + await performSaveAndExit(); + backToRom(); } async function handleSaveState(): Promise { @@ -394,32 +630,165 @@ async function handleLoadAutosave(): Promise { } } +const stateActionBusy = computed( + () => + isSavingState.value || + isLoadingState.value || + isLoadingAutosave.value || + isSavingAndExiting.value, +); + +// ── Fullscreen ───────────────────────────────────────────────────── async function toggleFullscreen(): Promise { - if (!playerWrapper.value) return; + if (!stageRef.value) return; try { if (!document.fullscreenElement) { - await playerWrapper.value.requestFullscreen(); + await stageRef.value.requestFullscreen(); } else { await document.exitFullscreen(); } } catch { - // Fullscreen request denied (permissions policy or user-gesture requirement). + // Fullscreen denied (permissions policy / gesture requirement). } } -function toggleMute(): void { - isMuted.value = !isMuted.value; - const platform = rom.value?.platform_slug; - if (platform) - streamingApi - .setMute(platform, isMuted.value) - .catch((err) => console.warn("[streaming] Could not set mute:", err)); -} - function onFullscreenChange(): void { isFullscreen.value = !!document.fullscreenElement; } +// ── Navigation ───────────────────────────────────────────────────── +function backToRom() { + router.push({ name: ROUTES.ROM, params: { rom: rom.value?.id } }); +} +function backToPlatform() { + router.push({ + name: ROUTES.PLATFORM, + params: { platform: rom.value?.platform_id }, + }); +} + +// ── Exit guard (big-picture safety) ──────────────────────────────── +// While a session is active every way out funnels through one dialog: +// route-leave (B press, browser back, any link) is intercepted, and +// holding Select+Start on the pad opens it directly. A single stray +// button press can no longer kill the game. +const exitDialogOpen = ref(false); +const isStopping = ref(false); +// Set by the route-leave guard so a confirmed exit resumes the original +// navigation instead of forcing the ROM details page. +let pendingLeave: (() => void) | null = null; + +async function openExitDialog(): Promise { + if (exitDialogOpen.value) return; + // RDialog teleports to , outside the stage element, so it would + // be invisible behind a fullscreened stage. Drop out of fullscreen first. + if (document.fullscreenElement) { + try { + await document.exitFullscreen(); + } catch { + // Ignore: worst case the dialog opens behind fullscreen. + } + } + exitDialogOpen.value = true; +} + +watch(exitDialogOpen, (open) => { + if (!open) { + pendingLeave = null; + if (gameRunning.value) nextTick(focusStream); + } +}); + +onBeforeRouteLeave((to) => { + if (!sessionActive.value) return true; + pendingLeave = () => router.push(to.fullPath); + void openExitDialog(); + return false; +}); + +function exitKeepPlaying(): void { + exitDialogOpen.value = false; +} + +// Both actions resolve before the dialog closes so their buttons can +// show a busy spinner (save-and-exit blocks on the broker's save+kill). +async function exitSaveAndQuit(): Promise { + const leave = pendingLeave; + await performSaveAndExit(); + exitDialogOpen.value = false; + (leave ?? backToRom)(); +} + +async function exitWithoutSaving(): Promise { + const leave = pendingLeave; + isStopping.value = true; + try { + await performStop(); + } finally { + isStopping.value = false; + } + exitDialogOpen.value = false; + (leave ?? backToRom)(); +} + +// Dialogs have no automatic spatial navigation, so cycle focus between +// the action buttons on arrow keys (the d-pad arrives as synthetic +// ArrowLeft/ArrowRight keydowns from useGamepad). +function onExitDialogKeydown(event: KeyboardEvent): void { + const arrows = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]; + if (!arrows.includes(event.key)) return; + const root = event.currentTarget as HTMLElement; + const buttons = Array.from( + root.querySelectorAll("button:not([disabled])"), + ); + if (buttons.length === 0) return; + const idx = buttons.indexOf(document.activeElement as HTMLElement); + const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1; + buttons[(idx + step + buttons.length) % buttons.length]?.focus(); + event.preventDefault(); +} + +// ── Select+Start exit chord ──────────────────────────────────────── +// useGamepad is muted for the whole session (launch included), so the +// chord is read straight from the Gamepad API here; polling while +// "loading" keeps the cancel dialog reachable by pad if a launch hangs. +// The 1.5s hold filters out anything a game itself binds to Select+Start. +// Only standard-mapped pads participate: elsewhere indices 8/9 are not +// guaranteed to be Select+Start. +const EXIT_CHORD_HOLD_MS = 1500; +let chordRaf = 0; +let chordHeldSince = 0; + +function pollExitChord(now: number): void { + const pads = navigator.getGamepads ? navigator.getGamepads() : []; + const held = Array.from(pads).some( + (pad) => + pad && + pad.mapping === "standard" && + pad.buttons[8]?.pressed && + pad.buttons[9]?.pressed, + ); + if (!held) { + chordHeldSince = 0; + } else if (!chordHeldSince) { + chordHeldSince = now; + } else if (now - chordHeldSince >= EXIT_CHORD_HOLD_MS) { + chordHeldSince = 0; + if (!exitDialogOpen.value) void openExitDialog(); + } + chordRaf = requestAnimationFrame(pollExitChord); +} + +watch(sessionActive, (active) => { + if (active && !chordRaf) { + chordRaf = requestAnimationFrame(pollExitChord); + } else if (!active && chordRaf) { + cancelAnimationFrame(chordRaf); + chordRaf = 0; + chordHeldSince = 0; + } +}); + function formatTime(iso: string): string { try { return new Date(iso).toLocaleTimeString(); @@ -428,103 +797,307 @@ function formatTime(iso: string): string { } } -const showLaunchScreen = computed( - () => - playerState.value === "idle" || - playerState.value === "loading" || - playerState.value === "error", -); +// ── Unload teardown ───────────────────────────────────────────────── +// Vue teardown never runs when the tab closes or the browser quits, so +// pagehide is the only signal. The keepalive requests outlive the page; +// the broker-side save+kill then runs to completion server-side. +function onPageHide(): void { + if (playerState.value === "exited") return; + const platform = rom.value?.platform_slug ?? ""; + if (playerState.value === "playing") { + streamingStore.saveAndExitKeepalive( + platform, + capabilities.value.autosaveSlot, + ); + } else if (sessionActive.value) { + streamingStore.releaseSessionKeepalive(platform); + } else { + return; + } + // Guards the in-app unmount path from double-releasing if the page + // comes back from the bfcache and is then navigated normally. + playerState.value = "exited"; +} + +onMounted(async () => { + document.addEventListener("fullscreenchange", onFullscreenChange); + window.addEventListener("pagehide", onPageHide); + + try { + const { data } = await romApi.getRom({ + romId: parseInt(route.params.rom as string), + }); + rom.value = data; + } catch { + playerState.value = "error"; + errorType.value = "server"; + errorMessage.value = t("play.stream-error-load-rom"); + return; + } + + if (rom.value) { + document.title = `${rom.value.name} | Play`; + } + + // Autofocus the Play CTA so gamepad/keyboard users land on the + // primary action without an extra Tab. + if (modality.value === "pad" || modality.value === "key") { + await nextTick(); + focusPlayButton(); + } +}); + +onBeforeUnmount(() => { + playingStore.setPlaying(false); + document.removeEventListener("fullscreenchange", onFullscreenChange); + window.removeEventListener("pagehide", onPageHide); + if (uiTimeout) clearTimeout(uiTimeout); + if (volumeDebounce) clearTimeout(volumeDebounce); + attachTimeouts.forEach((id) => clearTimeout(id)); + attachTimeouts = []; + if (chordRaf) { + cancelAnimationFrame(chordRaf); + chordRaf = 0; + } + iframeLoadCleanup?.(); + iframeLoadCleanup = null; + contentWindowCleanup?.(); + contentWindowCleanup = null; + stopActivityHeartbeat(); + emitActivityStop(); + if (playerState.value === "exited") { + // handleSaveAndExit / handleStop already released the session. + return; + } + if (playerState.value === "playing") { + // Navigation away while a game is active: fire save+kill in the + // broker background so navigation is never held up. + void streamingStore.saveAndExit( + rom.value?.platform_slug ?? "", + capabilities.value.autosaveSlot, + false, + ); + } else { + void streamingStore.releaseSession(rom.value?.platform_slug ?? ""); + } +});