diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 4b278d2b93..08d9d67cea 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/0108_memory_cards.py b/backend/alembic/versions/0108_memory_cards.py new file mode 100644 index 0000000000..bdd3e3aca6 --- /dev/null +++ b/backend/alembic/versions/0108_memory_cards.py @@ -0,0 +1,112 @@ +"""Add memory_cards and memory_card_versions tables + +Revision ID: 0108_memory_cards +Revises: 0107_roms_dedup_cover_index +Create Date: 2026-07-12 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0108_memory_cards" +down_revision = "0107_roms_dedup_cover_index" +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/alembic/versions/0109_container_adoptions.py b/backend/alembic/versions/0109_container_adoptions.py new file mode 100644 index 0000000000..fd6b818873 --- /dev/null +++ b/backend/alembic/versions/0109_container_adoptions.py @@ -0,0 +1,53 @@ +"""Add streaming_container_adoptions table + +Revision ID: 0109_container_adoptions +Revises: 0108_memory_cards +Create Date: 2026-07-21 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0109_container_adoptions" +down_revision = "0108_memory_cards" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "streaming_container_adoptions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("container_key", sa.String(length=512), nullable=False), + sa.Column("outcome", sa.String(length=16), nullable=False), + sa.Column("decided_by_user_id", sa.Integer(), nullable=True), + sa.Column( + "decided_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + 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( + ["decided_by_user_id"], ["users.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("container_key"), + ) + + +def downgrade() -> None: + op.drop_table("streaming_container_adoptions") diff --git a/backend/alembic/versions/0110_state_disc_file.py b/backend/alembic/versions/0110_state_disc_file.py new file mode 100644 index 0000000000..82c7c86647 --- /dev/null +++ b/backend/alembic/versions/0110_state_disc_file.py @@ -0,0 +1,38 @@ +"""add disc_file_id to states + +Revision ID: 0110_state_disc_file +Revises: 0109_container_adoptions +Create Date: 2026-08-14 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0110_state_disc_file" +down_revision = "0109_container_adoptions" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("states", schema=None) as batch_op: + batch_op.add_column(sa.Column("disc_file_id", sa.Integer(), nullable=True)) + # Postgres indexes no FK column on its own, and the SET NULL cascade + # scans this on every rom_files delete. + batch_op.create_index("ix_states_disc_file_id", ["disc_file_id"]) + batch_op.create_foreign_key( + "fk_states_disc_file_id", + "rom_files", + ["disc_file_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + with op.batch_alter_table("states", schema=None) as batch_op: + batch_op.drop_constraint("fk_states_disc_file_id", type_="foreignkey") + batch_op.drop_index("ix_states_disc_file_id") + batch_op.drop_column("disc_file_id") diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 500ae95714..6760973a26 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -316,6 +316,12 @@ def _get_env(var: str, fallback: str | None = None) -> str | None: STREAMING_SAVE_TIMEOUT: Final[int] = safe_int( _get_env("STREAMING_SAVE_TIMEOUT"), 45 ) # 45 seconds +# How many save states to keep per ROM, emulator and user. Each capture is +# kept as its own asset rather than overwriting a slot, so the oldest are +# pruned once this many exist. 0 disables pruning. +STREAMING_STATE_HISTORY_LIMIT: Final[int] = safe_int( + _get_env("STREAMING_STATE_HISTORY_LIMIT"), 50 +) # SENTRY SENTRY_DSN: Final[str | None] = _get_env("SENTRY_DSN") diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 87b6d30a45..3061824e2b 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -162,11 +162,39 @@ class NetplayICEServer(TypedDict): credential: NotRequired[str] +class StreamingPlatformOverride(TypedDict): + # Names the state and card namespace, so it has no container-level default. + emulator: str + # Anything set here wins over the same key on the container. + label: NotRequired[str] + memory_card_sync: NotRequired[bool] + + class StreamingContainer(TypedDict): - platform: str + # A container declares either one platform (the per-emulator mods) or a + # `platforms` map (one webstation serving many). Exactly one of the two. + platform: NotRequired[str] + # Platform slug to the emulator that serves it, or to a block of options + # overriding container keys for that platform, replacing platform + + # emulator on a container that hosts more than one. + platforms: NotRequired[dict[str, str | StreamingPlatformOverride]] host: str - broker_host: str + # Optional under `protocol: webstation`, which derives the broker host from + # `host` and `subfolder` when it is omitted. + broker_host: NotRequired[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] + # Broker dialect. Omitted (or "broker") is the per-emulator mod contract; + # "webstation" is the LSIO webstation container's activate/exit contract. + protocol: NotRequired[str] + # URL prefix the webstation broker is served under, matching its SUBFOLDER. + subfolder: NotRequired[str] class Config: diff --git a/backend/conftest.py b/backend/conftest.py index 614fdae22f..43b7e7694e 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -1,15 +1,16 @@ import os -# When running under pytest-xdist, give each worker its own database so the -# autouse `clear_database` fixture in one worker can't wipe rows another worker -# is mid-test with. This must run before any application module (config / -# database handlers) is imported, so the engine built at import time binds to -# the per-worker name. As the rootdir conftest, this file is imported before -# `tests/conftest.py` (which imports those modules). +# Tests must never inherit DB_NAME from the ambient environment (e.g. a +# sourced .env pointing at a real dev/prod database) -- the autouse +# `clear_database` fixture in tests/conftest.py deletes every row in that +# database on every test. Always pin to a dedicated test database, made +# unique per pytest-xdist worker so parallel workers can't wipe rows another +# worker is mid-test with. This must run before any application module +# (config / database handlers) is imported, so the engine built at import +# time binds to the test name. As the rootdir conftest, this file is +# imported before `tests/conftest.py` (which imports those modules). # # The Redis cache needs no equivalent handling: under pytest it is an in-process # FakeRedis, so each worker process is already isolated. _xdist_worker = os.environ.get("PYTEST_XDIST_WORKER") -if _xdist_worker: - _base_db_name = os.environ.get("DB_NAME", "romm_test") - os.environ["DB_NAME"] = f"{_base_db_name}_{_xdist_worker}" +os.environ["DB_NAME"] = f"romm_test_{_xdist_worker}" if _xdist_worker else "romm_test" diff --git a/backend/endpoints/memory_cards.py b/backend/endpoints/memory_cards.py new file mode 100644 index 0000000000..139c30557a --- /dev/null +++ b/backend/endpoints/memory_cards.py @@ -0,0 +1,432 @@ +import asyncio +import re +from pathlib import Path +from typing import Annotated + +from fastapi import Body, File, HTTPException, Request, UploadFile, 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, MemoryCardVersion +from utils.memory_cards import ( + MEMORY_CARD_MAX_BYTES, + UnsafeCardArchive, + assert_card_archive_safe, + store_memory_card_version, +) +from utils.router import APIRouter +from utils.uploads import check_asset_upload_size + +router = APIRouter( + prefix="/memory-cards", + tags=["memory-cards"], +) + +MEMORY_CARD_FILE_UPLOAD = File(..., description="Memory card archive to upload.") + +# The emulator name is a folder under the user's memory_cards directory, so it +# is held to what a folder may be called rather than to any list of emulators. +EMULATOR_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]*$") + + +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 not EMULATOR_NAME_RE.match(emulator): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not a valid emulator name: {emulator}", + ) + + 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. Browsing and download only: mounting a card at claim is + owner-scoped, so the picker lists the caller's own cards instead.""" + 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 + ] + + +# The expansion check decompresses what the uploader sent, so it runs off the +# loop. The executor it lands in is the one every other blocking call in the +# process shares, hence the cap on how many uploads may occupy it at once. +_ARCHIVE_CHECKS = asyncio.Semaphore(2) + + +async def _assert_safe_archive(content: bytes) -> None: + """The shared archive check, as a 400. Covers the whole gate: readable zip, + no escaping paths, no symlinks, no runaway expansion. + """ + try: + async with _ARCHIVE_CHECKS: + await asyncio.to_thread(assert_card_archive_safe, content) + except UnsafeCardArchive as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Memory card archive rejected, {exc}", + ) from None + + +def _version_file_or_404(version: MemoryCardVersion) -> Path: + 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 file_path + + +@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) + + return build_asset_file_response( + _version_file_or_404(version), filename=version.file_name + ) + + +@protected_route( + router.get, + "/{id}/content", + [Scope.ASSETS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +def download_memory_card(request: Request, id: int) -> FileResponse: + """Download the card as it stands now, without going through its history. + + This is the newest version, which is also what the next claim hydrates onto + a container, so what comes down here is what the emulator would boot with. + A card that has never been synced has nothing to serve and 404s.""" + _card_or_404(id, request.user.id) + + version = db_memory_card_handler.get_latest_version(id) + if not version: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memory card has no stored data yet", + ) + + return build_asset_file_response( + _version_file_or_404(version), filename=version.file_name + ) + + +@protected_route( + router.post, + "/{id}/versions", + [Scope.ASSETS_WRITE], + responses={ + status.HTTP_400_BAD_REQUEST: {}, + status.HTTP_404_NOT_FOUND: {}, + status.HTTP_413_CONTENT_TOO_LARGE: {}, + }, +) +async def upload_memory_card_version( + request: Request, + id: int, + cardFile: UploadFile = MEMORY_CARD_FILE_UPLOAD, +) -> MemoryCardVersionSchema: + """Store a card image the user supplied as the card's newest version, which + is what the next claim hydrates onto the container (owner only). + + Only the zip layout the broker exchanges is accepted. A bare card image + (`.ps2`, `.raw`) is refused rather than stored, because nothing downstream + would notice until hydrate pushed it and the emulator rejected the card. + """ + check_asset_upload_size(cardFile, "Memory card file") + card = _owned_card_or_404(id, request.user.id) + + content = await cardFile.read(MEMORY_CARD_MAX_BYTES + 1) + if len(content) > MEMORY_CARD_MAX_BYTES: + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail=( + f"Memory card exceeds the maximum size of " + f"{MEMORY_CARD_MAX_BYTES} bytes" + ), + ) + await _assert_safe_archive(content) + + # The version this call wrote, not the card's latest: a teardown evacuating + # the same card alongside the upload would make the latest describe a + # snapshot the uploader never sent. + version = await store_memory_card_version( + request.user, card, content, deduplicate=False + ) + if version is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Memory card upload was not stored", + ) + + log.info(f"Uploaded memory card {hl(card.name)} [{card.emulator}]") + return MemoryCardVersionSchema.model_validate(version) + + +@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) + + +def _reconcile_missing(version: MemoryCardVersion) -> bool: + """Whether this version's archive is gone, persisting the answer. + + Nothing else writes `missing_from_fs`, and the history is the only place a + user can see that a snapshot is unrecoverable before clicking download, so + the listing is where the flag is brought back in line with the disk. + """ + try: + path = fs_asset_handler.validate_path(version.full_path) + missing = not path.is_file() + except (ValueError, OSError): + missing = True + + if missing != version.missing_from_fs: + db_memory_card_handler.set_version_missing(version.id, missing) + return missing + + +@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).model_copy( + update={"missing_from_fs": _reconcile_missing(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}) + if updated is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Memory card not found" + ) + 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}) + if updated is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Memory card not found" + ) + 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", + ) + + # Resolve every card up front: deletion is irreversible and spans one + # transaction per card, so a bad id in the batch must fail before the + # first archive is removed rather than half way through. + owned = [ + (card_id, _owned_card_or_404(card_id, request.user.id)) for card_id in cards + ] + + for card_id, card in owned: + # The delete reports the archives that went with it, so the removal list + # cannot miss a version written while the batch was running. A file that + # will not budge (permissions, a locked mount) must not abort the batch: + # the rest of the cards would be left untouched with nothing to tell the + # caller how far it got. An orphaned archive is recoverable, a + # half-deleted batch is not. + for path in db_memory_card_handler.delete_card(card_id): + try: + await fs_asset_handler.remove_file(file_path=path) + except FileNotFoundError: + log.warning(f"Memory card file {hl(path)} already gone from disk") + except OSError as exc: + log.error( + f"Could not remove memory card file {hl(path)}, " + f"leaving it orphaned: {exc}" + ) + + 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/sockets/logs.py b/backend/endpoints/sockets/logs.py index 9d3a4adf04..739e680177 100644 --- a/backend/endpoints/sockets/logs.py +++ b/backend/endpoints/sockets/logs.py @@ -1,9 +1,10 @@ """Real-time backend log streaming over Socket.IO (admin only). Pieces: -- ``connect`` handler on the main socket server: resolves the session user and, - if they are an admin, joins them to the ``admin`` room. It never rejects a - connection, so the existing scan/sync sockets keep working for everyone. +- ``connect`` handler on the main socket server: resolves the session user, + joins them to their own ``user:{id}`` room, and, if they are an admin, also + joins the ``admin`` room. It never rejects a connection, so the existing + scan/sync sockets keep working for everyone. - ``start_log_forwarder``: a single background task (Redis-lock guarded) that subscribes to the ``romm:logs`` pub/sub channel — fed by ``LogStreamHandler`` in every backend process — and relays each line to the ``admin`` room. @@ -37,10 +38,12 @@ async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None: """Resolve the authenticated user on socket connect. Stores the user id in the socket session so activity events can trust the - server-resolved identity instead of a client-supplied ``user_id``, and joins - admin users to the log-streaming room. Always returns ``None`` (accepts the - connection) — only identity storage and room membership are gated, so the - existing scan/sync sockets keep working for everyone. + server-resolved identity instead of a client-supplied ``user_id``, joins + every authenticated user to their own ``user:{id}`` room (the target for + sync and streaming push notifications), and joins admins to the + log-streaming room. Always returns ``None`` (accepts the connection): + only identity storage and room membership are gated, so the existing + scan/sync sockets keep working for everyone. """ try: session = await get_session_from_environ(environ) @@ -56,6 +59,7 @@ async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None: return await store_authenticated_user(sid, user.id) + await socket_handler.socket_server.enter_room(sid, f"user:{user.id}") if not DISABLE_LOGS_VIEWER and user.role == Role.ADMIN: await socket_handler.socket_server.enter_room(sid, ADMIN_ROOM) diff --git a/backend/endpoints/streaming.py b/backend/endpoints/streaming.py index 585fe6f063..b7361e52d6 100644 --- a/backend/endpoints/streaming.py +++ b/backend/endpoints/streaming.py @@ -1,24 +1,62 @@ import asyncio +import base64 +import io import json import logging +import os +import re +import secrets +import time import urllib.error import urllib.request +import zipfile +from collections.abc import Callable, Coroutine from datetime import datetime, timezone -from typing import Annotated, Any, TypedDict -from urllib.parse import urlparse, urlunparse +from email.message import Message +from pathlib import PurePosixPath +from typing import Annotated, Any, Literal, TypedDict +from urllib.parse import quote, urljoin, urlparse, urlunparse -from fastapi import Body, HTTPException, Request +from fastapi import Body, HTTPException, Query, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field - -from config import LIBRARY_BASE_PATH, STREAMING_BROKER_SECRET, STREAMING_SAVE_TIMEOUT +from redis.exceptions import WatchError +from starlette.background import BackgroundTask + +from config import ( + LIBRARY_BASE_PATH, + STREAMING_BROKER_SECRET, + STREAMING_SAVE_TIMEOUT, + STREAMING_STATE_HISTORY_LIMIT, +) from config.config_manager import config_manager as cm 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.auth.dependencies import assert_rom_visible, get_permissions +from handler.database import ( + db_container_adoption_handler, + db_memory_card_handler, + db_platform_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_save, scan_screenshot, scan_state +from handler.socket_handler import socket_handler +from models.assets import MemoryCard, MemoryCardVersion, State +from models.rom import Rom +from models.user import Role, User +from utils.filesystem import sanitize_filename +from utils.memory_cards import ( + MEMORY_CARD_MAX_BYTES, + content_hash_of_bytes, + store_memory_card_version, +) from utils.router import APIRouter log = logging.getLogger("romm") @@ -35,8 +73,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 +84,32 @@ # expires on its own; no explicit DELETE. SESSION_DRAIN_SECONDS = 5 +# Save-and-exit holds the container past the drain window when an exit state is +# still coming back out of it. The marker is short and refreshed for as long as +# the pull runs (see _hold_drain_marker) rather than sized to the slowest +# possible transfer, so a backend that dies mid-pull frees the container in a +# minute instead of parking it for the length of a transfer nobody is doing. +_DRAIN_MARKER_TTL = 60 +_DRAIN_MARKER_REFRESH = 20 + +# 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 + +# How often backend-side work running under a claim restamps it. Well inside the +# stale window, so a single missed refresh cannot hand the container away. +_CLAIM_REFRESH_SECONDS = _SESSION_STALE_SECONDS // 3 + +# How long a marker or a claim may be kept alive by the work behind it. Past +# this the refresh stops and the container ages back out on its own: every step +# under a keepalive carries its own timeout, so overrunning this means something +# is wedged, and a wedged step must not reserve a container indefinitely. +_HOLD_CEILING_SECONDS = 15 * 60 + def _session_redis_key(session_key: str) -> str: return f"{_SESSION_KEY_PREFIX}{session_key}" @@ -70,6 +134,414 @@ async def _refresh_session(session_key: str) -> None: await async_cache.expire(_session_redis_key(session_key), SESSION_TTL_SECONDS) +# A contended session key is rewritten by at most a heartbeat, a swap and a +# release, so a handful of retries is far more than the contention warrants. +_SESSION_CAS_ATTEMPTS = 5 + + +class _SessionContended(Exception): + """The session key stayed contended for the whole CAS budget. + + Kept apart from the None that means "gone": a key nobody could write is + still a key that exists, and callers that treat it as a vanished claim + report a live session as ended. + """ + + +async def _mutate_session( + session_key: str, + changes: dict[str, Any], + *, + require: Callable[[dict[str, Any]], bool] | None = None, +) -> dict[str, Any] | None: + """Merge `changes` into a live session, atomically. + + The whole session is one JSON blob, so a plain read-modify-write silently + drops whichever concurrent update landed in between: a heartbeat racing a + disc swap would write back a copy with no `disc_file_id`. WATCH aborts the + write when the key moved under us and the retry re-reads. + + `require` is evaluated against the freshly read session inside the same + watch, so a caller can act on a condition without it going stale between + the check and the write. Returns the stored session, or None when the key + is gone, corrupt, or `require` rejected it. Raises `_SessionContended` when + the write never landed, which is not the same as the key being gone. + """ + key = _session_redis_key(session_key) + for _ in range(_SESSION_CAS_ATTEMPTS): + async with async_cache.pipeline() as pipe: + await pipe.watch(key) + raw = await pipe.get(key) + if raw is None: + await pipe.unwatch() + return None + try: + session = json.loads(raw) + except (TypeError, json.JSONDecodeError): + await pipe.unwatch() + await async_cache.delete(key) + return None + if require is not None and not require(session): + await pipe.unwatch() + return None + session.update(changes) + pipe.multi() + # xx so a release landing between the watch and the exec cannot be + # undone by resurrecting the key. + await pipe.set(key, json.dumps(session), xx=True, ex=SESSION_TTL_SECONDS) + try: + results = await pipe.execute() + except WatchError: + continue + # An expiry between the watch and the exec does not abort the + # transaction, so an xx write can succeed having set nothing. + if not (results and results[0]): + return None + return session + raise _SessionContended(session_key) + + +def _same_claim(session: dict[str, Any], claim: dict[str, Any]) -> bool: + """Whether a session read back is still the one a route resolved. Identity is + the holder plus the moment they took it, so a re-claim by the same user does + not pass for the claim it replaced.""" + return ( + not session.get("draining") + and session.get("user_id") == claim.get("user_id") + and session.get("claimed_at") == claim.get("claimed_at") + ) + + +async def _replace_session_if( + session_key: str, + require: Callable[[dict[str, Any]], bool], + value: str | None, + ttl: int | None = None, +) -> bool: + """Overwrite or delete a session key, while `require` accepts what is there. + + Returns True when the intended state is what the key now holds, False when + `require` rejected it. A delete finding the key already gone counts as done; + a write that did not land does not, so it raises `_SessionContended` along + with running out of retries. The caller still holds what it tried to give up + in that case, and must not report otherwise. + """ + key = _session_redis_key(session_key) + for _ in range(_SESSION_CAS_ATTEMPTS): + async with async_cache.pipeline() as pipe: + await pipe.watch(key) + raw = await pipe.get(key) + if raw is None: + await pipe.unwatch() + return value is None + try: + current = json.loads(raw) + except (TypeError, json.JSONDecodeError): + current = None + if not isinstance(current, dict) or not require(current): + await pipe.unwatch() + return False + pipe.multi() + if value is None: + await pipe.delete(key) + else: + # xx so a release landing between the watch and the exec cannot + # be undone by resurrecting the key. + await pipe.set(key, value, xx=True, ex=ttl) + try: + results = await pipe.execute() + except WatchError: + continue + # An expiry between the watch and the exec does not abort the + # transaction, so a write can succeed having set nothing. + if value is not None and not (results and results[0]): + raise _SessionContended(session_key) + return True + raise _SessionContended(session_key) + + +def _drain_marker(token: str) -> str: + return json.dumps({"draining": True, "drain_token": token}) + + +async def _claim_drain_marker( + session_key: str, claim: dict[str, Any], ttl: int = _DRAIN_MARKER_TTL +) -> str | None: + """Replace a session with a drain marker, while the key still holds the claim + that is exiting. + + Deliberately not a session record: the session is over, and joinable and the + admin views must not keep advertising it. Deliberately not an unconditional + write either, since an admin force-release and a fresh claim both fit inside + the blocking save+kill that runs before this, and the marker would bury a + session somebody is playing. + + Returns the token the marker carries, or None when the key had moved on. + Raises `_SessionContended` when the marker never landed, which leaves the + container held by a claim the caller has already stopped. + """ + token = secrets.token_hex(8) + landed = await _replace_session_if( + session_key, + lambda current: _same_claim(current, claim), + _drain_marker(token), + ttl, + ) + return token if landed else None + + +async def _hold_drain_marker(session_key: str, token: str) -> None: + """Keep a drain marker alive for as long as the work behind it runs. + + The marker is short-lived and refreshed rather than sized to the slowest + imaginable pull: a backend that dies mid-pull then frees the container in a + minute instead of parking it for the length of a transfer nobody is doing. + """ + marker = _drain_marker(token) + deadline = time.monotonic() + _HOLD_CEILING_SECONDS + while True: + await asyncio.sleep(_DRAIN_MARKER_REFRESH) + try: + held = await _replace_session_if( + session_key, + lambda current: current.get("drain_token") == token, + marker, + _DRAIN_MARKER_TTL, + ) + except _SessionContended: + # Contention is not a takeover: the marker is still ours, the write + # just did not land. Stopping here would leave the work behind the + # marker running against a key nothing refreshes. + continue + if not held: + log.warning("drain marker on %s is no longer ours", session_key) + return + if time.monotonic() >= deadline: + log.warning( + "stopped refreshing the drain marker on %s, the work behind it " + "has run for over %ss", + session_key, + _HOLD_CEILING_SECONDS, + ) + return + + +async def _hold_session_claim(session_key: str, claim: dict[str, Any]) -> None: + """Keep a claim's liveness stamp current for as long as work runs under it. + + For the paths where a drain marker could not be written and the claim itself + is what reserves the container. The stamp is the player's, and the player is + gone, so without this the record ages past `_SESSION_STALE_SECONDS` and the + next claimant tears the container down mid-work. + """ + deadline = time.monotonic() + _HOLD_CEILING_SECONDS + while True: + await asyncio.sleep(_CLAIM_REFRESH_SECONDS) + try: + held = await _mutate_session( + session_key, + {"last_seen": datetime.now(timezone.utc).isoformat()}, + require=lambda current: _same_claim(current, claim), + ) + except _SessionContended: + continue + if held is None: + log.warning("claim on %s is no longer ours", session_key) + return + if time.monotonic() >= deadline: + log.warning( + "stopped refreshing the claim on %s, the work behind it has run " + "for over %ss", + session_key, + _HOLD_CEILING_SECONDS, + ) + return + + +async def _drop_drain_marker(session_key: str, token: str) -> None: + """Delete a drain marker, while it is still the one `token` wrote. + + A marker that expired, or that a later exit replaced, belongs to whoever + holds the container now, and deleting it would free a container mid-play. + """ + try: + await _replace_session_if( + session_key, + lambda current: current.get("drain_token") == token, + None, + ) + except _SessionContended: + log.warning("could not drop the drain marker on %s", session_key) + + +async def _release_own_session(session_key: str, claim: dict[str, Any]) -> bool: + """Free a container, while the key still holds the claim being released. + + Save-and-exit blocks on the broker for as long as the emulator takes to + write and die, and an admin force-release plus a new claim both fit in that + window. The unguarded delete would then end a session that had just begun. + + False means the container is still held, either by somebody else's claim or + because the delete never landed. + """ + try: + return await _replace_session_if( + session_key, lambda current: _same_claim(current, claim), None + ) + except _SessionContended: + log.warning("could not release session %s", session_key) + return False + + +async def _set_session_disc( + session_key: str, file_id: int, broker_session_id: str | None = None +) -> None: + """Record the disc a session is now running. + + The write only lands on the claim it was made for. A swap can outlive that + claim: the broker holds it until the game is up, by which time the key can + be the short drain marker save-and-exit leaves behind, or a fresh claim by + someone else. Writing either would stamp the wrong disc, and the drain + marker would come back with a six-hour TTL that no release path owns. + + Best-effort: the disc is already in the tray by the time this runs, so a + key that could not be written costs the next state capture its disc, not + the swap the player asked for. + """ + + def _still_the_same_claim(session: dict[str, Any]) -> bool: + if session.get("draining"): + return False + return ( + broker_session_id is None + or session.get("broker_session_id") == broker_session_id + ) + + try: + written = await _mutate_session( + session_key, {"disc_file_id": file_id}, require=_still_the_same_claim + ) + except _SessionContended: + written = None + if written is None: + log.warning("could not record disc %s on session %s", file_id, session_key) + + +def _session_disc_id(session: dict[str, Any]) -> int | None: + """The disc a swap put this session on, if any.""" + value = session.get("disc_file_id") + return value if isinstance(value, int) else None + + +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 + + +# ── Termination notices ─────────────────────────────────────────────────────── + +# An admin force-release deletes the session key, but the displaced player's +# browser is still showing a stream that no longer exists. A tombstone keyed by +# container and displaced user lets their next poll say who ended it and why, +# instead of the picture simply stopping. Cleared when that user claims again; +# the TTL covers the case where they never come back. +_TERMINATION_KEY_PREFIX = "romm:streaming:terminated:" +_TERMINATION_TTL_SECONDS = 15 * 60 + + +def _termination_redis_key(session_key: str, user_id: int) -> str: + return f"{_TERMINATION_KEY_PREFIX}{session_key}:{user_id}" + + +async def _record_termination( + session: dict[str, Any], + session_key: str, + *, + ended_by: str | None, + reason: str | None, +) -> None: + """Leave a note for the player whose session was taken away, and push it + over the socket so the poll isn't the only way that tab finds out. No-op + when the session records no owner, since there is nobody to notify.""" + user_id = session.get("user_id") + if not isinstance(user_id, int): + return + notice = { + "ended_by": ended_by, + "reason": reason or None, + "ended_at": datetime.now(timezone.utc).isoformat(), + "platform": session.get("platform"), + "rom_id": session.get("rom_id"), + "rom_name": session.get("rom_name"), + } + await async_cache.set( + _termination_redis_key(session_key, user_id), + json.dumps(notice), + ex=_TERMINATION_TTL_SECONDS, + ) + # Best-effort: the poll is the source of truth and covers a missed or + # dropped push, so a socket error here must not fail the release itself. + try: + await socket_handler.socket_server.emit( + "streaming:session-ended", notice, room=f"user:{user_id}" + ) + except Exception: # noqa: BLE001 + log.warning("Failed to push session-ended notice", exc_info=True) + + +async def _get_termination(session_key: str, user_id: int) -> dict[str, Any] | None: + raw = await async_cache.get(_termination_redis_key(session_key, user_id)) + if raw is None: + return None + try: + return json.loads(raw) + except (TypeError, json.JSONDecodeError): + await async_cache.delete(_termination_redis_key(session_key, user_id)) + return None + + +async def _clear_termination(session_key: str, user_id: int) -> None: + await async_cache.delete(_termination_redis_key(session_key, user_id)) + + +async def _session_status(platform: str, request: Request) -> dict[str, Any]: + """Whether the caller still holds this platform's session, and if not, why + it ended. Read-only, so it is safe to poll.""" + candidates = _containers_for_platform(platform) + if not candidates: + raise HTTPException( + status_code=404, + detail=f"No streaming container configured for platform '{platform}'", + ) + if await _find_session_for_user(candidates, request.user.id) is not None: + return {"status": "active", "platform": platform} + # The tombstone is keyed per container, so with a pool the caller's notice + # can sit under any of them. + termination = None + for candidate in candidates: + termination = await _get_termination(_container_key(candidate), request.user.id) + if termination is not None: + break + return { + "status": "ended", + "platform": platform, + "termination": termination, + } + + 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: @@ -93,16 +565,34 @@ def _parse_host_url(host: str) -> str | None: return host +def _parse_stream_host(host: str) -> str | None: + """Validate a configured stream host: an absolute URL, or a path when the + container is reverse proxied onto RomM's own origin (`/streaming`). The + browser resolves a path against whatever origin it is already on, which is + what makes the iframe same origin and its pointer events reachable.""" + host = host.strip() + if host.startswith("/"): + return host.rstrip("/") or "/" + return _parse_host_url(host) + + def _derive_broker_host(container: dict[str, Any]) -> str | None: - """Resolve the broker API host for a container: broker_host if set, - otherwise the stream host with its port swapped to 8000. Returns None - when neither resolves to a usable scheme-bearing URL.""" + """Resolve the broker API host for a container. + + `broker_host` wins when set. Otherwise a webstation container serves the + broker on the same origin as the stream (`_webstation_path` adds the + subfolder), while the per-emulator mods serve it on port 8000. Returns None + when neither resolves to a usable scheme-bearing URL, which is the case for + a container proxied onto a bare path. + """ broker_host = _parse_host_url(container.get("broker_host", "")) if broker_host: return broker_host.rstrip("/") stream_host = _parse_host_url(container.get("host", "")) if not stream_host: return None + if _is_webstation(container): + return stream_host.rstrip("/") parsed = urlparse(stream_host) return urlunparse(parsed._replace(netloc=f"{parsed.hostname}:8000")).rstrip("/") @@ -119,53 +609,167 @@ def _container_key(container: dict[str, Any]) -> str: # frontend via /config, so the slot selector is not a second hardcoded copy. -class PlatformCapabilities(TypedDict): +class _SlotCapabilities(TypedDict): max_slots: int # manual save slots, selectable as 1..max_slots has_autosave: bool # whether a dedicated autosave slot can be loaded - autosave_slot: int # that slot's index (loadable, not savable), 0 if none + autosave_slot: int # that slot's index, 0 if none + has_memory_card: bool # whether the broker serves a whole-card /memory-card + + +class PlatformCapabilities(_SlotCapabilities): + supports_disc_swap: bool # a live swap route exists for this platform + has_manual_disc_swap: bool # no route, but the emulator's own UI can do it # Keyed by platform slug (lowercase). A platform absent here gets no save-state # UI until its broker's slot semantics are known. -_PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = { - # Dolphin (ngc, wii, wiiu): slots 1-7 manual, slot 8 autosave. - "ngc": {"max_slots": 7, "has_autosave": True, "autosave_slot": 8}, - "wii": {"max_slots": 7, "has_autosave": True, "autosave_slot": 8}, - "wiiu": {"max_slots": 7, "has_autosave": True, "autosave_slot": 8}, - # PCSX2 (ps2) and xemu (xbox): slots 1-9 manual, slot 10 autosave. - "ps2": {"max_slots": 9, "has_autosave": True, "autosave_slot": 10}, - "xbox": {"max_slots": 9, "has_autosave": True, "autosave_slot": 10}, +_PLATFORM_CAPABILITIES: dict[str, _SlotCapabilities] = { + # Dolphin (ngc, wii, wiiu): slots 1-7 manual, slot 8 autosave. Only the + # GameCube side has a memory card; Wii and Wii U saves live in NAND and + # round-trip through /save-file instead. + "ngc": { + "max_slots": 7, + "has_autosave": True, + "autosave_slot": 8, + "has_memory_card": True, + }, + "wii": { + "max_slots": 7, + "has_autosave": True, + "autosave_slot": 8, + "has_memory_card": False, + }, + "wiiu": { + "max_slots": 7, + "has_autosave": True, + "autosave_slot": 8, + "has_memory_card": False, + }, + # PCSX2 (ps2): slots 1-9 manual, slot 10 autosave. + "ps2": { + "max_slots": 9, + "has_autosave": True, + "autosave_slot": 10, + "has_memory_card": True, + }, + # xemu (xbox) keeps the emulated HDD in raw format so its FATX partition + # can be read directly, and a raw image cannot hold QEMU snapshots. No + # states at all, so the launch screen reports the save instead of + # offering slots. Saves round-trip through /save-file, not /memory-card. + "xbox": { + "max_slots": 0, + "has_autosave": False, + "autosave_slot": 0, + "has_memory_card": False, + }, } -_NO_CAPABILITIES: PlatformCapabilities = { +_NO_CAPABILITIES: _SlotCapabilities = { "max_slots": 0, "has_autosave": False, "autosave_slot": 0, + "has_memory_card": False, +} + +# Keyed by emulator, consulted when the platform itself is not listed above. +# RetroArch serves dozens of platforms from one container and the operator +# picks which in their config, so enumerating them here would be a second copy +# of the broker's core table that goes stale the moment the broker gains a +# platform. +_EMULATOR_CAPABILITIES: dict[str, _SlotCapabilities] = { + # The webstation broker resolves every state route to a single working + # slot, since RomM is the library of states. There is no grid to pick + # from, just the one slot save and resume both land in. + "retroarch": { + "max_slots": 0, + "has_autosave": True, + "autosave_slot": 10, + "has_memory_card": False, + }, + # PPSSPP has no control socket, so it works the same way: every save/load + # route resolves to the one slot its controls.ini hotkey always lands on + # (PPSSPP_STATE_SLOT, default 1). Saves live on the emulated Memory Stick + # and round-trip through /save-file, not /memory-card. Kept apart from + # ngc/wii/wiiu/ps2/xbox above (platform-keyed) because psp can also be + # served through RetroArch, which needs its own generic entry to win + # instead. + "ppsspp": { + "max_slots": 0, + "has_autosave": True, + "autosave_slot": 1, + "has_memory_card": False, + }, } +# Platforms whose emulator can change discs on a running game. Kept apart from +# the tables above because those are keyed by platform or by emulator and this +# is neither: RetroArch serves dozens of platforms and only these five load a +# playlist its tray commands can step through. +_DISC_SWAP_PLATFORMS = frozenset({"dc", "saturn", "segacd", "turbografx-cd", "dos"}) + +# Platforms with no swap route but a working manual swap inside the emulator's +# own UI. The frontend shows this as a static hint, not a control. +_MANUAL_DISC_SWAP_PLATFORMS = frozenset({"ps2"}) + + +def _configured_emulator(platform: str) -> str: + """The emulator a configured container serves this platform with, if any.""" + for container in _get_streaming_config().get("containers", []): + if str(container.get("platform", "")).lower() == platform: + return _emulator_for_container(container) + return "" + + def platform_capabilities(platform: str) -> PlatformCapabilities: - """Save-state capabilities for a platform slug, or a no-slots default.""" - return _PLATFORM_CAPABILITIES.get(platform.lower(), _NO_CAPABILITIES) + """Save-state and disc capabilities for a platform slug, or a no-slots + default. + + A platform listed explicitly wins, so a platform served by more than one + emulator keeps the semantics its own entry describes. The disc flags are an + overlay on top of that, keyed only by platform. + """ + platform = platform.lower() + base = _PLATFORM_CAPABILITIES.get(platform) or _EMULATOR_CAPABILITIES.get( + _configured_emulator(platform), _NO_CAPABILITIES + ) + return { + "max_slots": base["max_slots"], + "has_autosave": base["has_autosave"], + "autosave_slot": base["autosave_slot"], + "has_memory_card": base["has_memory_card"], + "supports_disc_swap": platform in _DISC_SWAP_PLATFORMS, + "has_manual_disc_swap": platform in _MANUAL_DISC_SWAP_PLATFORMS, + } -# Coarse request-body bounds, derived from the table so the slot ranges live in +def _known_to_lack_memory_card(platform: str) -> bool: + """True only for a platform listed above as having no memory card. + + An unlisted platform is unknown, not cardless. The operator opted in and + their broker may well serve /memory-card, so the flag is honoured there. + """ + capabilities = _PLATFORM_CAPABILITIES.get(platform.lower()) + return capabilities is not None and not capabilities["has_memory_card"] + + +# Coarse request-body bound, derived from the table so the slot range lives in # exactly one place. The per-platform check in the routes is the tighter, -# authoritative guard; these just reject obviously out-of-range input up front. -_MAX_SAVE_SLOT = max( - (c["max_slots"] for c in _PLATFORM_CAPABILITIES.values()), default=1 -) -_MAX_LOAD_SLOT = max( - (max(c["max_slots"], c["autosave_slot"]) for c in _PLATFORM_CAPABILITIES.values()), +# authoritative guard; this just rejects obviously out-of-range input up front. +_MAX_SLOT = max( + ( + max(c["max_slots"], c["autosave_slot"]) + for c in (*_PLATFORM_CAPABILITIES.values(), *_EMULATOR_CAPABILITIES.values()) + ), default=1, ) -def _assert_valid_slot(platform: str, slot: int, *, allow_autosave: bool) -> None: +def _assert_valid_slot(platform: str, slot: int) -> None: """Reject a slot the platform does not expose before hitting the broker.""" caps = platform_capabilities(platform) valid = 1 <= slot <= caps["max_slots"] - if allow_autosave and caps["has_autosave"] and slot == caps["autosave_slot"]: + if caps["has_autosave"] and slot == caps["autosave_slot"]: valid = True if not valid: raise HTTPException( @@ -176,10 +780,28 @@ 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 + # Answer to the one-time import prompt on a container whose pre-existing + # card has never been adopted. "adopt" keeps it, "discard" wipes it, and + # "discard" doubles as the override for a card that could not be read. + card_import: Literal["adopt", "discard"] | None = None + # Decided on the launch screen and fixed for the session. True advertises + # the session on GET /sessions/joinable and tells the room to show its + # comms surface while the host is still alone. + multiplayer: bool = False class SaveAndExitRequest(BaseModel): - slot: Annotated[int, Field(ge=0, le=10)] = 0 + # 0 leaves the slot to the broker's own exit save. Anything else is a + # coarse union bound here and the exact per-platform ceiling in the route. + slot: Annotated[int, Field(ge=0, le=_MAX_SLOT)] = 0 wait: bool = True @@ -192,78 +814,411 @@ class MuteRequest(BaseModel): class SaveStateRequest(BaseModel): - # Coarse union bound; the route validates the exact per-platform ceiling - # against _PLATFORM_CAPABILITIES. - slot: Annotated[int, Field(ge=1, le=_MAX_SAVE_SLOT)] = 1 + # Coarse union bound (widest is the autosave slot); the route validates the + # exact per-platform ceiling against _PLATFORM_CAPABILITIES. + slot: Annotated[int, Field(ge=1, le=_MAX_SLOT)] = 1 + + +class SwapDiscRequest(BaseModel): + file_id: Annotated[int, Field(ge=1)] class LoadStateRequest(BaseModel): # Coarse union bound (widest is the autosave slot); the route validates the # exact per-platform ceiling against _PLATFORM_CAPABILITIES. - slot: Annotated[int, Field(ge=1, le=_MAX_LOAD_SLOT)] = 1 + slot: Annotated[int, Field(ge=1, le=_MAX_SLOT)] = 1 + + +class DesktopSessionRequest(BaseModel): + # The container to open, named by the key GET /streaming/containers + # reports. Named rather than pooled: an admin configuring a container + # needs that one, not whichever is free. + container: Annotated[str, Field(min_length=1, max_length=300)] + + +# Keys a platform block may set for itself. Everything else on a container +# describes the container, not one platform it serves. +PLATFORM_OVERRIDE_KEYS = ("emulator", "label", "memory_card_sync") + + +def _platform_row( + base: dict[str, Any], platform: str, options: Any +) -> dict[str, Any] | None: + """One expanded row for a platform, or None when the entry is unusable. + + `options` is either the emulator name or a block overriding container keys. + """ + if isinstance(options, str): + emulator = options.strip() + overrides: dict[str, Any] = {} + elif isinstance(options, dict): + raw = options.get("emulator") + emulator = raw.strip() if isinstance(raw, str) else "" + overrides = { + k: v + for k, v in options.items() + if k in PLATFORM_OVERRIDE_KEYS and k != "emulator" + } + for key in options: + if key not in PLATFORM_OVERRIDE_KEYS: + log.warning( + "container platform '%s' sets unknown option '%s', ignoring", + platform, + key, + ) + else: + log.warning( + "container platform '%s' must name an emulator or set a block of " + "options, skipping", + platform, + ) + return None + + if not emulator: + # The emulator names the state and card namespace, so guessing one + # would file this platform's saves under another container. + log.warning("container platform '%s' has no emulator, skipping", platform) + return None + + return {**base, **overrides, "platform": platform, "emulator": emulator} + + +def _expand_containers(entries: Any) -> list[dict[str, Any]]: + """One entry per (container, platform). + + A container declaring `platforms` yields a copy per platform with + `platform` and `emulator` filled in; a flat entry yields itself. A map + value is either the emulator name or a block overriding container keys for + that one platform. Every copy keeps the same host, so `_container_key` + collapses them back into the one session the container can actually serve. + """ + expanded: list[dict[str, Any]] = [] + for entry in entries or []: + if not isinstance(entry, dict): + continue + + platforms = entry.get("platforms") + if platforms is None: + expanded.append(entry) + continue + if not isinstance(platforms, dict): + log.warning( + "container `platforms` must be a map of platform to emulator, " + "skipping: %s", + entry, + ) + continue + if entry.get("platform"): + log.warning( + "container declares both `platform` and `platforms`, " + "serving `platforms` only: %s", + entry, + ) + + base = {k: v for k, v in entry.items() if k != "platforms"} + for platform, options in platforms.items(): + if not isinstance(platform, str) or not platform.strip(): + log.warning( + "container platform key is not a name, skipping: %r", platform + ) + continue + row = _platform_row(base, platform.strip(), options) + if row is not None: + expanded.append(row) + return expanded def _get_streaming_config() -> dict[str, Any]: """Extract streaming config from the parsed Config object""" cfg = cm.get_config() - return {"enabled": cfg.STREAMING_ENABLED, "containers": cfg.STREAMING_CONTAINERS} + return { + "enabled": cfg.STREAMING_ENABLED, + "containers": _expand_containers(cfg.STREAMING_CONTAINERS), + } -# ── Routes ──────────────────────────────────────────────────────────────────── +def _interchangeable(first: dict[str, Any], other: dict[str, Any]) -> bool: + """Whether two containers serving a platform are a pool rather than two + different setups. The emulator names the state and card namespace, and + whole-card sync decides whether cards are synced at all, so a player landing + on either container has to find their saves in the same place.""" + return _emulator_for_container(first) == _emulator_for_container( + other + ) and _memory_card_sync_enabled(first) == _memory_card_sync_enabled(other) -def _container_for_platform(platform: str) -> dict[str, Any] | None: +def _containers_for_platform(platform: str) -> list[dict[str, Any]]: + """Every container serving a platform, in config order. + + More than one entry is a pool and the claim takes the first free one. + Config order is deliberate: the head of the list stays warm (shader caches, + BIOS, memory cards) instead of players spreading across cold containers. + """ cfg = _get_streaming_config() if not cfg.get("enabled", False): - return None + return [] lower = platform.lower() + candidates: list[dict[str, Any]] = [] for entry in cfg.get("containers", []): if not isinstance(entry, dict): continue - # An entry needs both a platform and a scheme-bearing host. - # Skipping a malformed entry here means claim / control routes raise - # a clean 404 instead of a 500 on container["host"]. + # An entry needs a platform and a host that is either scheme bearing + # or a proxied path. Skipping a malformed entry here means claim / + # control routes raise a clean 404 instead of a 500 on container["host"]. if entry.get("platform", "").lower() != lower: continue - if not _parse_host_url(entry.get("host", "")): + if not _parse_stream_host(entry.get("host", "")): + log.warning( + "container for platform '%s' missing a scheme-bearing host " + "or a proxied path, skipping: %s", + platform, + entry, + ) + continue + if not _derive_broker_host(entry): + # A proxied host carries no address RomM can call, so the broker + # is only reachable if the operator named it. + log.warning( + "container for platform '%s' has no reachable broker, set " + "broker_host, skipping: %s", + platform, + entry, + ) + continue + if entry.get("memory_card_sync", False) and _known_to_lack_memory_card(lower): + log.warning( + "container for platform '%s' sets memory_card_sync but that " + "platform has no memory card, ignoring the flag and syncing " + "individual save files instead", + platform, + ) + if candidates and not _interchangeable(candidates[0], entry): log.warning( - "container for platform '%s' missing a scheme-bearing " - "host, skipping: %s", + "container for platform '%s' disagrees with the first one on " + "emulator or memory card sync, so it is not a pool member, " + "skipping: %s", platform, entry, ) continue - return entry + candidates.append(entry) + return candidates + + +def _containers_by_key() -> dict[str, list[dict[str, Any]]]: + """Configured containers grouped by key. A container serving several + platforms expands into one entry per platform, all sharing one key.""" + grouped: dict[str, list[dict[str, Any]]] = {} + for entry in _get_streaming_config().get("containers", []): + if isinstance(entry, dict): + grouped.setdefault(_container_key(entry), []).append(entry) + return grouped + + +def _container_label(container_key: str, entries: list[dict[str, Any]]) -> str | None: + """The container's own default label, ignoring any per-platform override. + + `label` is a PLATFORM_OVERRIDE_KEYS entry, so `entries[0]` may carry a + platform-specific override rather than the container's default. Look it + up on the raw, pre-expansion config entry instead. + """ + raw_containers: Any = cm.get_config().STREAMING_CONTAINERS or [] + for raw in raw_containers: + if isinstance(raw, dict) and _container_key(raw) == container_key: + return raw.get("label") + return entries[0].get("label") if entries else None + + +def _container_for_session( + grouped: dict[str, list[dict[str, Any]]], container_key: str, platform: Any +) -> dict[str, Any] | None: + """The config entry a session was claimed under. Entries sharing a key + differ in the platform-keyed fields (emulator, card sync), so picking an + arbitrary one would file the session's saves under another platform.""" + entries = grouped.get(container_key) + if not entries: + return None + if isinstance(platform, str): + lower = platform.lower() + for entry in entries: + if entry.get("platform", "").lower() == lower: + return entry + return entries[0] + + +def _swappable_disc_file_ids(rom: Rom) -> set[int]: + """The rom files that are valid swap targets. + + Mirrors the playlist filtering the client and the download endpoint use: + the .m3u is never a target, and when .cue files are present the raw tracks + they reference are not either. + """ + files = [f for f in rom.files if f.file_extension.lower() != "m3u"] + cues = [f for f in files if f.file_extension.lower() == "cue"] + return {f.id for f in (cues or files)} + + +def _session_rom_is_visible(request: Request, session: dict[str, Any]) -> bool: + """Can the caller see the ROM a session is running? + + Sessions outlive nothing but the cache, so a rom_id that no longer + resolves is treated as visible: there is no hidden ROM left to protect. + """ + rom_id = session.get("rom_id") + if rom_id is None: + return True + rom = db_rom_handler.get_rom(rom_id) + if rom is None: + return True + if not request.user.is_authenticated: + return True + return get_permissions(request).can_see_rom(rom.id, rom.platform_id) + + +def _assert_session_rom_visible( + request: Request, session: dict[str, Any], *, not_found_detail: str +) -> None: + """Raise 404 when the session's ROM is hidden from the caller.""" + if not _session_rom_is_visible(request, session): + raise HTTPException(status_code=404, detail=not_found_detail) + + +def _visible_rom_name(request: Request, session: dict[str, Any]) -> str | None: + """The name of the ROM a session is running, blanked when the caller cannot + see that ROM. "Busy" is safe to report to anyone; what is running is not.""" + if not session or not _session_rom_is_visible(request, session): + return None + name = session.get("rom_name") + return str(name) if name else None + + +def _platform_is_visible(request: Request, platform_slug: str) -> bool: + """Can the caller see this platform? + + A container may name a slug the library has never scanned, which has no + platform row and so nothing to hide. + """ + if not platform_slug or not request.user.is_authenticated: + return True + platform = db_platform_handler.get_platform_by_slug(platform_slug) + if platform is None: + return True + return get_permissions(request).can_see_platform(platform.id) + + +async def _find_session_for_user( + candidates: list[dict[str, Any]], user_id: int +) -> tuple[dict[str, Any], str, dict[str, Any]] | None: + """The candidate holding this user's session, as (container, key, session). + + With a pool the platform no longer identifies the container, the session + does. A container being drained is nobody's, whether the marker replaced + the session (release) or was set on it (teardown): its emulator is already + being killed, so treating it as held would report a live stream and let + control routes act on it. + """ + for candidate in candidates: + session_key = _container_key(candidate) + session = await _get_session(session_key) + if session is None or session.get("draining"): + continue + if session.get("user_id") == user_id: + return candidate, session_key, session return None +async def _resolve_named_container( + platform: str, container_key: str +) -> tuple[dict[str, Any], str, dict[str, Any] | None]: + """One named container serving a platform, plus whatever session it holds. + + Returns (container, session_key, session), the session being None when the + container is free or draining. Raises 404 when the key names no container + serving this platform. + """ + for candidate in _containers_for_platform(platform): + session_key = _container_key(candidate) + if session_key != container_key: + continue + session = await _get_session(session_key) + if session is not None and session.get("draining"): + session = None + return candidate, session_key, session + raise HTTPException( + status_code=404, + detail=f"No streaming container '{container_key}' for platform '{platform}'", + ) + + async def _resolve_owned_session( platform: str, request: Request ) -> tuple[dict[str, Any], str, dict[str, Any]]: - """Map platform → container, fetch its active session, verify ownership. + """Find the caller's session among the platform's containers. Returns (container, session_key, session). Raises 404 when the platform has - no configured container or no active session, 403 when the session belongs - to a different user. + no configured container or nothing is active, 403 when every active session + belongs to someone else, 409 when an admin's fallback is ambiguous. """ - container = _container_for_platform(platform) - if container is None: + candidates = _containers_for_platform(platform) + if not candidates: raise HTTPException( status_code=404, detail=f"No streaming container configured for platform '{platform}'", ) - session_key = _container_key(container) - session = await _get_session(session_key) - if session is None: + + others: list[tuple[dict[str, Any], str, dict[str, Any]]] = [] + for candidate in candidates: + session_key = _container_key(candidate) + session = await _get_session(session_key) + if session is None or session.get("draining"): + continue + if session.get("user_id") == request.user.id: + return candidate, session_key, session + others.append((candidate, session_key, session)) + + if not others: raise HTTPException( status_code=404, detail=f"No active session for platform '{platform}'" ) - _assert_session_owner(session, request) - return container, session_key, session - - -# ── Broker communication ──────────────────────────────────────────────────────────────────── + # An admin may control a session they do not own, but the scan found none of + # theirs, so fall back to the platform's active session. A pool can hold + # several and the path does not say which, so the caller has to name one. + if request.user.role != Role.ADMIN: + raise HTTPException( + status_code=403, detail="Session is claimed by another user" + ) + if len(others) > 1: + raise HTTPException( + status_code=409, + detail=( + f"{len(others)} sessions are active on platform '{platform}', " + "name a container instead" + ), + ) + return others[0] + + +# ── 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 - save archive and memory card transfers; state transfers use +# their own per-emulator deadline, see _STATE_TRANSFER_LIMITS +# 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 +# The broker waits for the core to report a running game before touching the +# tray, then sits out the tray settle, so this has to outlast that wait. +_BROKER_SWAP_DISC_TIMEOUT = 120 +_CARD_HYDRATE_TIMEOUT = 120 +_CARD_TEARDOWN_TIMEOUT = 30 def _broker_url(container: dict[str, Any], path: str) -> str: @@ -299,6 +1254,57 @@ def _broker_secret(container: dict[str, Any]) -> str: return STREAMING_BROKER_SECRET or container.get("broker_secret", "") +def _broker_headers(container: dict[str, Any]) -> dict[str, str]: + """Auth headers for a broker call, empty when no secret is configured. + Returns a fresh dict so callers can add their own headers to it.""" + secret = _broker_secret(container) + return {"X-Broker-Secret": secret} if secret else {} + + +# urllib's timeout bounds a single socket operation, not the transfer, so a +# broker feeding one byte per timeout window can hold a read open for as long +# as it likes. Reading in chunks against a wall clock is what ends it. +_BROKER_READ_CHUNK = 1024 * 1024 +# Control responses are small; a JSON body past this is a broker fault. +_BROKER_JSON_MAX_BYTES = 4 * 1024 * 1024 +# An error body only ever reaches a log line and a 502 detail. +_BROKER_ERROR_MAX_BYTES = 8 * 1024 + + +def _broker_error_body(exc: urllib.error.HTTPError) -> str: + """The text a failed broker call answered with. + + HTTPError is itself the response, so it holds a connection until something + closes it, and its body is as long as the broker cares to make it. + """ + try: + return exc.read(_BROKER_ERROR_MAX_BYTES).decode(errors="replace") + except OSError as read_exc: + log.warning("could not read broker error body, %s", read_exc) + return "" + finally: + exc.close() + + +def _read_bounded(resp: Any, max_bytes: int, deadline: float) -> bytes: + """Read up to `max_bytes` from an open response, giving up at `deadline`. + + Reads one byte past the cap so the caller can tell a body that fits from + one that was truncated. + """ + chunks: list[bytes] = [] + size = 0 + while size <= max_bytes: + if time.monotonic() > deadline: + raise TimeoutError("broker response exceeded its time budget") + chunk = resp.read(min(_BROKER_READ_CHUNK, max_bytes + 1 - size)) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) + return b"".join(chunks) + + def _broker_request( container: dict[str, Any], path: str, @@ -314,16 +1320,18 @@ def _broker_request( error; callers decide whether to surface or swallow it. """ url = _broker_url(container, path) - secret = _broker_secret(container) - headers = {"X-Broker-Secret": secret} if secret else {} + headers = _broker_headers(container) data = None if body is not None: data = json.dumps(body).encode() headers["Content-Type"] = "application/json" headers["Content-Length"] = str(len(data)) req = urllib.request.Request(url, data=data, method=method, headers=headers) + deadline = time.monotonic() + timeout with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 - raw = resp.read() + raw = _read_bounded(resp, _BROKER_JSON_MAX_BYTES, deadline) + if len(raw) > _BROKER_JSON_MAX_BYTES: + raise ValueError("broker response exceeds size limit") return json.loads(raw) if raw else {} @@ -347,31 +1355,147 @@ def _broker_request_safe( ) except Exception as exc: log.warning("broker %s failed, %s", label, exc) + # An HTTPError is an open response, and these routes are called often. + if isinstance(exc, urllib.error.HTTPError): + exc.close() return None -def _call_broker(container: dict[str, Any], rom_path: str, rom_name: str) -> None: +def _broker_get_binary( + container: dict[str, Any], + path: str, + *, + max_bytes: int, + timeout: float, +) -> tuple[Message, bytes]: """ - POST to the broker's /launch endpoint to tell the emulator container to - load a ROM. + GET a binary body from the broker, returning (response headers, content). + Headers come back because some routes carry metadata there. Raises the + underlying urllib/OS error, or ValueError for an empty or oversized body. + """ + req = urllib.request.Request( + _broker_url(container, path), + method="GET", + headers=_broker_headers(container), + ) + deadline = time.monotonic() + timeout + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 + headers = resp.headers + content = _read_bounded(resp, max_bytes, deadline) + if not content: + raise ValueError("broker returned an empty body") + if len(content) > max_bytes: + raise ValueError("broker response exceeds size limit") + return headers, content - Raises HTTPException if the broker is unreachable or returns an error. + +def _broker_get_binary_safe( + container: dict[str, Any], + path: str, + label: str, + *, + max_bytes: int, + timeout: float, +) -> tuple[Message, bytes] | None: + """ + Best-effort variant of _broker_get_binary: returns None instead of raising. + A 404 is a normal answer on these routes (no state in the slot, no new + saves, no captured frame), so it is not logged. """ - url = _broker_url(container, "/launch") try: - body = _broker_request( - container, - "/launch", - body={"rom_path": rom_path, "rom_name": rom_name}, - timeout=10, - ) - log.info("broker launched ROM, %s", body) + return _broker_get_binary(container, path, max_bytes=max_bytes, timeout=timeout) except urllib.error.HTTPError as exc: - error_body = exc.read().decode(errors="replace") - log.error("broker HTTP error %d: %s", exc.code, error_body) - try: - detail = json.loads(error_body) - except Exception: + # The error is a response too, and these routes are polled. + exc.close() + if exc.code != 404: + log.warning("broker %s failed, HTTP %d", label, exc.code) + return None + except Exception as exc: + log.warning("broker %s failed, %s", label, exc) + return None + + +def _broker_put_binary_json( + container: dict[str, Any], + path: str, + content: bytes, + label: str, + *, + content_type: str, + timeout: float, +) -> dict[str, Any] | None: + """ + PUT a binary body to the broker and return its parsed JSON reply, or None + on failure. Best-effort, logs but never raises. + """ + req = urllib.request.Request( + _broker_url(container, path), + data=content, + method="PUT", + headers={ + "Content-Type": content_type, + "Content-Length": str(len(content)), + **_broker_headers(container), + }, + ) + deadline = time.monotonic() + timeout + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 + body = json.loads(_read_bounded(resp, _BROKER_JSON_MAX_BYTES, deadline)) + return body if isinstance(body, dict) else {} + except Exception as exc: + log.warning("broker %s failed, %s", label, exc) + if isinstance(exc, urllib.error.HTTPError): + exc.close() + return None + + +def _broker_put_binary( + container: dict[str, Any], + path: str, + content: bytes, + label: str, + *, + content_type: str, + timeout: float, +) -> bool: + """PUT a binary body to the broker, reporting whether it acked with ok.""" + body = _broker_put_binary_json( + container, path, content, label, content_type=content_type, timeout=timeout + ) + return bool(body and body.get("status") == "ok") + + +def _call_broker( + container: dict[str, Any], + rom_path: str, + rom_name: str, + load_slot: int | None = None, +) -> dict[str, Any]: + """ + POST to the broker's /launch endpoint to tell the emulator container to + load a ROM. + + 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. Returns the parsed launch response body. + """ + 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: + resp = _broker_request( + container, "/launch", body=body, timeout=_BROKER_LAUNCH_TIMEOUT + ) + log.info("broker launched ROM, %s", resp) + return resp if isinstance(resp, dict) else {} + except urllib.error.HTTPError as exc: + error_body = _broker_error_body(exc) + log.error("broker HTTP error %d: %s", exc.code, error_body) + try: + detail = json.loads(error_body) + except Exception: detail = error_body raise HTTPException( status_code=502, @@ -391,34 +1515,56 @@ 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 # save + reset path can approach that too. Time out past the slowest # broker so a slow-but-successful save is not reported as saved=False. # Overridable for operators who raise SAVE_WAIT on a broker. + if _is_webstation(container): + # No background variant on this protocol: exit always runs the save, + # the teardown and the save dump together before it answers. + report = _webstation_exit(container, slot) + saved = bool(report and report.get("state_saved", False)) + effective_slot = slot + if report is not None and isinstance(report.get("state_slot"), int): + effective_slot = report["state_slot"] + log.info("broker exit, saved=%s slot=%d", saved, effective_slot) + return saved, effective_slot + body = _broker_request_safe( container, "/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,157 +1576,2267 @@ 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 def _save_state_broker(container: dict[str, Any], slot: int) -> bool: """POST /save-state to the broker. Returns True if the request was accepted.""" + if _is_webstation(container): + # Synchronous on this protocol: the broker answers once the emulator + # acked the write, so it needs the same budget as the exit save. + body = _broker_request_safe( + container, + _webstation_path(container, "/save-state"), + "save-state", + body={"slot": slot}, + timeout=STREAMING_SAVE_TIMEOUT, + ) + return bool(body and body.get("saved", False)) + 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") def _load_state_broker(container: dict[str, Any], slot: int) -> bool: """POST /load-state to the broker. Returns True if broker confirmed success.""" + if _is_webstation(container): + body = _broker_request_safe( + container, + _webstation_path(container, "/load-state"), + "load-state", + body={"slot": slot}, + timeout=_BROKER_LOAD_STATE_TIMEOUT, + ) + return bool(body and body.get("loaded", False)) + # 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) +def _swap_disc_broker(container: dict[str, Any], disc_path: str) -> bool: + """POST /swap-disc to the broker. True once the disc is mounted.""" + if _is_webstation(container): + body = _broker_request_safe( + container, + _webstation_path(container, "/swap-disc"), + "swap-disc", + body={"path": disc_path}, + timeout=_BROKER_SWAP_DISC_TIMEOUT, + ) + return bool(body and body.get("status") == "ok") + # Only the webstation broker has a tray protocol; the legacy per-emulator + # brokers have no route to call. + return False -# ── Routes ──────────────────────────────────────────────────────────────────── +def _stop_broker(container: dict[str, Any], save: bool = True) -> int | None: + """Tell the broker to stop emulator. Best-effort, don't raise on failure. + + Returns the slot a state was captured in, or None when none was. With + `save` off no state is written at all, which is what a player leaving + without saving asked for; the game's own save data still travels either + way, so progress made at an in-game save point survives the stop. + + `save` only reaches webstation containers. The per-emulator brokers have + one stop and it writes no state, so they are already what `save` off asks + for and there is nothing to pass them. + """ + if _is_webstation(container): + report = _webstation_exit(container, slot=0, save=save) + if save and report and report.get("state_saved"): + slot = report.get("state_slot") + return slot if isinstance(slot, int) else None + return None + _broker_request_safe( + container, "/launch", "stop", method="DELETE", timeout=_BROKER_ACK_TIMEOUT + ) + return None + + +# ── Webstation broker protocol ──────────────────────────────────────────────── # -# Reads gate on ROMS_READ; anything that creates, controls or releases a session -# gates on ROMS_USER_WRITE, matching the play-session routes. ROMS_USER_WRITE is -# always-on for authenticated users, so this costs no real user anything, but it -# is absent from READ_SCOPES -- which is all KIOSK_MODE hands an anonymous -# visitor. Without it, kiosk visitors (who all share one synthetic user, so -# session ownership cannot separate them) could claim sessions and overwrite -# each other's save states. +# One webstation container replaces the per-emulator mods, and its contract +# differs enough to need translating. It hosts a single session behind a +# subfolder; activate carries the user, the rom and the save data in one body; +# and exit does the save state, the teardown and the save dump together. +# +# The awkward part is save transfer. Activate names the restore archive by +# container path, but RomM holds bytes and runs on another host, so an archive +# is uploaded first and the path it returns is what activate gets. On the way +# out the broker pushes to a callback, which is unreachable in dev mode and +# lost on a failed push, so RomM pulls from the export list instead and deletes +# what it stored. +# +# Save states round-trip through the same /state-file routes as the other +# brokers, just under the subfolder, so RomM holds the library either way. The +# difference is that this broker keeps one working slot instead of ten: a slot +# sent to it resolves to that one, and a pushed state is only accepted while a +# session is up. Reads outlive the session, because exit captures a state and +# RomM can only come back for it once the teardown has answered. What is still +# missing here is volume, mute and whole-card sync. -@protected_route(router.get, "/config", [Scope.ROMS_READ]) -async def get_config(request: Request) -> JSONResponse: - """Return streaming configuration to the frontend""" - cfg = _get_streaming_config() +def _is_webstation(container: dict[str, Any]) -> bool: + return str(container.get("protocol", "")).strip().lower() == "webstation" - safe_containers = [] - for c in cfg.get("containers", []): - if not c.get("platform") or not c.get("host"): - log.warning("container missing platform/host, skipping: %s", c) - continue - platform = c.get("platform", "") - safe_containers.append( - { - "platform": platform, - "host": c.get("host"), - "label": c.get("label") or platform.upper(), - # Ship slot capabilities so the frontend selector reads them - # instead of keeping its own hardcoded per-platform copy. - "capabilities": platform_capabilities(platform), - } +def _broker_session_id(session: dict[str, Any]) -> str | None: + """The id activate gave the broker, absent on sessions claimed before it.""" + value = session.get("broker_session_id") + return str(value) if value else None + + +def _webstation_path(container: dict[str, Any], path: str) -> str: + """Prefix a session route with the container's SUBFOLDER.""" + subfolder = str(container.get("subfolder", "/streaming")).strip() + if not subfolder.startswith("/"): + subfolder = f"/{subfolder}" + return f"{subfolder.rstrip('/')}/api/session{path}" + + +def _container_capabilities(container: dict[str, Any]) -> PlatformCapabilities: + """The save-state controls the frontend may offer for this container. + + Disc swap is keyed by platform, but only the webstation broker has a tray + route, so it is cleared here rather than in `platform_capabilities`: a + legacy container must not advertise a control whose every use 502s. + """ + capabilities = platform_capabilities(str(container.get("platform", ""))) + if not _is_webstation(container): + capabilities = {**capabilities, "supports_disc_swap": False} + return capabilities + + +def _webstation_activate( + container: dict[str, Any], + *, + session_id: str, + user: User, + emulator: str, + rom: dict[str, Any] | None = None, + archive_path: str | None = None, + resume_slot: int | None = None, + memory_card_synced: bool = False, + multiplayer: bool = False, +) -> dict[str, Any]: + """POST /activate. Raises HTTPException the same way _call_broker does. + + `rom` is omitted for emulators the broker registers with requires_rom + False, the desktop being the one that matters here. + """ + body: dict[str, Any] = { + "session_id": session_id, + "user": { + "id": user.id, + "username": user.username, + "display_name": user.username, + }, + "emulator": emulator, + "multiplayer": multiplayer, + } + if rom is not None: + body["rom"] = rom + save: dict[str, Any] = {} + if archive_path: + save["archive"] = archive_path + if resume_slot is not None: + save["resume_slot"] = resume_slot + if memory_card_synced: + # The card travels on its own routes, so the broker leaves it out of + # both the archive it restores and the one it dumps at exit. + save["memory_card_synced"] = True + if save: + body["save"] = save + + path = _webstation_path(container, "/activate") + try: + resp = _broker_request( + container, path, body=body, timeout=_BROKER_LAUNCH_TIMEOUT ) + except urllib.error.HTTPError as exc: + error_body = _broker_error_body(exc) + log.error("broker HTTP error %d: %s", exc.code, error_body) + try: + detail = json.loads(error_body) + except Exception: + detail = error_body + raise HTTPException( + status_code=502, detail=f"Broker returned {exc.code}: {detail}" + ) from exc + except (urllib.error.URLError, OSError) as exc: + url = _broker_url(container, path) + log.error("broker unreachable at %s: %s", url, exc) + raise HTTPException( + status_code=503, + detail=( + f"Could not reach the webstation broker at {url}. " + "Check that the container is running and its broker port is " + "reachable from the RomM host." + ), + ) from exc - return JSONResponse( - { - "enabled": cfg.get("enabled", False), - "containers": safe_containers, - } + resp = resp if isinstance(resp, dict) else {} + log.info("broker activated session, %s", resp) + return resp + + +def _webstation_join(container: dict[str, Any], user: User) -> dict[str, Any] | None: + """POST /api/session/join. The broker's answer, or None if it refused. + + The broker mints the seat and replies with a landing URL carrying the new + viewer's own token. Nothing here grants control of the container: every + control route still goes through _assert_session_owner. + """ + body = _broker_request_safe( + container, + _webstation_path(container, "/join"), + "join", + body={ + "user": { + "id": user.id, + "username": user.username, + "display_name": user.username, + }, + "permission": "participant", + }, + timeout=_BROKER_ACK_TIMEOUT, ) + return body if isinstance(body, dict) else None -@protected_route(router.post, "/sessions", [Scope.ROMS_USER_WRITE]) -async def claim_session( - request: Request, req: Annotated[ClaimSessionRequest, Body()] -) -> JSONResponse: +def _webstation_exit( + container: dict[str, Any], slot: int, save: bool = True +) -> dict[str, Any] | None: + """POST /exit. Best-effort, the caller is already tearing the session down. + + Slot 0 is a real slot on this broker (it keeps one working slot), so the + request carries it like any other and the save flag, not the number, is + what says whether a state is wanted at all. """ - Claim a streaming session and tell the broker to load the ROM. + query = f"?slot={slot}" + ("" if save else "&save=0") + body = _broker_request_safe( + container, + _webstation_path(container, f"/exit{query}"), + "exit", + timeout=STREAMING_SAVE_TIMEOUT, + ) + return body if isinstance(body, dict) else None - The ROM's filesystem path is derived server-side from its database row - - the client only supplies a ROM id, never a path. - Returns 404 if the ROM doesn't exist or no container serves its platform. - Returns 409 if the container is already occupied. - Returns 502/503 if the broker rejects the launch or is unreachable. + +def _webstation_upload_archive( + container: dict[str, Any], name: str, content: bytes +) -> str | None: + """PUT a save archive and return the container path activate wants.""" + body = _broker_put_binary_json( + container, + _webstation_path(container, f"/imports/{quote(name, safe='')}"), + content, + "archive upload", + content_type="application/zip", + timeout=_BROKER_TRANSFER_TIMEOUT, + ) + if not body or not body.get("path"): + return None + return str(body["path"]) + + +def _webstation_exports(container: dict[str, Any]) -> list[dict[str, Any]]: + """Save archives waiting on the container, newest first.""" + body = _broker_request_safe( + container, + _webstation_path(container, "/exports"), + "export list", + method="GET", + timeout=_BROKER_ACK_TIMEOUT, + ) + exports = body.get("exports") if isinstance(body, dict) else None + return exports if isinstance(exports, list) else [] + + +def _webstation_collect_export(container: dict[str, Any], name: str) -> bytes | None: + """Download one archive and drop the container's copy once it is in hand. + + The name comes from the broker's own listing, so it is escaped whole: a + slash or a `..` in it would otherwise address a different broker route. """ - rom = db_rom_handler.get_rom(req.rom_id) - if rom is None: - raise HTTPException(status_code=404, detail="ROM not found") + result = _broker_get_binary_safe( + container, + _webstation_path(container, f"/exports/{quote(name, safe='')}"), + "export download", + max_bytes=_SAVE_FILE_MAX_BYTES, + timeout=_BROKER_TRANSFER_TIMEOUT, + ) + if result is None: + return None + _broker_request_safe( + container, + _webstation_path(container, f"/exports/{quote(name, safe='')}"), + "export delete", + method="DELETE", + timeout=_BROKER_ACK_TIMEOUT, + ) + return result[1] - # A hidden ROM/platform must not be launchable via its id: enforce the same - # visibility policy as the ROM detail/content endpoints before any broker - # 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) - if container is None: - raise HTTPException( - status_code=404, - detail=f"No streaming container configured for platform '{rom.platform_slug}'", +# ── 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. + +# State transfer limits, keyed by emulator. Most savestates are a RAM plus VRAM +# snapshot of tens of MB, but xemu's state is the whole Xbox hard disk image and +# zips to hundreds of MB even after trimming. Size and deadline live together so +# a state that clears one is not rejected by the other. + + +class StateTransferLimits(TypedDict): + max_bytes: int # largest state body exchanged with the broker + timeout: int # seconds allowed for that body, in either direction + + +_DEFAULT_STATE_TRANSFER: StateTransferLimits = { + "max_bytes": 256 * 1024 * 1024, + "timeout": _BROKER_TRANSFER_TIMEOUT, +} + +# Keyed by emulator name as _emulator_for_container returns it (lowercase). +_STATE_TRANSFER_LIMITS: dict[str, StateTransferLimits] = { + # The xemu broker caps its expanded hard disk image at 2 GiB, and transfers + # run around 18 MB/s, so a full-size archive needs minutes, not seconds. + "xemu": {"max_bytes": 2 * 1024 * 1024 * 1024, "timeout": 240}, +} + + +def _state_transfer_limits(container: dict[str, Any]) -> StateTransferLimits: + return _STATE_TRANSFER_LIMITS.get( + _emulator_for_container(container), _DEFAULT_STATE_TRANSFER + ) + + +# 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) -> asyncio.Task: + task = asyncio.get_running_loop().create_task(coro) + _sync_tasks.add(task) + task.add_done_callback(_sync_tasks.discard) + return task + + +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 _transfer_route(container: dict[str, Any], path: str) -> str: + """Where a state or memory card transfer lives on this container's broker. + + The webstation broker serves the same routes, only under its subfolder. + """ + return _webstation_path(container, path) if _is_webstation(container) else path + + +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. + """ + limits = _state_transfer_limits(container) + result = _broker_get_binary_safe( + container, + _transfer_route(container, f"/state-file?slot={slot}"), + "state-file GET", + max_bytes=limits["max_bytes"], + timeout=limits["timeout"], + ) + if result is None: + return None + headers, content = result + filename = headers.get("X-State-Filename", "") + if not filename: + log.warning("broker state-file response missing a filename") + return None + return filename, content + + +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.""" + return _broker_put_binary( + container, + _transfer_route(container, f"/state-file?filename={quote(filename, safe='')}"), + content, + "state-file PUT", + content_type="application/octet-stream", + timeout=_state_transfer_limits(container)["timeout"], + ) + + +# 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 +_PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +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) embeds one; + the others write the frame as its own file, served by /state-screenshot.""" + 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 + + +_STATE_FRAME_KEY_PREFIX = "romm:streaming:frame:" +# Long enough to cover the broker's state write plus the pull retries, short +# enough that a frame never outlives the save it was captured for. +_STATE_FRAME_TTL_SECONDS = 120 + + +def _state_frame_redis_key(user_id: int, rom_id: int) -> str: + return f"{_STATE_FRAME_KEY_PREFIX}{user_id}:{rom_id}" + + +async def _stash_state_frame(user_id: int, rom_id: int, image: bytes) -> None: + """Hold a browser-captured frame until the state it belongs to is pulled.""" + await async_cache.set( + _state_frame_redis_key(user_id, rom_id), + base64.b64encode(image), + ex=_STATE_FRAME_TTL_SECONDS, + ) + + +async def _take_state_frame(user_id: int, rom_id: int) -> bytes | None: + key = _state_frame_redis_key(user_id, rom_id) + raw = await async_cache.get(key) + await async_cache.delete(key) + if not raw: + return None + try: + return base64.b64decode(raw) + except (ValueError, TypeError): + return None + + +def _fetch_state_screenshot(container: dict[str, Any], slot: int) -> bytes | None: + """GET /state-screenshot from the broker, for emulators whose state files + carry no frame of their own. A 404 is the normal "this broker does not + capture frames" answer, so it is not logged.""" + result = _broker_get_binary_safe( + container, + _transfer_route(container, f"/state-screenshot?slot={slot}"), + "state-screenshot GET", + max_bytes=_STATE_SCREENSHOT_MAX_BYTES, + timeout=_BROKER_TRANSFER_TIMEOUT, + ) + return result[1] if result else None + + +async def _store_state_screenshot( + user: User, rom: Rom, state_filename: str, image: bytes +) -> None: + """Store a 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. + """ + # Both sources are unverified bytes: a zip entry that only claims to be a + # PNG, or whatever the broker returned. Guard here so one check covers both. + if not image.startswith(_PNG_MAGIC): + log.warning("state screenshot for %s is not a PNG, skipping", state_filename) + return + + 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) + + +def _user_states_for_emulator(user_id: int, rom_id: int, emulator: str) -> list[State]: + """The user's states for this ROM and emulator, newest first.""" + states = [ + s + for s in db_state_handler.get_states(user_id=user_id, rom_id=rom_id) + if (s.emulator or "").lower() == emulator + ] + # Ties on id, because updated_at only has second resolution: two captures + # in the same second would otherwise order arbitrarily, and only the first + # of them is ever hydrated. + states.sort(key=lambda s: (s.updated_at, s.id), reverse=True) + return states + + +async def _is_duplicate_of_latest(latest: State | None, content: bytes) -> bool: + """Whether ``content`` matches the most recent stored state byte for byte. + + Saving twice without playing in between is common (the exit autosave right + after a manual save), and those captures are identical. Only the newest is + compared: an older match is a genuine revisit of the same point. + """ + if latest is None or latest.file_size_bytes != len(content): + return False + try: + existing = await fs_asset_handler.read_file( + f"{latest.file_path}/{latest.file_name}" ) + except FileNotFoundError: + return False + return existing == content - # 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}" - rom_name = rom.name or rom.fs_name_no_ext - session_key = _container_key(container) - now = datetime.now(timezone.utc).isoformat() - session = { - "rom_id": rom.id, - "rom_name": rom_name, - "claimed_at": now, - "user_id": request.user.id, - } +async def _remove_pruned_file(path: str) -> None: + """Drop a pruned asset's file. A file that will not go leaves the prune + running: the rows are already gone, and stopping here would leave the rest + of the history over the limit as well.""" + try: + await fs_asset_handler.remove_file(file_path=path) + except FileNotFoundError: + log.warning("pruned file already gone, %s", path) + except OSError as exc: + log.error("could not remove pruned file %s, leaving it orphaned: %s", path, exc) + + +async def _prune_state_history(user: User, rom: Rom, emulator: str) -> int: + """Delete the oldest states past the retention limit. Returns how many went. + + A file already gone from disk still loses its row, since a stale entry that + no longer opens is worse than a missing file. + """ + limit = STREAMING_STATE_HISTORY_LIMIT + if limit <= 0: + return 0 + states = _user_states_for_emulator(user.id, rom.id, emulator) + stale = states[limit:] + for state in stale: + screenshot = state.screenshot + db_state_handler.delete_state(state.id) + await _remove_pruned_file(f"{state.file_path}/{state.file_name}") + if screenshot is not None: + db_screenshot_handler.delete_screenshot(screenshot.id) + await _remove_pruned_file(f"{screenshot.file_path}/{screenshot.file_name}") + if stale: + log.info( + "pruned %d state(s) past the %d limit, rom=%s", + len(stale), + limit, + rom.name, + ) + return len(stale) + + +async def _store_state_asset( + user: User, + rom: Rom, + emulator: str, + filename: str, + content: bytes, + screenshot: bytes | None = None, + disc_file_id: int | None = None, +) -> None: + """Store a pulled state file as a new entry in the ROM's state history. + + Each capture is kept rather than overwriting the slot it came from, so the + player can resume from any earlier point. An unchanged capture is dropped + and the oldest entries are pruned once the retention limit is reached. + """ + history = _user_states_for_emulator(user.id, rom.id, emulator) + if await _is_duplicate_of_latest(history[0] if history else None, content): + log.info("state identical to the last capture, skipping, rom=%s", rom.name) + return + + stamped = _stamped_state_filename(emulator, filename, datetime.now(timezone.utc)) + 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=stamped) + + scanned_state = await scan_state( + file_name=stamped, + 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=stamped + ) + if db_state: + # Only reachable when the stamp collides, so the file on disk was just + # overwritten and the row needs to agree with it, disc included. + db_state_handler.update_state( + db_state.id, + { + "file_size_bytes": scanned_state.file_size_bytes, + "disc_file_id": disc_file_id, + }, + ) + else: + scanned_state.rom_id = rom.id + scanned_state.user_id = user.id + scanned_state.emulator = emulator + scanned_state.disc_file_id = disc_file_id + 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. + if screenshot is not None: + try: + await _store_state_screenshot(user, rom, stamped, screenshot) + except Exception: + log.exception("failed to store state screenshot for %s", stamped) + + await _prune_state_history(user, rom, emulator) + + +async def _restore_session_disc( + rom_id: int, + container: dict[str, Any], + session_key: str, + file_id: int, + broker_session_id: str | None = None, +) -> bool: + """Background task: put back the disc a resumed state was captured on. + + The launch always mounts the ROM folder so the emulator loads the playlist + and starts on the first disc; anything else would boot a bare image the + tray commands cannot step through. So the disc is restored afterwards, and + the broker holds the swap until the core reports a running game. + + Best-effort: a failure leaves the session on disc one, which the player can + fix with the swap control. + """ + rom_file = db_rom_handler.get_rom_file_by_id(file_id) + if rom_file is None or rom_file.rom_id != rom_id: + log.warning( + "resume: could not restore disc, file %s is not in the library", file_id + ) + return False + library_base = (container.get("library_path") or LIBRARY_BASE_PATH).rstrip("/") + disc_path = f"{library_base}/{rom_file.full_path}" + if not await asyncio.to_thread(_swap_disc_broker, container, disc_path): + log.warning("resume: could not restore disc %s", rom_file.file_name) + return False + await _set_session_disc(session_key, file_id, broker_session_id) + log.info("resume: restored disc %s", rom_file.file_name) + return True + + +async def _release_after_state_pull( + session_key: str, pull: Coroutine[Any, Any, Any], token: str +) -> None: + """Run the exit state pull, then drop the drain marker it was holding behind. + + The marker goes even when the pull raised: the state is recoverable from the + container on the next claim, a container nothing releases is not. `token` + keeps that release aimed at this drain, so an overrun that outlived its own + marker cannot free whoever took the container afterwards. + """ + keepalive = asyncio.ensure_future(_hold_drain_marker(session_key, token)) + try: + await pull + finally: + keepalive.cancel() + await _drop_drain_marker(session_key, token) + + +async def _release_claim_after_state_pull( + session_key: str, pull: Coroutine[Any, Any, Any], claim: dict[str, Any] +) -> None: + """The same, for an exit whose drain marker never landed. + + Nothing took the container over in that case, so the claim itself is what + still reserves it: the pull runs under it and the claim goes afterwards. The + stamp is kept current meanwhile, since the player who owned it has left and + an unrefreshed claim reads as abandoned to the next claimant. + """ + keepalive = asyncio.ensure_future(_hold_session_claim(session_key, claim)) + try: + await pull + finally: + keepalive.cancel() + if not await _release_own_session(session_key, claim): + log.error("session %s survived its own exit", session_key) + + +async def _pull_state_to_library( + user_id: int, + rom_id: int, + container: dict[str, Any], + slot: int, + disc_file_id: int | None = None, +) -> 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 + # The browser frame is preferred: it is what the player actually saw, + # and capturing it never asks the emulator to read back its own + # framebuffer, which is what deadlocks GPU-rendered cores. PCSX2 embeds + # a frame in the state file; the rest write one beside it. + screenshot = await _take_state_frame(user_id, rom_id) + if screenshot is None: + screenshot = _extract_state_screenshot(emulator, content) + if screenshot is None: + screenshot = await asyncio.to_thread( + _fetch_state_screenshot, container, slot + ) + try: + await _store_state_asset( + user, rom, emulator, filename, content, screenshot, disc_file_id + ) + 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 _push_resume_state(container: dict[str, Any], resume_state: State) -> bool: + """Send the state a player picked to resume from down to the container. + + Best-effort: a failure means the session just starts fresh, which the claim + response reports through `resume`. + """ + try: + content = await fs_asset_handler.read_file( + f"{resume_state.file_path}/{resume_state.file_name}" + ) + except Exception: + log.exception("could not read resume state %s", resume_state.file_name) + return False + pushed = await asyncio.to_thread( + _push_state_file, + container, + _container_state_filename(resume_state.file_name), + content, + ) + if not pushed: + log.warning("resume state not pushed, launching fresh") + return pushed + + +async def _hydrate_states_to_broker( + user_id: int, + rom_id: int, + container: dict[str, Any], + resume_pushed: bool = False, +) -> int: + """Background task: push the newest stored state for this ROM down to the + freshly claimed container. Emulators read state files lazily, so pushing + right after launch is safe. + + Only the newest is sent: every history entry collapses to the same + container-side name, and that name is what the in-emulator quick-load lands + on. Older captures are reached through the resume picker instead. + + For the same reason, a resume pick already sent at claim time means there is + nothing to add here: any push would overwrite it before the broker's + deferred load fires. + """ + if resume_pushed: + return 0 + + 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) + + states = _user_states_for_emulator(user_id, rom_id, emulator) + if not states: + return 0 + + newest = states[0] + try: + content = await fs_asset_handler.read_file( + f"{newest.file_path}/{newest.file_name}" + ) + except FileNotFoundError: + log.warning("stored state missing on disk, %s", newest.file_name) + return 0 + ok = await asyncio.to_thread( + _push_state_file, + container, + _container_state_filename(newest.file_name), + content, + ) + if ok: + log.info("hydrated newest state to container, rom=%s", rom.name) + return 1 if ok else 0 + + +# ── 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 is generous for every emulator that +# separates its saves from its states. +_SAVE_FILE_MAX_BYTES = 256 * 1024 * 1024 + + +def _fetch_save_archive( + container: dict[str, Any], broker_session_id: str | None = None +) -> 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. + """ + if _is_webstation(container): + # Exit already built the delta archive and left it on the container, + # named after the session that produced it. Matching on that name is + # what keeps an archive a previous pull failed to collect from being + # filed under this session's player. + if not broker_session_id: + return None + prefix = f"{broker_session_id}-" + for export in _webstation_exports(container): + name = str(export.get("name", "")) + if name.startswith(prefix): + return _webstation_collect_export(container, name) + return None + + result = _broker_get_binary_safe( + container, + "/save-file", + "save-file GET", + max_bytes=_SAVE_FILE_MAX_BYTES, + timeout=_BROKER_TRANSFER_TIMEOUT, + ) + return result[1] if result else None + + +def _push_save_archive(container: dict[str, Any], content: bytes) -> bool: + """PUT /save-file to the broker. Best-effort, logs but never raises.""" + return _broker_put_binary( + container, + "/save-file", + content, + "save-file PUT", + content_type="application/zip", + timeout=_BROKER_TRANSFER_TIMEOUT, + ) + + +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], + broker_session_id: str | None = None, +) -> 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, broker_session_id + ) + 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 _newest_save_archive( + user_id: int, rom_id: int, emulator: str +) -> tuple[str, bytes] | None: + """The user's most recent stored save archive for this emulator, read off + disk. Returns (file name, content), or None when there is nothing to send. + """ + archives = [ + save + for save in db_save_handler.get_saves(user_id=user_id, rom_id=rom_id) + if (save.emulator or "").lower() == emulator and save.file_name.endswith(".zip") + ] + if not archives: + return None + # Ties on id, because created_at only has second resolution: two archives + # written in the same second would otherwise hydrate arbitrarily. + newest = max(archives, key=lambda s: (s.created_at, s.id)) + + 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 None + return newest.file_name, content + + +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). + """ + rom = db_rom_handler.get_rom(rom_id) + if db_user_handler.get_user(user_id) is None or rom is None: + return False + + newest = await _newest_save_archive( + user_id, rom_id, _emulator_for_container(container) + ) + if newest is None: + return False + file_name, content = newest + + ok = await asyncio.to_thread(_push_save_archive, container, content) + if ok: + log.info("hydrated saves to container, rom=%s file=%s", rom.name, file_name) + return ok + + +async def _hydrate_saves_to_webstation( + user_id: int, rom_id: int, container: dict[str, Any] +) -> str | None: + """Upload the newest stored save archive and return the container path. + + The webstation broker restores as part of activate rather than through a + push of its own, so hydration here only gets the bytes into place and + hands back the path activate names. + """ + newest = await _newest_save_archive( + user_id, rom_id, _emulator_for_container(container) + ) + if newest is None: + return None + file_name, content = newest + + path = await asyncio.to_thread( + _webstation_upload_archive, container, f"rom-{rom_id}.zip", content + ) + if path: + log.info("uploaded saves to container, file=%s path=%s", file_name, path) + return path + + +# ── Whole memory-card sync (per-user card model) ────────────────────────────── +# Opt-in per container via `memory_card_sync: true`. When on, the container's +# entire card (PCSX2 Slot 1, Dolphin Slot A) 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. + + +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: + """Whether whole-card sync is both requested and possible for a container. + + Honouring `memory_card_sync` on a platform with no memory card would be + silent data loss: whole-card sync REPLACES /save-file, so the per-file + saves that platform actually uses (Wii NAND, xemu HDD) would stop syncing + while RomM shuttled an empty card around. The flag is ignored instead, and + _containers_for_platform warns the operator once per lookup. + """ + if not container.get("memory_card_sync", False): + return False + return not _known_to_lack_memory_card(container.get("platform", "")) + + +def _memory_card_route(container: dict[str, Any]) -> str: + """Where this container's broker serves the whole Slot-1 card. + + The webstation broker hosts several emulators off one container and the + card belongs to the emulator, not to a session, so it takes the name in the + query. The per-emulator brokers serve the one card they have. + """ + path = "/memory-card" + if _is_webstation(container): + path = f"{path}?emulator={quote(_emulator_for_container(container), safe='')}" + return _transfer_route(container, path) + + +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.""" + + +# Its messages carry the broker host and port, so the client gets this fixed +# string and the real cause stays in the server log. +_CARD_UNREADABLE_REASON = "The streaming container did not return its memory card" + +_CARD_IMPORT_FAILED_DETAIL = "Could not import the memory card" + + +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. + """ + try: + _, content = _broker_get_binary( + container, + _memory_card_route(container), + max_bytes=MEMORY_CARD_MAX_BYTES, + timeout=timeout, + ) + return content + except urllib.error.HTTPError as exc: + try: + 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 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 + finally: + exc.close() + 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.""" + return _broker_put_binary( + container, + _memory_card_route(container), + content, + "memory-card PUT", + content_type="application/zip", + timeout=timeout, + ) + + +class MemoryCardSummary(TypedDict): + file_count: int + total_bytes: int + game_codes: list[str] + + +def _summarize_memory_card(content: bytes) -> MemoryCardSummary: + """Describe a fetched card for the import dialog. GameCube names its saves + `--.gci`, so the gamecode is a filename field, + not something that needs the card format parsed. + + Never raises: a card we cannot parse still has to be offered to the user. + """ + codes: set[str] = set() + file_count = 0 + total = 0 + try: + with zipfile.ZipFile(io.BytesIO(content)) as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + file_count += 1 + total += info.file_size + parts = PurePosixPath(info.filename).name.split("-") + if len(parts) >= 3 and info.filename.lower().endswith(".gci"): + codes.add(parts[1]) + except Exception: + # Unparseable: nothing describable, so report nothing rather than a + # file-less card that still claims a size. + return {"file_count": 0, "total_bytes": 0, "game_codes": []} + return { + "file_count": file_count, + "total_bytes": total, + "game_codes": sorted(codes), + } + + +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: they are browsable and downloadable through the memory + card UI, but never live-mounted onto another user's session, since a mounted + card is written back as a new version on release and that version belongs to + the owner. 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 _adoption_already_stored(card_id: int, content: bytes | None) -> bool: + """Is the container's card already this card's latest version? + + Dedup can refuse a version because a previous claim stored it and then died + before recording the adoption decision, leaving the prompt to fire again on + unchanged content. That retry is idempotent: hydrate would push back the + very bytes sitting on the container, so the adoption stands and only the + decision row is missing. A match against an older version means hydrate + would push something else over the card, which is the case that must abort. + """ + if not content: + return False + content_hash = content_hash_of_bytes(content) + if not content_hash: + return False + latest = db_memory_card_handler.get_latest_version(card_id) + return latest is not None and latest.content_hash == content_hash + + +async def _discard_blank_card(card_id: int) -> None: + """Drop a card this claim created, with any archive it picked up on the way: + an abort after adoption is a card that already holds a version.""" + for path in db_memory_card_handler.delete_card(card_id): + try: + await fs_asset_handler.remove_file(file_path=path) + except OSError as exc: + log.warning("could not remove card archive %s, %s", path, exc) + + +async def _evacuate_memory_card( + user_id: int, card_id: int, 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, 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 + try: + return await _evacuate_memory_card(user_id, card_id, 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] +) -> bool: + """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. + + Returns False when the session stopped looking abandoned before any of that + started, meaning the owner came back or another request got here first. + """ + # Claim the teardown before touching the broker. The work below runs for + # seconds, and the staleness check that led here is older still, so without + # the marker a heartbeat landing in that window would refresh a claim whose + # container is already being wiped. Re-checking staleness under the same + # watch is what makes the decision current rather than the caller's, and the + # marker's own token is what keeps a second claim from running all of this + # a second time over the same container. + token = secrets.token_hex(8) + try: + marked = await _replace_session_if( + session_key, + lambda current: _session_is_stale(current) + and _same_claim(current, session), + _drain_marker(token), + _DRAIN_MARKER_TTL, + ) + except _SessionContended: + # Somebody is writing to this key, so it is not the derelict session + # this path exists to clean up. + return False + if not marked: + return False + + keepalive = asyncio.ensure_future(_hold_drain_marker(session_key, token)) + try: + state_slot = 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) + # That tab may still be showing the stream, so leave the same note an + # admin force-release does rather than letting the picture simply stop. + await _record_termination( + session, session_key, ended_by=None, reason="abandoned" + ) + + rom_id = session.get("rom_id") + user_id = session.get("user_id") + # The stop wrote an exit state the broker keeps only until the next + # activate, and dropping the claim below is what lets that activate + # happen, so it is collected here rather than left to be overwritten. + if ( + state_slot is not None + and isinstance(rom_id, int) + and isinstance(user_id, int) + ): + await _pull_state_to_library( + user_id, + rom_id, + container, + state_slot, + disc_file_id=_session_disc_id(session), + ) + except Exception: + log.exception("abandoned session teardown failed, key=%s", session_key) + finally: + # A step raising above would otherwise leave the container unclaimable + # until the marker expires: draining blocks the claim, the takeover + # skips it, and no release path owns it. Only this teardown's own marker + # goes, never a claim that replaced it in the meantime. + keepalive.cancel() + await _drop_drain_marker(session_key, token) + return True + + +# How long a claim waits for stale sessions to be torn down before it gives up. +# The teardown stops a broker, evacuates and wipes a card and pulls the exit +# state, each with its own timeout and retries, so on a sick container it can +# run for minutes. A claim is an interactive request and must not, so this is +# the budget for the whole sweep rather than for each container in it. +_ABANDONED_TEARDOWN_WAIT = 30.0 + + +async def _await_teardown_within_budget( + container: dict[str, Any], + session_key: str, + session: dict[str, Any], + budget: float, +) -> bool: + """Tear down an abandoned session, but only wait `budget` seconds for it. + + A teardown that overruns keeps running, shielded, and frees the container + when it lands. It is never cancelled part-way: the card evacuation and the + state pull are the abandoned player's only copies, and the drain marker + this leaves behind blocks a claim until the teardown drops it. + """ + task = _spawn_sync_task( + _teardown_abandoned_session(container, session_key, session) + ) + try: + return await asyncio.wait_for(asyncio.shield(task), timeout=budget) + except asyncio.TimeoutError: + log.warning( + "stale session teardown is taking too long, leaving it to finish, key=%s", + session_key, + ) + return False + + +# 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, and the +# match is also where the capture stamp is inserted, so an emulator missing +# from here gets no history either. +_STATE_SLOT_PATTERNS = { + "pcsx2": re.compile(r"\.(\d{1,2})\.p2s$"), + "dolphin": re.compile(r"\.s(\d{2})$"), + "xemu": re.compile(r"\.x(\d{2})$"), + # RetroArch leaves the number off its default slot: "GAME.state" is slot 0 + # and "GAME.state3" is slot 3. + "retroarch": re.compile(r"\.state(\d{0,2})$"), +} + + +# Lowest slot each emulator's broker will actually address. Everything but +# RetroArch counts from 1, so a "0" in one of their names is a filename that +# happens to look like a state, not a slot they could load. +_MIN_STATE_SLOT = {"retroarch": 0} + + +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) or 0) + return slot if slot >= _MIN_STATE_SLOT.get(emulator, 1) else None + + +# Every capture is kept, so the library needs one file per save, not one per +# slot. The stamp goes immediately before the slot token so the patterns above +# still match at the end of the name: states written before this keep +# resolving, and the container-side name is recovered by dropping the stamp. +_STATE_STAMP_FORMAT = "%Y%m%d-%H%M%S%f" +_STATE_STAMP_PATTERN = re.compile(r"\.\d{8}-\d{12}(?=\.)") + + +def _stamped_state_filename(emulator: str, filename: str, when: datetime) -> str: + """Return ``filename`` with a capture stamp inserted before its slot token. + + An emulator with no known slot convention keeps the original name, since + there is nowhere unambiguous to put the stamp. That emulator gets no + history: each capture lands on the same name and updates its row in place, + the pre-history behavior. No streaming emulator is in that position now. + """ + pattern = _STATE_SLOT_PATTERNS.get(emulator) + if pattern is None: + return filename + match = pattern.search(filename) + if match is None: + return filename + stamp = when.strftime(_STATE_STAMP_FORMAT) + return f"{filename[: match.start()]}.{stamp}{filename[match.start() :]}" + + +def _container_state_filename(filename: str) -> str: + """Strip any capture stamp, giving the name the emulator expects on disk.""" + return _STATE_STAMP_PATTERN.sub("", filename, count=1) + + +def _resolve_resume_state( + user_id: int, rom: Rom, container: dict[str, Any], state_id: int +) -> tuple[State, 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 ──────────────────────────────────────────────────────────────────── +# +# Reads gate on ROMS_READ; anything that creates, controls or releases a session +# gates on ROMS_USER_WRITE, matching the play-session routes. ROMS_USER_WRITE is +# always-on for authenticated users, so this costs no real user anything, but it +# is absent from READ_SCOPES -- which is all KIOSK_MODE hands an anonymous +# visitor. Without it, kiosk visitors (who all share one synthetic user, so +# session ownership cannot separate them) could claim sessions and overwrite +# each other's save states. + + +@protected_route(router.get, "/config", [Scope.ROMS_READ]) +async def get_config(request: Request) -> JSONResponse: + """Return streaming configuration to the frontend""" + cfg = _get_streaming_config() + + # Keyed by platform: a pool is a backend concern, the frontend picks a + # platform and the claim decides which container serves it. + safe_containers: dict[str, dict[str, Any]] = {} + for c in cfg.get("containers", []): + if not c.get("platform") or not c.get("host"): + log.warning("container missing platform/host, skipping: %s", c) + continue + + platform = c.get("platform", "") + if platform in safe_containers: + continue + # The entry carries the platform's label and capabilities, so a + # platform hidden from this caller must not be listed here either. + if not _platform_is_visible(request, platform): + continue + safe_containers[platform] = { + "platform": platform, + "host": c.get("host"), + "label": c.get("label") or platform.upper(), + # Ship slot capabilities so the frontend selector reads them + # instead of keeping its own hardcoded per-platform copy. + "capabilities": _container_capabilities(c), + # 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), + } + + return JSONResponse( + { + "enabled": cfg.get("enabled", False), + "containers": list(safe_containers.values()), + } + ) + + +@protected_route(router.post, "/sessions", [Scope.ROMS_USER_WRITE]) +async def claim_session( + request: Request, req: Annotated[ClaimSessionRequest, Body()] +) -> JSONResponse: + """ + Claim a streaming session and tell the broker to load the ROM. + + The ROM's filesystem path is derived server-side from its database row - + the client only supplies a ROM id, never a path. + Returns 404 if the ROM doesn't exist or no container serves its platform. + Returns 409 if every container serving the platform is occupied. + Returns 428 if the container's pre-existing memory card needs a decision. + Returns 502/503 if the broker rejects the launch or is unreachable. + """ + rom = db_rom_handler.get_rom(req.rom_id) + if rom is None: + raise HTTPException(status_code=404, detail="ROM not found") + + # A hidden ROM/platform must not be launchable via its id: enforce the same + # visibility policy as the ROM detail/content endpoints before any broker + # launch. Raises a 404 that masks the hidden ROM's existence. + assert_rom_visible(request, rom, not_found_detail="ROM not found") + + platform = rom.platform_slug + candidates = _containers_for_platform(platform) + if not candidates: + raise HTTPException( + status_code=404, + detail=f"No streaming container configured for platform '{platform}'", + ) + + # Pool members are interchangeable on emulator and card sync (enforced by + # _containers_for_platform), so the pre-claim validation below holds for + # whichever one the walk ends up winning. + reference = candidates[0] + + # Validate the resume pick before claiming so a bad state_id cannot + # leave a 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, reference, 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(reference): + memory_card = _resolve_memory_card( + request.user.id, + _emulator_for_container(reference), + req.memory_card_id, + ) + + rom_name = rom.name or rom.fs_name_no_ext + + now = datetime.now(timezone.utc).isoformat() + session = { + # Unique per claim, and safe to put in a filename. The webstation + # broker names its exit archive after it, so the pull can tell this + # session's saves from one an earlier pull failed to collect. + "broker_session_id": secrets.token_hex(8), + "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, and so a container + # serving several platforms resolves back to the right config entry. + "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, + # Read by GET /sessions/joinable and enforced by POST + # /sessions/{platform}/join, so it has to survive on the record rather + # than living only on the broker. + "multiplayer": req.multiplayer, + # 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, + } + + async def _try_claim(candidate: dict[str, Any]) -> bool: + # 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 and heartbeats refresh it. + return bool( + await async_cache.set( + _session_redis_key(_container_key(candidate)), + json.dumps(session), + nx=True, + ex=SESSION_TTL_SECONDS, + ) + ) + + # Config order, first free wins. + container: dict[str, Any] | None = None + for candidate in candidates: + if await _try_claim(candidate): + container = candidate + break + + if container is None: + # Every container is held, but a holder may be long gone: a closed tab + # or a crashed browser never sends a release, and the TTL alone would + # hold it for hours. A stale heartbeat means abandoned, so tear that + # session down (evacuating its card and crediting its playtime) and + # retry. Evicting anyone is deferred until here so a pool never + # displaces a stale session while another container sits idle. A drain + # marker is never taken over: an exit still has the broker killing the + # emulator or the state coming out of it, and the marker only outlives + # that work by _DRAIN_MARKER_TTL, since the backend doing it is what + # refreshes it. + deadline = time.monotonic() + _ABANDONED_TEARDOWN_WAIT + for candidate in candidates: + existing = await _get_session(_container_key(candidate)) + if ( + existing is None + or existing.get("draining") + or not _session_is_stale(existing) + ): + continue + log.warning( + "taking over stale session, platform=%s user_id=%s", + platform, + existing.get("user_id"), + ) + if not await _await_teardown_within_budget( + candidate, + _container_key(candidate), + existing, + max(0.0, deadline - time.monotonic()), + ): + continue + if await _try_claim(candidate): + container = candidate + break - # 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. - 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 {} + if container is None: + # Report the head of the pool as the holder: with one container that is + # the only holder, and with several the player just needs to know the + # platform is busy. + existing = await _get_session(_container_key(candidates[0])) or {} + # A drain marker belongs to nobody: the previous session is over and its + # exit state is still coming out of the container, so rom_name and + # claimed_at are both blank and "in use" would name no one. The player + # who just pressed save-and-exit sees this, and needs to be told to wait + # rather than that somebody else took their platform. + draining = bool(existing.get("draining")) + if draining: + message = "The previous session is still saving, try again shortly" + elif len(candidates) == 1: + message = "Session in use" + else: + message = f"All {len(candidates)} containers for this platform are in use" raise HTTPException( status_code=409, detail={ - "message": "Session in use", - "rom_name": existing.get("rom_name"), + "message": message, + "draining": draining, + "rom_name": _visible_rom_name(request, existing), "claimed_at": existing.get("claimed_at"), }, ) + session_key = _container_key(container) + + # 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. 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}" + + # A container that still holds someone's pre-existing card must not be + # wiped on a hunch. Probe once, then record the answer so this never + # interrupts a claim again. Probed only by the claim winner, so a user who + # loses the race gets the 409 rather than a prompt describing the card of + # the player currently on the container. Every exit from here that is not a + # started session releases the claim, so an abandoned dialog leaves no trace. + adoption_content: bytes | None = None + adoption_undecided = False + if ( + _memory_card_sync_enabled(container) + and db_container_adoption_handler.get_adoption(_container_key(container)) + is None + ): + adoption_undecided = True + try: + adoption_content = await asyncio.to_thread(_fetch_memory_card, container) + except _MemoryCardUnavailable as exc: + # Unreadable is not empty, so the wipe needs the user's consent. + # Only "discard" may override it: a card that was never captured + # cannot be adopted, and pretending otherwise destroys it. + log.warning("could not read the container memory card, %s", exc) + if req.card_import != "discard": + await async_cache.delete(_session_redis_key(session_key)) + if req.card_import is None: + raise HTTPException( + status_code=428, + detail={ + "code": "memory_card_import_required", + "outcome": "unreadable", + "reason": _CARD_UNREADABLE_REASON, + }, + ) from exc + raise HTTPException( + status_code=502, detail=_CARD_IMPORT_FAILED_DETAIL + ) from exc + adoption_content = None + if adoption_content is None and req.card_import == "adopt": + # The slot is empty now, so the import the user asked for cannot + # happen. Abort without recording so the prompt fires again. + log.warning("adopt requested but the container slot is empty") + await async_cache.delete(_session_redis_key(session_key)) + raise HTTPException(status_code=502, detail=_CARD_IMPORT_FAILED_DETAIL) + if adoption_content is not None and req.card_import is None: + await async_cache.delete(_session_redis_key(session_key)) + raise HTTPException( + status_code=428, + detail={ + "code": "memory_card_import_required", + "outcome": "found", + "summary": _summarize_memory_card(adoption_content), + }, + ) + if req.card_import == "discard": + adoption_content = None + + # The player is back in a session, so any note about their previous one + # being force-released has served its purpose. + await _clear_termination(session_key, request.user.id) + + # 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 + # Through _mutate_session, not a plain SET: a force-release landing in + # this window deletes the key, and writing it back whole would resurrect + # a claim on a container whose emulator is already being stopped. + try: + await _mutate_session(session_key, {"memory_card_id": memory_card.id}) + except _SessionContended: + log.warning( + "could not record card %s on session %s", memory_card.id, session_key + ) + + # Establish version 1 from the container's own card before hydrate runs, so + # the hydrate that follows pushes the adopted card back rather than a blank. + # An absent or discarded card is recorded too, so the prompt fires once and + # a card that shows up later is treated as the container's, not the user's. + if _memory_card_sync_enabled(container) and adoption_undecided: + # Resolved above or freshly created as a blank, never None on this path. + assert memory_card is not None + adopted = req.card_import == "adopt" and adoption_content is not None + if adopted: + stored: MemoryCardVersion | None = None + try: + stored = await store_memory_card_version( + request.user, + memory_card, + adoption_content, # type: ignore[arg-type] + ) + except Exception as exc: + # Hydrate would wipe the container next, so a failed import must + # abort rather than destroy the card it was asked to keep. + log.exception("could not adopt the container memory card") + await async_cache.delete(_session_redis_key(session_key)) + if created_blank_card_id is not None: + await _discard_blank_card(created_blank_card_id) + raise HTTPException( + status_code=502, detail=_CARD_IMPORT_FAILED_DETAIL + ) from exc + if not stored and not _adoption_already_stored( + memory_card.id, adoption_content + ): + # Content-hash dedup matched an older version of this card, so + # no version was created and hydrate would push whichever + # version is latest over the container card. Abort instead. + log.error( + "adopted memory card matched an existing version of card %d", + memory_card.id, + ) + await async_cache.delete(_session_redis_key(session_key)) + if created_blank_card_id is not None: + await _discard_blank_card(created_blank_card_id) + raise HTTPException(status_code=502, detail=_CARD_IMPORT_FAILED_DETAIL) + db_container_adoption_handler.add_adoption( + container_key=_container_key(container), + outcome="adopt" if adopted else "discard", + user_id=request.user.id, + ) + + # 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. + # The webstation broker only takes a state while a session is up, and its + # session starts at activate, so that push has to happen after launch. + resume_pushed = False + resume_after_launch = _is_webstation(container) and resume_state is not None + if resume_state is not None and not resume_after_launch: + resume_pushed = await _push_resume_state(container, resume_state) + + # Prepare in-game saves before launch - games read them at boot, so unlike + # states this cannot be deferred to a background task. + archive_path: str | None = None + 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: + await _discard_blank_card(created_blank_card_id) + raise HTTPException( + status_code=502, detail="Could not prepare the memory card" + ) + if _is_webstation(container): + # Restore runs inside activate on this protocol, so hydration only gets + # the bytes onto the container and names the path activate restores. + # Still runs under whole-card sync: the archive carries the state the + # last session ended on, which the card does not. + # Best-effort: a failed upload just means the container keeps its own. + try: + archive_path = await _hydrate_saves_to_webstation( + request.user.id, rom.id, container + ) + except Exception: + log.exception("save hydration failed, continuing launch") + # With no pick to push, the restored archive is the resume: it carries + # the autosave slot's state file alongside the in-game saves, so + # loading that slot replays where the last session left off. + caps = platform_capabilities(platform) + if ( + not resume_after_launch + and archive_path is not None + and caps["has_autosave"] + ): + resume_slot = caps["autosave_slot"] + resume_pushed = True + elif memory_card is None: + # 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) + if _is_webstation(container): + launch_result = await asyncio.to_thread( + _webstation_activate, + container, + session_id=str(session["broker_session_id"]), + user=request.user, + emulator=_emulator_for_container(container), + rom={ + "id": rom.id, + "name": rom_name, + "platform": platform, + "path": rom_path, + }, + archive_path=archive_path, + resume_slot=( + resume_slot if resume_pushed or resume_after_launch else None + ), + memory_card_synced=memory_card is not None, + multiplayer=req.multiplayer, + ) + else: + launch_result = 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: + await _discard_blank_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) + + # The webstation broker's deferred load waits for its emulator to report + # the game running, and holds off further until the state file is there, so + # this push lands ahead of it even though it runs after activate. + if resume_after_launch and resume_state is not None: + resume_pushed = await _push_resume_state(container, resume_state) + + # Hydrate the container with the user's newest stored state in the + # background, the stream should not wait on file transfers. + _spawn_sync_task( + _hydrate_states_to_broker( + request.user.id, + rom.id, + container, + resume_pushed=resume_pushed, + ) + ) + + # A resumed state remembers which disc it was captured on. The launch + # always starts on the playlist's first disc, so put the right one back. + resume_disc_id = ( + resume_state.disc_file_id if resume_pushed and resume_state else None + ) + if isinstance(resume_disc_id, int): + _spawn_sync_task( + _restore_session_disc( + rom.id, + container, + session_key, + file_id=resume_disc_id, + broker_session_id=session["broker_session_id"], + ) + ) + + host = container.get("host", "") + if _is_webstation(container): + # Activate answers with the room URL carrying the claiming user's + # token, relative to the container root. An absolute path replaces + # whatever path the configured host carries. + room_url = ( + str(launch_result.get("url", "")) if isinstance(launch_result, dict) else "" + ) + if room_url: + host = urljoin(host, room_url) + else: + # The broker mints a stream token bound to this session and returns it + # in the launch body. Append it so the iframe URL carries it, the broker + # swaps it for a cookie on first load. No token means the gate is not + # deployed on that container, so leave host untouched. + stream_token = ( + launch_result.get("stream_token", "") + if isinstance(launch_result, dict) + else "" + ) + if stream_token: + sep = "&" if "?" in host else "?" + host = f"{host}{sep}stream_token={stream_token}" return JSONResponse( { - "platform": rom.platform_slug, - "host": container.get("host", ""), - "label": container.get("label", rom.platform_slug.upper()), + "platform": platform, + "host": host, + "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, } ) @@ -596,15 +3852,74 @@ 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) + container, session_key, session = await _resolve_owned_session(platform, request) + if req.slot: + _assert_valid_slot(platform, req.slot) + + # 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 + ) - saved = await asyncio.to_thread( - _save_and_exit_broker, container, slot=req.slot, wait=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) + + 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") + pull_state = ( + _pull_state_to_library( + request.user.id, + rom_id, + container, + effective_slot, + disc_file_id=_session_disc_id(session), + ) + if isinstance(rom_id, int) and (saved or not effective_wait) + else None ) - if req.wait: - # Broker confirmed the save+kill is done, the key can go now. - await async_cache.delete(_session_redis_key(session_key)) + released = True + if pull_state is not None: + # The container holds until the state is in the library. The broker keeps + # the exited session's state only until the next activate, so dropping + # the key first is what lets a new claim overwrite it. + try: + token = await _claim_drain_marker(session_key, session) + except _SessionContended: + _spawn_sync_task( + _release_claim_after_state_pull(session_key, pull_state, session) + ) + else: + if token is None: + # The key stopped being this claim while the save+kill blocked, + # so the pull runs without holding anything: whoever owns the + # container now owns the key too. + pull_state.close() + log.warning( + "skipping the exit state pull, session %s was taken over", + session_key, + ) + else: + _spawn_sync_task( + _release_after_state_pull(session_key, pull_state, token) + ) + elif effective_wait: + # Broker confirmed the save+kill is done and there is no state coming + # back, the key can go now. + released = await _release_own_session(session_key, session) else: # Broker is still killing the emulator in the background. Drop the # key to a short drain TTL instead of deleting it outright: a @@ -613,13 +3928,150 @@ async def save_and_exit_session( # save). The marker is JSON so _get_session leaves it in place # (a bare string would parse as corrupt and be deleted, ending # the drain early). The key expires on its own once the window passes. - await async_cache.set( - _session_redis_key(session_key), - json.dumps({"draining": True}), - ex=SESSION_DRAIN_SECONDS, + try: + await _claim_drain_marker(session_key, session, SESSION_DRAIN_SECONDS) + except _SessionContended: + released = False + + # 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, + _broker_session_id(session), + ) ) + + if not released: + log.error("save-and-exit could not give up session %s", session_key) log.info("save-and-exit, platform=%s saved=%s", platform, saved) - return JSONResponse({"status": "ok", "saved": saved, "platform": platform}) + # `released` false means the container is still on this claim, so the client + # is still the one holding it and has to try again. + return JSONResponse( + {"status": "ok", "saved": saved, "platform": platform, "released": released} + ) + + +@protected_route(router.post, "/sessions/{platform}/heartbeat", [Scope.ROMS_USER_WRITE]) +async def heartbeat_session(request: Request, platform: str) -> JSONResponse: + """Refresh the session's liveness stamp and report whether it still exists. + + 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. + + Reports `ended` rather than raising 404 when the caller no longer holds the + session, so a force-released player learns why on the poll they are already + making rather than watching a dead stream. + """ + candidates = _containers_for_platform(platform) + if not candidates: + raise HTTPException( + status_code=404, + detail=f"No streaming container configured for platform '{platform}'", + ) + found = await _find_session_for_user(candidates, request.user.id) + if found is None: + return JSONResponse(await _session_status(platform, request)) + _, session_key, _ = found + + # Merging rather than writing the copy read above keeps a swap that landed + # in between; refusing a draining session keeps a heartbeat from making a + # container that is already being torn down look live. Either returns None, + # meaning the claim is gone and reporting "active" would leave the client + # beating a session it no longer holds. + try: + refreshed = await _mutate_session( + session_key, + {"last_seen": datetime.now(timezone.utc).isoformat()}, + require=lambda s: not s.get("draining"), + ) + except _SessionContended: + # A key too busy to write is a key that exists, so the session is live + # and the missed stamp is covered by the next beat. + log.warning("heartbeat could not stamp contended session %s", session_key) + return JSONResponse({"status": "active", "platform": platform}) + if refreshed is None: + return JSONResponse(await _session_status(platform, request)) + return JSONResponse({"status": "active", "platform": platform}) + + +@protected_route(router.get, "/sessions/{platform}/status", [Scope.ROMS_READ]) +async def session_status(request: Request, platform: str) -> JSONResponse: + """Does the caller still hold this platform's session? + + Unlike the heartbeat this has no side effects, so a client can call it on + mount or after a reconnect without extending a claim it may not own. + """ + return JSONResponse(await _session_status(platform, request)) + + +@protected_route(router.post, "/sessions/{platform}/join", [Scope.ROMS_READ]) +async def join_session( + request: Request, + platform: str, + container: str | None = Query(default=None), +) -> JSONResponse: + """Join a multiplayer session someone else is hosting. + + The caller gets a room URL and nothing else. Note the scope: ROMS_READ, + not the ROMS_USER_WRITE every control route below demands. Those all go + through _assert_session_owner, so a joiner cannot change the volume, write + states, or release the container. + """ + if container is not None: + candidate, _, session = await _resolve_named_container(platform, container) + found = (candidate, session) if session is not None else None + else: + found = None + for candidate in _containers_for_platform(platform): + session = await _get_session(_container_key(candidate)) + if session is None or session.get("draining"): + continue + if session.get("multiplayer"): + found = (candidate, session) + break + + if found is None: + raise HTTPException( + status_code=404, detail=f"No active session on platform '{platform}'" + ) + candidate, session = found + + if not session.get("multiplayer"): + raise HTTPException( + status_code=403, detail="That session is not open for joining" + ) + + # Joining streams someone else's ROM, so it needs the same visibility + # policy the claim route enforces. Masked as the not-found above so a + # hidden ROM's existence stays hidden. + _assert_session_rom_visible( + request, session, not_found_detail=f"No active session on platform '{platform}'" + ) + + if not _is_webstation(candidate): + raise HTTPException( + status_code=409, detail="That container does not support joining" + ) + + joined = await asyncio.to_thread(_webstation_join, candidate, request.user) + room_url = str(joined.get("url", "")) if joined else "" + if not room_url: + raise HTTPException(status_code=502, detail="The session refused the join") + + return JSONResponse( + { + "platform": platform, + "host": urljoin(candidate.get("host", ""), room_url), + "label": candidate.get("label", platform.upper()), + "rom_id": session.get("rom_id"), + "rom_name": session.get("rom_name"), + } + ) @protected_route(router.post, "/sessions/{platform}/volume", [Scope.ROMS_USER_WRITE]) @@ -658,18 +4110,80 @@ async def set_mute( 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) - _assert_valid_slot(platform, req.slot, allow_autosave=False) + """Save game state to a slot without stopping the emulator. + + The autosave slot is a valid target: the library keeps every capture, so + the player writes through one slot rather than picking one. + """ + container, session_key, session = await _resolve_owned_session(platform, request) + _assert_valid_slot(platform, req.slot) ok = await asyncio.to_thread(_save_state_broker, container, req.slot) if not ok: 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, + disc_file_id=_session_disc_id(session), + ) + ) + return JSONResponse({"status": "saving", "slot": req.slot, "platform": platform}) +async def _read_capped_body(request: Request, max_bytes: int) -> bytes | None: + """The request body, or None once it goes past `max_bytes`. + + `Request.body()` buffers everything the client sends before any check can + look at the size, so the cap is applied as the chunks arrive instead. + """ + declared = request.headers.get("content-length") + if declared is not None and declared.isdigit() and int(declared) > max_bytes: + return None + + chunks: list[bytes] = [] + size = 0 + async for chunk in request.stream(): + size += len(chunk) + if size > max_bytes: + return None + chunks.append(chunk) + return b"".join(chunks) + + +@protected_route( + router.post, "/sessions/{platform}/state-frame", [Scope.ROMS_USER_WRITE] +) +async def put_state_frame(request: Request, platform: str) -> JSONResponse: + """Stash a frame the browser grabbed off the stream canvas, for the state + save that follows it to pick up as its thumbnail.""" + _, session_key, session = await _resolve_owned_session(platform, request) + + image = await _read_capped_body(request, _STATE_SCREENSHOT_MAX_BYTES) + if image is None: + raise HTTPException(status_code=413, detail="Frame too large") + if not image.startswith(_PNG_MAGIC): + raise HTTPException(status_code=400, detail="Frame must be a PNG") + + rom_id = session.get("rom_id") + if not isinstance(rom_id, int): + raise HTTPException(status_code=409, detail="Session has no rom") + + await _stash_state_frame(request.user.id, rom_id, image) + await _refresh_session(session_key) + return JSONResponse({"status": "ok", "platform": platform}) + + @protected_route( router.post, "/sessions/{platform}/load-state", [Scope.ROMS_USER_WRITE] ) @@ -678,7 +4192,7 @@ async def load_state( ) -> JSONResponse: """Load game state from a manual slot or the platform's autosave slot.""" container, session_key, _ = await _resolve_owned_session(platform, request) - _assert_valid_slot(platform, req.slot, allow_autosave=True) + _assert_valid_slot(platform, req.slot) ok = await asyncio.to_thread(_load_state_broker, container, req.slot) if not ok: @@ -690,36 +4204,447 @@ async def load_state( ) +@protected_route(router.post, "/sessions/{platform}/swap-disc", [Scope.ROMS_USER_WRITE]) +async def swap_disc( + request: Request, platform: str, req: Annotated[SwapDiscRequest, Body()] +) -> JSONResponse: + """Change the mounted disc without restarting the emulator.""" + container, session_key, session = await _resolve_owned_session(platform, request) + # Container-scoped, not platform-scoped: only the webstation broker has a + # tray route, so a legacy container serving this platform gets the same + # refusal the frontend was told to expect rather than a 502 from the broker. + if not _container_capabilities(container)["supports_disc_swap"]: + raise HTTPException( + status_code=400, detail=f"Platform '{platform}' cannot swap discs" + ) + + rom_id = session.get("rom_id") + if not isinstance(rom_id, int): + raise HTTPException(status_code=409, detail="Session has no rom") + rom_file = db_rom_handler.get_rom_file_by_id(req.file_id) + if rom_file is None or rom_file.rom_id != rom_id: + raise HTTPException(status_code=404, detail="File does not belong to this rom") + + # Loaded separately: the file comes back detached, so reaching its rom from + # there is a lazy load with no session behind it. + rom = db_rom_handler.get_rom(rom_id) + if rom is None: + raise HTTPException(status_code=404, detail="Rom not found") + + if req.file_id not in _swappable_disc_file_ids(rom): + raise HTTPException(status_code=400, detail="File is not a swappable disc") + + library_base = (container.get("library_path") or LIBRARY_BASE_PATH).rstrip("/") + disc_path = f"{library_base}/{rom_file.full_path}" + ok = await asyncio.to_thread(_swap_disc_broker, container, disc_path) + if not ok: + raise HTTPException(status_code=502, detail="Broker failed to swap disc") + + # Resets the TTL as part of the same write. + await _set_session_disc(session_key, req.file_id, session.get("broker_session_id")) + return JSONResponse({"status": "ok", "file_id": req.file_id, "platform": platform}) + + @protected_route(router.delete, "/sessions/{platform}", [Scope.ROMS_USER_WRITE]) -async def release_session(request: Request, platform: str) -> JSONResponse: - """Release a session and tell the broker to stop the emulator.""" - container = _container_for_platform(platform) - if container is None: - # Streaming disabled or platform unconfigured, nothing to release. - return JSONResponse({"status": "not_found", "platform": platform}) +async def release_session( + request: Request, + platform: str, + reason: str | None = Query(default=None, max_length=200), + container_key: str | None = Query(default=None, alias="container", max_length=300), + save: bool = Query(default=True), +) -> JSONResponse: + """Release a session and tell the broker to stop the emulator. + + `reason` is only meaningful when an admin ends someone else's session; it + is surfaced to the displaced player. `container` names which container to + release, needed when a pool serves the platform and the admin is ending a + session they do not own; it is the key `GET /streaming/sessions` reports. + + `save=false` is a player leaving deliberately without saving. It defaults + on because the other way in here is a tab closing, where nobody chose + anything and the last minutes of play would otherwise be gone. + """ + if container_key is not None: + container, session_key, session = await _resolve_named_container( + platform, container_key + ) + if session is None: + return JSONResponse({"status": "not_found", "platform": platform}) + _assert_session_owner(session, request) + else: + try: + container, session_key, session = await _resolve_owned_session( + platform, request + ) + except HTTPException as exc: + # Nothing configured or nothing active: releasing is a no-op rather + # than an error, matching a repeated release from the same tab. + if exc.status_code != 404: + raise + return JSONResponse({"status": "not_found", "platform": platform}) + + # Teardown pulls the whole card off the broker and pushes a blank one back, + # several seconds of broker round-trips. The player who quit does not need + # the claim released to get their UI back, so it runs after the response is + # sent. The Redis claim stays held until teardown deletes it, so a re-launch + # or concurrent claim is still blocked throughout, preserving the + # evacuate-before-release invariant. + teardown = BackgroundTask( + _teardown_released_session, + container, + session, + session_key, + platform, + acting_user_id=request.user.id, + acting_username=request.user.username, + reason=reason, + save=save, + ) + log.info("session releasing, platform=%s save=%s", platform, save) + return JSONResponse( + {"status": "released", "platform": platform}, background=teardown + ) + + +async def _teardown_released_session( + container: dict[str, Any], + session: dict[str, Any], + session_key: str, + platform: str, + *, + acting_user_id: int, + acting_username: str, + reason: str | None, + save: bool = True, +) -> None: + """Stop the emulator, evacuate the card, then release the claim. + + Ordering is load-bearing (see the inline notes). Runs detached from the + release request; every step is best-effort so a teardown hiccup cannot wedge + the claim, which the stale-session takeover reclaims if this never finishes. + """ + # Take the claim into a drain marker first. Everything below runs detached + # and blocks on the broker for as long as the emulator takes to die, so a + # takeover landing in that window would otherwise have its emulator stopped + # and its claim deleted along with the session it replaced. + try: + token = await _claim_drain_marker(session_key, session) + except _SessionContended: + # The claim is still ours, it just could not be marked: it is what + # reserves the container below, and the delete at the end is guarded on + # it anyway. + log.error("could not drain released session %s", session_key) + token = None + else: + if token is None: + log.warning("skipping teardown, session %s was taken over", session_key) + return + + keepalive = asyncio.ensure_future( + _hold_drain_marker(session_key, token) + if token is not None + else _hold_session_claim(session_key, session) + ) + try: + # 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 marker, or the + # claim it could not replace, holds the container throughout, so no + # concurrent claim can interleave. + state_slot = await asyncio.to_thread(_stop_broker, container, save) + + # 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 captured the card. + safe_to_wipe = await _evacuate_session_card(session, container) + if safe_to_wipe: + await _wipe_session_card(container) + + # Leave a note when this is a force-release rather than a player closing + # their own game. A different user is the obvious case; a reason covers + # the rest, since only the admin panel sends one and an admin can be + # logged in as the same account that is playing in another tab. + if session.get("user_id") != acting_user_id or reason is not None: + await _record_termination( + session, session_key, ended_by=acting_username, reason=reason + ) + log.info( + "session force-released, platform=%s by=%s user_id=%s reason=%s", + platform, + acting_username, + session.get("user_id"), + reason or "-", + ) + + await _record_play_session(session) + + rom_id = session.get("rom_id") + # Awaited, not spawned: the broker holds the exited session's state only + # until the next activate, and releasing the claim below is what lets + # that activate happen. + if isinstance(rom_id, int) and state_slot is not None: + await _pull_state_to_library( + session["user_id"], + rom_id, + container, + state_slot, + disc_file_id=_session_disc_id(session), + ) + + # Legacy per-file save pull, only for containers not on whole-card sync. + # Fire and forget: it reads files the broker keeps after the emulator + # dies, so it does not gate the claim release above it. + 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, + _broker_session_id(session), + ) + ) + + log.info("session released, platform=%s", platform) + except Exception: + log.exception("session teardown failed, platform=%s", platform) + finally: + # The claim goes even when a step above raised. The API already told the + # caller the session was released, so a key left behind blocks every + # later claim until stale takeover or the TTL expires. A card left + # un-evacuated is recoverable, since the next claim prompts to adopt + # whatever is still on the container; a phantom claim is not. + keepalive.cancel() + if token is not None: + await _drop_drain_marker(session_key, token) + else: + await _release_own_session(session_key, session) + + +def _container_by_key(container_key: str) -> tuple[dict[str, Any], str]: + """A configured container named by its key, plus the platform to file its + sessions under. + + The session routes are platform-keyed, so a container serving several gets + the first, which `_container_for_session` resolves back to this entry. + Raises 404 when the key names no container. + """ + entries = _containers_by_key().get(container_key) if container_key else None + if not entries or not _get_streaming_config().get("enabled", False): + raise HTTPException( + status_code=404, detail=f"No streaming container '{container_key}'" + ) + return entries[0], str(entries[0].get("platform", "")) + + +@protected_route(router.get, "/containers", [Scope.ROMS_READ]) +async def list_containers(request: Request) -> JSONResponse: + """Admin view, one row per configured container with whatever it is running. + + One row per container rather than per platform: a container serves many + platforms but hosts one session, so the platform rows the frontend gets + from `/streaming/config` are the wrong unit for operating the fleet. + """ + if request.user.role != Role.ADMIN: + raise HTTPException(status_code=403, detail="Forbidden") + + cfg = _get_streaming_config() + containers: list[dict[str, Any]] = [] + for container_key, entries in _containers_by_key().items(): + first = entries[0] + session = await _get_session(container_key) if container_key else None + # A drain marker is a container on its way to idle, not an occupant: + # reporting it would put an all-null session row in the fleet view and + # offer a force-release for a teardown that is already running. + if session and session.get("draining"): + session = None + user_id = session.get("user_id") if session else None + user = db_user_handler.get_user(user_id) if isinstance(user_id, int) else None + containers.append( + { + "container": container_key, + "label": _container_label(container_key, entries), + "host": first.get("host"), + "platforms": [e.get("platform") for e in entries if e.get("platform")], + # The desktop emulator lives on the webstation broker only; the + # per-emulator mods have no activate route to ask for it. + "supports_desktop": _is_webstation(first), + # A container whose host has no scheme has an empty key and can + # never be claimed, so surface it rather than listing it as idle. + "configured": bool(container_key), + "session": ( + { + "platform": session.get("platform"), + "rom_id": session.get("rom_id"), + "rom_name": session.get("rom_name"), + "desktop": bool(session.get("desktop")), + "claimed_at": session.get("claimed_at"), + "user_id": user_id, + "username": user.username if user else None, + } + if session + else None + ), + } + ) + return JSONResponse( + {"enabled": cfg.get("enabled", False), "containers": containers} + ) + + +@protected_route(router.post, "/desktop", [Scope.ROMS_USER_WRITE]) +async def claim_desktop_session( + request: Request, req: Annotated[DesktopSessionRequest, Body()] +) -> JSONResponse: + """Admin, open a container's desktop with no game running. + + This is how an operator configures an emulator (BIOS, controllers, paths) + inside the container that will run it. The desktop claims the same key + under the same SET NX as a game, so it blocks players and a running game + blocks it: only one thing can drive the container's display. + + Returns 404 for an unknown container, 409 when it is occupied, 502/503 when + the broker rejects the activation or is unreachable. + """ + if request.user.role != Role.ADMIN: + raise HTTPException(status_code=403, detail="Forbidden") + + container, platform = _container_by_key(req.container) + if not _is_webstation(container): + raise HTTPException( + status_code=400, + detail="This container's broker does not serve a desktop session", + ) session_key = _container_key(container) - session = await _get_session(session_key) - if session is None: - return JSONResponse({"status": "not_found", "platform": platform}) + now = datetime.now(timezone.utc).isoformat() + session = { + "user_id": request.user.id, + "broker_session_id": secrets.token_hex(8), + # No ROM and no card. Teardown reads both and skips the save pull, the + # card evacuation and the playtime credit when they are absent, which + # is what a desktop session wants: nothing of it belongs in a library. + "rom_id": None, + "rom_name": None, + "memory_card_id": None, + "desktop": True, + "platform": platform, + "claimed_at": now, + "last_seen": now, + } + # No stale takeover here, unlike a player's claim: the admin named this + # container, so displacing whoever holds it should be their explicit call + # through release, not a side effect of asking for the desktop. + 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( + status_code=409, + detail={ + "message": "Container in use", + "rom_name": _visible_rom_name(request, existing), + "claimed_at": existing.get("claimed_at"), + }, + ) + + try: + launch_result = await asyncio.to_thread( + _webstation_activate, + container, + session_id=str(session["broker_session_id"]), + user=request.user, + emulator="desktop", + ) + except Exception: + # Activation failed, free the claim so the container isn't wedged. + await async_cache.delete(_session_redis_key(session_key)) + raise + + host = container.get("host", "") + room_url = str(launch_result.get("url", "")) if launch_result else "" + if room_url: + host = urljoin(host, room_url) - _assert_session_owner(session, request) - await async_cache.delete(_session_redis_key(session_key)) + log.info("desktop session claimed, container=%s", session_key) + return JSONResponse( + { + "container": session_key, + "platform": platform, + "host": host, + "label": container.get("label", platform.upper()), + "claimed_at": now, + } + ) + + +@protected_route(router.get, "/sessions/joinable", [Scope.ROMS_READ]) +async def list_joinable_sessions( + request: Request, rom_id: int | None = Query(default=None, ge=1) +) -> JSONResponse: + """Active multiplayer sessions any user may ask to join. - # Best-effort stop, don't block the user on broker errors. - await asyncio.to_thread(_stop_broker, container) + Deliberately not admin-gated, unlike GET /sessions: it exposes only + sessions whose host opted into multiplayer at launch, and only the fields + a Join button needs. Sessions the caller is already hosting are left out. + """ + grouped = _containers_by_key() + + 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: + continue + try: + s = json.loads(raw) + except (TypeError, json.JSONDecodeError): + continue + if not s.get("multiplayer") or s.get("draining"): + continue + if s.get("user_id") == request.user.id: + continue + if rom_id is not None and s.get("rom_id") != rom_id: + continue + if not _session_rom_is_visible(request, s): + continue - log.info("session released, platform=%s", platform) - return JSONResponse({"status": "released", "platform": platform}) + # 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 = ( + _container_for_session(grouped, container_key, s.get("platform")) or {} + ) + user_id = s.get("user_id") + host = 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"), + "host_username": host.username if host else None, + } + ) + return JSONResponse({"sessions": sessions}) @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] = {} + grouped = _containers_by_key() + + 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: @@ -728,40 +4653,117 @@ async def list_sessions(request: Request) -> JSONResponse: s = json.loads(raw) except (TypeError, json.JSONDecodeError): continue + # A drain marker holds the key while a teardown finishes; it has no + # owner and nothing to release, so it is not an active session. + if s.get("draining"): + 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 = ( + _container_for_session(grouped, container_key, s.get("platform")) or {} + ) + 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"), + "desktop": bool(s.get("desktop")), + "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_USER_WRITE]) -async def force_release_all(request: Request) -> JSONResponse: - """Force-release all active sessions.""" +async def force_release_all( + request: Request, reason: str | None = Query(default=None, max_length=200) +) -> JSONResponse: + """Admin, force-release all active sessions. + + `reason` is surfaced to every displaced player alongside the admin's name. + """ if request.user.role != Role.ADMIN: raise HTTPException(status_code=403, detail="Forbidden") # Map container keys back to configs so each broker can be told to stop - # deleting only the Redis keys would leave the games running. - containers_by_key = { - _container_key(c): c - for c in _get_streaming_config().get("containers", []) - if isinstance(c, dict) - } + grouped = _containers_by_key() + + async def _teardown(key: str | bytes, container_key: str) -> None: + # Read before the teardown so the displaced player can be identified + # even when the container config has since been removed. + session = await _get_session(container_key) + container = _container_for_session( + grouped, container_key, session.get("platform") if session else None + ) + + # 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. + try: + if container is not None: + # Best-effort stop; a broker error must not abort the sweep. + state_slot = await asyncio.to_thread(_stop_broker, container) + 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) + + rom_id = session.get("rom_id") + user_id = session.get("user_id") + # The next claim's activate overwrites the exit state, so a + # displaced player only keeps it if it is collected here. + if ( + state_slot is not None + and isinstance(rom_id, int) + and isinstance(user_id, int) + ): + await _pull_state_to_library( + user_id, + rom_id, + container, + state_slot, + disc_file_id=_session_disc_id(session), + ) + + # Note who ended it before the key goes, so the player's next poll + # can explain the stream vanishing. + if session is not None: + await _record_termination( + session, + container_key, + ended_by=request.user.username, + reason=reason, + ) + finally: + # The sweep answered "released", so the key goes even when a step + # above raised; a phantom claim would block every later claim. + 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 1d2f4cfe49..af088a8301 100644 --- a/backend/handler/database/__init__.py +++ b/backend/handler/database/__init__.py @@ -1,8 +1,10 @@ from .client_tokens_handler import DBClientTokensHandler from .collections_handler import DBCollectionsHandler +from .container_adoptions_handler import DBContainerAdoptionsHandler from .device_save_sync_handler import DBDeviceSaveSyncHandler from .devices_handler import DBDevicesHandler from .firmware_handler import DBFirmwareHandler +from .memory_cards_handler import DBMemoryCardsHandler from .music_playlists_handler import DBMusicPlaylistsHandler from .permissions_handler import DBPermissionsHandler from .platforms_handler import DBPlatformsHandler @@ -17,9 +19,11 @@ db_client_token_handler = DBClientTokensHandler() db_collection_handler = DBCollectionsHandler() +db_container_adoption_handler = DBContainerAdoptionsHandler() db_device_handler = DBDevicesHandler() db_device_save_sync_handler = DBDeviceSaveSyncHandler() db_firmware_handler = DBFirmwareHandler() +db_memory_card_handler = DBMemoryCardsHandler() db_music_playlist_handler = DBMusicPlaylistsHandler() db_permission_handler = DBPermissionsHandler() db_platform_handler = DBPlatformsHandler() diff --git a/backend/handler/database/container_adoptions_handler.py b/backend/handler/database/container_adoptions_handler.py new file mode 100644 index 0000000000..4fd25e06d3 --- /dev/null +++ b/backend/handler/database/container_adoptions_handler.py @@ -0,0 +1,43 @@ +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from decorators.database import begin_session +from models.container_adoption import AdoptionOutcome, StreamingContainerAdoption + +from .base_handler import DBBaseHandler + + +class DBContainerAdoptionsHandler(DBBaseHandler): + @begin_session + def get_adoption( + self, + container_key: str, + session: Session = None, # type: ignore + ) -> StreamingContainerAdoption | None: + return session.scalar( + select(StreamingContainerAdoption).filter_by(container_key=container_key) + ) + + @begin_session + def add_adoption( + self, + container_key: str, + outcome: AdoptionOutcome, + user_id: int, + session: Session = None, # type: ignore + ) -> StreamingContainerAdoption | None: + """Returns None when another claim recorded the decision first. The + unique constraint is the arbiter, not the earlier read.""" + adoption = StreamingContainerAdoption( + container_key=container_key, + outcome=outcome, + decided_by_user_id=user_id, + ) + try: + session.add(adoption) + session.flush() + except IntegrityError: + session.rollback() + return None + return adoption diff --git a/backend/handler/database/memory_cards_handler.py b/backend/handler/database/memory_cards_handler.py new file mode 100644 index 0000000000..80cd9c30f8 --- /dev/null +++ b/backend/handler/database/memory_cards_handler.py @@ -0,0 +1,216 @@ +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, for reads that may cross ownership (a public card's + detail or version list) and for lookups by an id already resolved to + the session's own card. Visibility is enforced separately by the + caller; this never scopes by user on its own.""" + 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. Browsing only, since another user's card is + never mounted onto a session (see _resolve_memory_card). Mirrors + db_state_handler.get_rom_shared_states but keyed by emulator.""" + 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 | None: + """Returns None when the row was deleted concurrently.""" + session.execute( + update(MemoryCard) + .where(MemoryCard.id == id) + .values(**data) + .execution_options(synchronize_session="evaluate") + ) + return session.query(MemoryCard).filter_by(id=id).one_or_none() + + @begin_session + def delete_card( + self, + id: int, + session: Session = None, # type: ignore + ) -> list[str]: + """Delete a card and return the paths of the version archives that went + with it. The listing shares the delete's transaction and locks the rows, + so a snapshot written alongside cannot end up deleted in the database and + absent from the caller's removal list.""" + paths = [ + f"{file_path}/{file_name}" + for file_path, file_name in session.execute( + select(MemoryCardVersion.file_path, MemoryCardVersion.file_name) + .filter_by(memory_card_id=id) + .with_for_update() + ).all() + ] + + # Versions cascade via the FK / relationship. + session.execute( + delete(MemoryCard) + .where(MemoryCard.id == id) + .execution_options(synchronize_session="evaluate") + ) + return paths + + # --- 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. + + Ties on id, because created_at only has second resolution: an upload + landing in the same second as an evacuated snapshot would otherwise + hydrate arbitrarily. + """ + return session.scalar( + select(MemoryCardVersion) + .filter_by(memory_card_id=card_id) + .order_by(desc(MemoryCardVersion.created_at), desc(MemoryCardVersion.id)) + .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), desc(MemoryCardVersion.id)) + ).all() + + @begin_session + def set_version_missing( + self, + id: int, + missing: bool, + session: Session = None, # type: ignore + ) -> None: + """Record whether a version's archive is still on disk, so the history + can say a snapshot is gone instead of offering a download that 404s.""" + session.execute( + update(MemoryCardVersion) + .where(MemoryCardVersion.id == id) + .values(missing_from_fs=missing) + .execution_options(synchronize_session="evaluate") + ) + + @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..1c66aba09a 100644 --- a/backend/handler/filesystem/assets_handler.py +++ b/backend/handler/filesystem/assets_handler.py @@ -23,6 +23,26 @@ _MIME_DETECTOR = magic.Magic(mime=True) _MIME_DETECTOR_LOCK = threading.Lock() +# A zip entry's declared size is attacker-controlled, so entries are hashed in +# chunks against this ceiling rather than read whole. Sized well above any real +# memory card or save archive. +MAX_DECOMPRESSED_ENTRY_BYTES = 512 * 1024 * 1024 + + +def hash_zip_entry(zf: zipfile.ZipFile, name: str) -> str: + """md5 of one zip entry, streamed so a compression bomb cannot exhaust memory.""" + hash_obj = hashlib.md5(usedforsecurity=False) + read = 0 + with zf.open(name, "r") as entry: + while chunk := entry.read(8192): + read += len(chunk) + if read > MAX_DECOMPRESSED_ENTRY_BYTES: + raise ValueError( + f"zip entry {name} exceeds the decompressed size limit" + ) + hash_obj.update(chunk) + return hash_obj.hexdigest() + def validate_image_upload(upload: UploadFile, *, label: str = "Image") -> str: """Validate that an uploaded file is one of the safe image types. @@ -142,6 +162,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: @@ -154,8 +183,7 @@ async def _compute_zip_hash(self, zip_path: str) -> str: file_hashes = [] for name in sorted(zf.namelist()): if not name.endswith("/"): - content = zf.read(name) - file_hash = hashlib.md5(content, usedforsecurity=False).hexdigest() + file_hash = hash_zip_entry(zf, name) file_hashes.append(f"{name}:{file_hash}") combined = "\n".join(file_hashes) return hashlib.md5(combined.encode(), usedforsecurity=False).hexdigest() diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 67874ba158..31be08da53 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -50,7 +50,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 @@ -1237,6 +1237,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 bba55b4706..92c5ddd554 100644 --- a/backend/main.py +++ b/backend/main.py @@ -42,6 +42,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.music_playlists import router as music_playlists_router from endpoints.netplay import router as netplay_router @@ -72,6 +73,7 @@ initialize_context, set_context_middleware, ) +from utils.memory_cards import MEMORY_CARD_MAX_BYTES logging.config.dictConfig(LOGGING_CONFIG) @@ -132,6 +134,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: ], ) +# Memory cards are bounded by what a broker will take, which is lower than the +# asset ceiling. Bounding them here too keeps a card the endpoint would refuse +# from being spooled to disk in full first. +app.add_middleware( + UploadSizeLimitMiddleware, + max_size=min(MEMORY_CARD_MAX_BYTES, MAX_ASSET_UPLOAD_SIZE_BYTES), + paths=[re.compile(r"^/api/memory-cards")], +) + if not IS_PYTEST_RUN and not DISABLE_CSRF_PROTECTION: # CSRF protection (except endpoints listed in exempt_urls) app.add_middleware( @@ -194,6 +205,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..0b674e330a 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 @@ -127,6 +128,15 @@ class State(RomAsset): # `is_public` mirrors Screenshot/RomNote — lets other users browse and # download a user's public states (community). Defaults false (private). is_public: Mapped[bool] = mapped_column(default=False) + # The disc mounted when this state was captured, so a resume can put the + # same one back. SET NULL rather than CASCADE: losing the file row must + # not take the player's save with it. + disc_file_id: Mapped[int | None] = mapped_column( + ForeignKey("rom_files.id", ondelete="SET NULL"), + nullable=True, + default=None, + index=True, + ) rom: Mapped[Rom] = relationship(lazy="joined", back_populates="states") user: Mapped[User] = relationship(lazy="joined", back_populates="states") @@ -141,3 +151,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/container_adoption.py b/backend/models/container_adoption.py new file mode 100644 index 0000000000..ce38bca0e0 --- /dev/null +++ b/backend/models/container_adoption.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import Literal + +from sqlalchemy import TIMESTAMP, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import BaseModel + +# The only two decisions: the container's pre-existing card was imported, or +# it was wiped. The single source of truth for both the column and its writers. +AdoptionOutcome = Literal["adopt", "discard"] + + +class StreamingContainerAdoption(BaseModel): + """One row per streaming container, recording the one-time decision about + the card that was already on it when memory_card_sync was enabled. + + Keyed by container rather than by card: a card can be deleted, and if the + marker went with it the next user would be offered a container card that by + then holds someone else's saves. + """ + + __tablename__ = "streaming_container_adoptions" + __table_args__ = {"extend_existing": True} + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + container_key: Mapped[str] = mapped_column(String(length=512), unique=True) + outcome: Mapped[AdoptionOutcome] = mapped_column(String(length=16)) + decided_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + decided_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) diff --git a/backend/models/user.py b/backend/models/user.py index 8dee994cd5..c48f916193 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 @@ -96,6 +96,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/config/test_config_loader.py b/backend/tests/config/test_config_loader.py index 746542a33d..62b27ebeb3 100644 --- a/backend/tests/config/test_config_loader.py +++ b/backend/tests/config/test_config_loader.py @@ -348,3 +348,32 @@ def test_config_update_preserves_streaming_section(tmp_path): "label": "PCSX2", } ] + + +def test_config_update_preserves_nested_container_platforms(tmp_path): + """A container's `platforms` map is the only nested mapping inside the + containers list, so it is the shape a runtime rewrite could flatten.""" + config_file = tmp_path / "config.yml" + config_file.write_text( + "streaming:\n" + " enabled: true\n" + " containers:\n" + " - host: https://192.168.1.51:3001\n" + " broker_host: http://192.168.1.51:8000\n" + " label: WEBSTATION\n" + " platforms:\n" + " ps2: pcsx2\n" + " ngc: dolphin\n" + ) + loader = ConfigManager(str(config_file)) + loader.add_platform_binding("gc", "ngc") + + reloaded = ConfigManager(str(config_file)) + assert reloaded.config.STREAMING_CONTAINERS == [ + { + "host": "https://192.168.1.51:3001", + "broker_host": "http://192.168.1.51:8000", + "label": "WEBSTATION", + "platforms": {"ps2": "pcsx2", "ngc": "dolphin"}, + } + ] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ced230b688..653569a7db 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,8 +23,9 @@ 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.container_adoption import StreamingContainerAdoption from models.device import Device from models.device_save_sync import DeviceSaveSync from models.platform import Platform @@ -95,6 +97,9 @@ 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(StreamingContainerAdoption).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 +270,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..0bd0bb6e20 --- /dev/null +++ b/backend/tests/endpoints/test_memory_cards.py @@ -0,0 +1,875 @@ +import io +import shutil +import zipfile +from unittest import mock + +import pytest +from fastapi import status +from tests._zipfile_shim import reload_zipfile + +from handler.database import db_memory_card_handler +from handler.filesystem import fs_asset_handler +from models.assets import MemoryCard, MemoryCardVersion +from models.platform import Platform +from models.user import User +from utils.memory_cards import content_hash_of_bytes, store_memory_card_version + + +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 + + +@pytest.mark.parametrize( + "emulator", ["../../etc", "pcsx2/../..", "pcsx2\\..", ".hidden", "/abs"] +) +def test_create_memory_card_unsafe_emulator_rejected( + client, access_token: str, emulator: str +): + """The emulator names a folder under the user's card directory, so a value + that walks out of it is refused at creation rather than at the first write.""" + response = client.post( + "/api/memory-cards", + json={"name": "Card", "emulator": emulator}, + 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_version_listing_flags_an_archive_that_is_gone( + mock_validate_path, + client, + access_token: str, + memory_card: MemoryCard, + memory_card_version: MemoryCardVersion, + tmp_path, +): + """The history is the only place a user sees a snapshot is unrecoverable + before clicking download, so the flag is brought back in line here.""" + mock_validate_path.return_value = tmp_path / "not-there.zip" + + response = client.get( + f"/api/memory-cards/{memory_card.id}/versions", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()[0]["missing_from_fs"] is True + assert db_memory_card_handler.get_version_by_id( + memory_card_version.id + ).missing_from_fs + + +@mock.patch("endpoints.memory_cards.fs_asset_handler.validate_path") +def test_version_listing_clears_the_flag_when_the_archive_is_back( + mock_validate_path, + client, + access_token: str, + memory_card: MemoryCard, + memory_card_version: MemoryCardVersion, + tmp_path, +): + db_memory_card_handler.set_version_missing(memory_card_version.id, True) + restored = tmp_path / "card.zip" + restored.write_bytes(b"CARD_ZIP") + mock_validate_path.return_value = restored + + response = client.get( + f"/api/memory-cards/{memory_card.id}/versions", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()[0]["missing_from_fs"] is False + assert not db_memory_card_handler.get_version_by_id( + memory_card_version.id + ).missing_from_fs + + +@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 + + +# --- Card content --- + + +@mock.patch("endpoints.memory_cards.fs_asset_handler.validate_path") +def test_owner_downloads_current_card_content( + mock_validate_path, + client, + access_token: str, + memory_card: MemoryCard, + memory_card_version: MemoryCardVersion, + tmp_path, +): + test_file = tmp_path / "card.zip" + test_file.write_bytes(b"CURRENT_CARD") + mock_validate_path.return_value = test_file + + response = client.get( + f"/api/memory-cards/{memory_card.id}/content", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_200_OK + assert response.content == b"CURRENT_CARD" + + +@mock.patch("endpoints.memory_cards.fs_asset_handler.validate_path") +def test_download_serves_the_newest_version( + mock_validate_path, + client, + access_token: str, + memory_card: MemoryCard, + memory_card_version: MemoryCardVersion, + tmp_path, +): + newest = db_memory_card_handler.add_version( + _version_for(memory_card.id, "newer.zip", "ffffffffffffffffffffffffffffffff") + ) + test_file = tmp_path / "card.zip" + test_file.write_bytes(b"NEWEST_CARD") + mock_validate_path.return_value = test_file + + response = client.get( + f"/api/memory-cards/{memory_card.id}/content", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_200_OK + assert response.headers["content-disposition"].endswith(f'"{newest.file_name}"') + + +def test_download_card_without_versions_is_404( + client, access_token: str, memory_card: MemoryCard +): + response = client.get( + f"/api/memory-cards/{memory_card.id}/content", headers=_auth(access_token) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_other_user_cannot_download_private_card_content( + client, + viewer_access_token: str, + memory_card: MemoryCard, + memory_card_version: MemoryCardVersion, +): + response = client.get( + f"/api/memory-cards/{memory_card.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_card_content( + 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/{memory_card.id}/content", + headers=_auth(viewer_access_token), + ) + assert response.status_code == status.HTTP_200_OK + assert response.content == b"SHARED_CARD" + + +# --- Upload --- + + +def _zip_bytes(members: dict[str, bytes]) -> bytes: + reload_zipfile() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, data in members.items(): + zf.writestr(name, data) + return buf.getvalue() + + +def _version_for(card_id: int, file_name: str, content_hash: str) -> MemoryCardVersion: + return MemoryCardVersion( + memory_card_id=card_id, + file_name=file_name, + file_name_no_tags=file_name, + file_name_no_ext=file_name, + file_extension="zip", + file_path="psx/memory_cards/pcsx2", + file_size_bytes=8.0, + content_hash=content_hash, + ) + + +def _stub_storage(card_id: int, file_name: str, content_hash: str): + """Keep a real store_memory_card_version call off the disk: the write is a + no-op and the scan hands back the version it would have produced.""" + + async def _scan(**kwargs): + return _version_for(card_id, file_name, content_hash) + + return ( + mock.patch( + "utils.memory_cards.fs_asset_handler.write_file", new=mock.AsyncMock() + ), + mock.patch( + "utils.memory_cards.scan_memory_card_version", + new=mock.AsyncMock(side_effect=_scan), + ), + ) + + +def test_upload_memory_card_version(client, access_token: str, memory_card: MemoryCard): + content = _zip_bytes({"Mcd001.ps2": b"card data"}) + write_patch, scan_patch = _stub_storage(memory_card.id, "uploaded.zip", "uploaded") + with write_patch as write_file, scan_patch: + response = client.post( + f"/api/memory-cards/{memory_card.id}/versions", + files={"cardFile": ("card.zip", content, "application/zip")}, + headers=_auth(access_token), + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["content_hash"] == "uploaded" + write_file.assert_awaited_once() + assert db_memory_card_handler.get_latest_version(memory_card.id).id == ( + response.json()["id"] + ) + + +def test_upload_of_already_stored_content_still_becomes_newest( + client, access_token: str, memory_card: MemoryCard +): + """Re-uploading a card the user downloaded earlier must not be deduplicated + away: the head version is what the next claim hydrates.""" + content = _zip_bytes({"Mcd001.ps2": b"card data"}) + hash_of_content = content_hash_of_bytes(content) + assert hash_of_content is not None + db_memory_card_handler.add_version( + _version_for(memory_card.id, "older.zip", hash_of_content) + ) + + write_patch, scan_patch = _stub_storage( + memory_card.id, "reuploaded.zip", hash_of_content + ) + with write_patch, scan_patch: + response = client.post( + f"/api/memory-cards/{memory_card.id}/versions", + files={"cardFile": ("card.zip", content, "application/zip")}, + headers=_auth(access_token), + ) + + assert response.status_code == status.HTTP_200_OK + assert len(db_memory_card_handler.get_versions(memory_card.id)) == 2 + assert response.json()["file_name"] == "reuploaded.zip" + + +def test_upload_non_zip_is_rejected(client, access_token: str, memory_card: MemoryCard): + """A bare card image is refused here rather than stored: nothing downstream + would notice until hydrate pushed it and the emulator rejected the card.""" + response = client.post( + f"/api/memory-cards/{memory_card.id}/versions", + files={ + "cardFile": ("Mcd001.ps2", b"raw card image", "application/octet-stream") + }, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.parametrize( + "entry", + [ + "../escaped.ps2", + "sub/../../escaped.ps2", + "/etc/passwd", + "..\\..\\escaped.ps2", + "sub\\..\\..\\escaped.ps2", + "\\\\server\\share\\escaped.ps2", + ], +) +def test_upload_with_an_escaping_entry_is_rejected( + client, access_token: str, memory_card: MemoryCard, entry: str +): + """RomM keeps the zip whole, so this is the last place that sees the entry + names before the broker unpacks them onto a container.""" + content = _zip_bytes({entry: b"card data"}) + write_patch, scan_patch = _stub_storage(memory_card.id, "uploaded.zip", "uploaded") + with write_patch as write_file, scan_patch: + response = client.post( + f"/api/memory-cards/{memory_card.id}/versions", + files={"cardFile": ("card.zip", content, "application/zip")}, + headers=_auth(access_token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "unsafe path" in response.json()["detail"] + write_file.assert_not_awaited() + assert db_memory_card_handler.get_versions(memory_card.id) == [] + + +def test_upload_with_a_symlink_entry_is_rejected( + client, access_token: str, memory_card: MemoryCard +): + """A symlink's own name passes every path check; what it points at does + not, and the extractor this guard protects follows it.""" + reload_zipfile() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + link = zipfile.ZipInfo("Mcd001.ps2") + # High half of external_attr is the unix mode: symlink, 0777. + link.external_attr = (0o120777 << 16) | 0o600 + zf.writestr(link, "/etc/passwd") + + write_patch, scan_patch = _stub_storage(memory_card.id, "uploaded.zip", "uploaded") + with write_patch as write_file, scan_patch: + response = client.post( + f"/api/memory-cards/{memory_card.id}/versions", + files={"cardFile": ("card.zip", buf.getvalue(), "application/zip")}, + headers=_auth(access_token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "symlink" in response.json()["detail"] + write_file.assert_not_awaited() + assert db_memory_card_handler.get_versions(memory_card.id) == [] + + +def test_upload_that_unpacks_past_the_cap_is_rejected( + client, access_token: str, memory_card: MemoryCard +): + """A zip's own size says nothing about what it becomes, and the container + that unpacks it has a disk. The cap is patched down so the test does not + have to build a gigabyte to prove it is enforced.""" + content = _zip_bytes({"Mcd001.ps2": b"\0" * 4096}) + write_patch, scan_patch = _stub_storage(memory_card.id, "uploaded.zip", "uploaded") + with ( + mock.patch("utils.memory_cards._CARD_MAX_UNPACKED_BYTES", 1024), + write_patch as write_file, + scan_patch, + ): + response = client.post( + f"/api/memory-cards/{memory_card.id}/versions", + files={"cardFile": ("card.zip", content, "application/zip")}, + headers=_auth(access_token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "unpacks to over" in response.json()["detail"] + write_file.assert_not_awaited() + assert db_memory_card_handler.get_versions(memory_card.id) == [] + + +def test_upload_whose_entries_only_add_up_past_the_cap_is_rejected( + client, access_token: str, memory_card: MemoryCard +): + """A card set is several files and the container's disk pays for the whole + of it, so the budget is spent across entries rather than per entry.""" + content = _zip_bytes({f"Mcd00{slot}.ps2": b"\0" * 512 for slot in range(1, 4)}) + write_patch, scan_patch = _stub_storage(memory_card.id, "uploaded.zip", "uploaded") + with ( + mock.patch("utils.memory_cards._CARD_MAX_UNPACKED_BYTES", 1024), + write_patch as write_file, + scan_patch, + ): + response = client.post( + f"/api/memory-cards/{memory_card.id}/versions", + files={"cardFile": ("card.zip", content, "application/zip")}, + headers=_auth(access_token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "unpacks to over" in response.json()["detail"] + write_file.assert_not_awaited() + assert db_memory_card_handler.get_versions(memory_card.id) == [] + + +def test_upload_to_another_users_card_is_404( + client, viewer_access_token: str, memory_card: MemoryCard +): + db_memory_card_handler.update_card(memory_card.id, {"is_public": True}) + response = client.post( + f"/api/memory-cards/{memory_card.id}/versions", + files={"cardFile": ("card.zip", _zip_bytes({"a": b"b"}), "application/zip")}, + headers=_auth(viewer_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 + + +@mock.patch("endpoints.memory_cards.fs_asset_handler.remove_file") +def test_delete_batch_with_a_bad_id_deletes_nothing( + mock_remove_file, + client, + access_token: str, + memory_card: MemoryCard, +): + """Deletion is irreversible and runs a transaction per card, so a bad id + anywhere in the batch must fail before the first card is touched.""" + response = client.post( + "/api/memory-cards/delete", + json={"cards": [memory_card.id, 999999]}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_remove_file.assert_not_awaited() + assert db_memory_card_handler.get_card_by_id(memory_card.id) is not None + + +@mock.patch( + "endpoints.memory_cards.fs_asset_handler.remove_file", + new=mock.AsyncMock(side_effect=PermissionError("read-only mount")), +) +def test_delete_survives_an_archive_that_will_not_budge( + client, + access_token: str, + admin_user: User, + memory_card: MemoryCard, + memory_card_version: MemoryCardVersion, +): + """An orphaned archive is recoverable; a batch that stops half way with + nothing telling the caller how far it got is not. The unremovable archive + is on the first card, so the second one proves the batch carried on.""" + second = db_memory_card_handler.add_card( + MemoryCard( + user_id=admin_user.id, + emulator="pcsx2", + platform_id=memory_card.platform_id, + name="second_card", + slot=1, + is_public=False, + ) + ) + response = client.post( + "/api/memory-cards/delete", + json={"cards": [memory_card.id, second.id]}, + headers=_auth(access_token), + ) + assert response.status_code == status.HTTP_200_OK + 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 + assert db_memory_card_handler.get_card_by_id(second.id) is None + + +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 + + +# --- Version storage --- + + +async def _hash_on_disk(content: bytes, filename: str) -> str | None: + """The hash the scan stores, computed the way the scan computes it.""" + path = "memory_card_hash_lockstep" + await fs_asset_handler.write_file(file=content, path=path, filename=filename) + try: + return await fs_asset_handler.compute_content_hash(f"{path}/{filename}") + finally: + shutil.rmtree(fs_asset_handler.base_path / path, ignore_errors=True) + + +@pytest.mark.parametrize( + ("content", "filename"), + [ + (_zip_bytes({"Mcd001.ps2": b"card data", "sub/Mcd002.ps2": b"more"}), "c.zip"), + (b"a card that is not an archive", "c.bin"), + ], + ids=["zip", "plain"], +) +async def test_the_in_memory_hash_matches_the_stored_one(content: bytes, filename: str): + """Dedup compares a hash taken in memory against hashes the scan wrote to + the database. The two are separate implementations, so a drift between them + would not fail anywhere: it would quietly stop deduplicating.""" + assert content_hash_of_bytes(content) == await _hash_on_disk(content, filename) + + +async def test_version_filename_steps_around_an_occupied_name( + admin_user: User, memory_card: MemoryCard +): + """Two snapshots in the same millisecond would otherwise share a name, and + write_file overwrites silently: the first archive's bytes would go while its + row lived on describing them.""" + taken = {"first": True} + + async def _file_exists(file_path: str) -> bool: + if taken["first"]: + taken["first"] = False + return True + return False + + with ( + mock.patch( + "utils.memory_cards.fs_asset_handler.file_exists", + new=mock.AsyncMock(side_effect=_file_exists), + ), + mock.patch( + "utils.memory_cards.fs_asset_handler.write_file", new=mock.AsyncMock() + ) as write_file, + mock.patch( + "utils.memory_cards.scan_memory_card_version", + new=mock.AsyncMock( + side_effect=lambda **kwargs: _version_for( + memory_card.id, kwargs["file_name"], "stored" + ) + ), + ), + ): + assert await store_memory_card_version( + admin_user, memory_card, b"card data", deduplicate=False + ) + + written = write_file.await_args.kwargs["filename"] if write_file.await_args else "" + assert "(2)" in written + assert ( + db_memory_card_handler.get_latest_version(memory_card.id).file_name == written + ) + + +async def test_a_failed_scan_leaves_no_archive_behind( + admin_user: User, memory_card: MemoryCard +): + """No row points at the archive yet, so leaving it there strands bytes + nothing can reach and no delete would ever clean up.""" + with ( + mock.patch( + "utils.memory_cards.fs_asset_handler.file_exists", + new=mock.AsyncMock(return_value=False), + ), + mock.patch( + "utils.memory_cards.fs_asset_handler.write_file", new=mock.AsyncMock() + ), + mock.patch( + "utils.memory_cards.fs_asset_handler.remove_file", new=mock.AsyncMock() + ) as remove_file, + mock.patch( + "utils.memory_cards.scan_memory_card_version", + new=mock.AsyncMock(side_effect=OSError("scan blew up")), + ), + ): + with pytest.raises(OSError): + await store_memory_card_version( + admin_user, memory_card, b"card data", deduplicate=False + ) + + remove_file.assert_awaited_once() + assert db_memory_card_handler.get_versions(memory_card.id) == [] diff --git a/backend/tests/endpoints/test_streaming.py b/backend/tests/endpoints/test_streaming.py index 6824d9c417..49a5f55fd8 100644 --- a/backend/tests/endpoints/test_streaming.py +++ b/backend/tests/endpoints/test_streaming.py @@ -1,30 +1,62 @@ import asyncio +import io +import json import logging +import re +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 endpoints.streaming import platform_capabilities from handler.auth import oauth_handler -from handler.database import db_platform_handler, db_rom_handler +from handler.database import ( + db_container_adoption_handler, + 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.rom import Rom, RomFile from models.user import User +# ── Fixtures / helpers ──────────────────────────────────────────────────────── + def _hide(entity: PermEntity, entity_id: int, user_id: int) -> None: with sync_session.begin() as s: s.add(HiddenEntity(entity=entity, entity_id=entity_id, user_id=user_id)) +def _reads(body: bytes): + """A `read(n)` that drains like a socket: the body once, then EOF. + + The broker readers loop until the response runs dry, so a stub answering the + same bytes to every call would look like a body that never ends. + """ + chunks = iter([body]) + + def read(_size: int | None = None) -> bytes: + return next(chunks, b"") + + return read + + @pytest.fixture def client(): with TestClient(app) as client: @@ -33,7 +65,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 @@ -85,6 +117,12 @@ def _container_for(rom: Rom, broker_host="http://192.168.1.10:8000"): } +def _first_container(platform: str): + """The container a claim for this platform would try first, or None.""" + candidates = streaming._containers_for_platform(platform) + return candidates[0] if candidates else None + + def _rom_on(slug: str) -> Rom: """Create a platform with the given slug and a ROM on it.""" platform = db_platform_handler.add_platform( @@ -104,18 +142,31 @@ def _rom_on(slug: str) -> Rom: ) +def _add_rom_file(rom: Rom, file_name: str) -> RomFile: + """A RomFile on `rom`, the way multi_file_rom builds them.""" + return db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name=file_name, + file_path=f"{rom.fs_path}/{rom.fs_name}", + file_size_bytes=1, + ) + ) + + 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) @@ -157,9 +208,389 @@ def test_get_config_ships_platform_capabilities(client, access_token): "max_slots": 9, "has_autosave": True, "autosave_slot": 10, + "has_memory_card": True, + "supports_disc_swap": False, + "has_manual_disc_swap": True, + } + + +def test_get_config_ships_capabilities_for_a_retroarch_platform(client, access_token): + """RetroArch serves dozens of platforms, none of them listed by name. Without + a fallback they all reported no states and the player offered no save + button, only save and exit.""" + container = { + "host": "http://192.168.1.10:3000", + "broker_host": "http://192.168.1.10:8000", + "platforms": {"psp": "retroarch"}, + } + with _streaming(container): + r = client.get("/api/streaming/config", headers=_auth(access_token)) + assert r.status_code == 200 + caps = r.json()["containers"][0]["capabilities"] + assert caps["has_autosave"] is True + assert caps["autosave_slot"] == 10 + + +def test_get_config_ships_capabilities_for_a_native_ppsspp_platform( + client, access_token +): + """PPSSPP has no control socket either, so it gets the same single-slot + treatment as RetroArch, but its own slot: the broker's controls.ini + hotkey always lands on PPSSPP_STATE_SLOT (1), not RetroArch's 10.""" + container = { + "host": "http://192.168.1.10:3000", + "broker_host": "http://192.168.1.10:8000", + "platforms": {"psp": {"emulator": "ppsspp", "label": "PPSSPP"}}, + } + with _streaming(container): + r = client.get("/api/streaming/config", headers=_auth(access_token)) + assert r.status_code == 200 + caps = r.json()["containers"][0]["capabilities"] + assert caps["has_autosave"] is True + assert caps["autosave_slot"] == 1 + assert caps["max_slots"] == 0 + + +def test_get_config_ships_per_platform_label_overrides(client, access_token): + """The headline promise of a shared webstation container: a PS2 row can + say "PCSX2" while its siblings keep the container's fallback label.""" + container = { + "host": "http://box:3010", + "protocol": "webstation", + "label": "Emulation station", + "platforms": { + "wii": "dolphin", + "ps2": {"emulator": "pcsx2", "label": "PCSX2"}, + }, + } + with _streaming(container): + r = client.get("/api/streaming/config", headers=_auth(access_token)) + assert r.status_code == 200 + by_platform = {c["platform"]: c for c in r.json()["containers"]} + assert by_platform["wii"]["label"] == "Emulation station" + assert by_platform["ps2"]["label"] == "PCSX2" + + +def test_a_platform_entry_wins_over_the_emulator_fallback(): + """ngc has its own slot semantics, so serving it through RetroArch must not + quietly replace them.""" + container = { + "host": "http://192.168.1.10:3000", + "broker_host": "http://192.168.1.10:8000", + "platforms": {"ngc": "retroarch"}, + } + with _streaming(container): + assert streaming.platform_capabilities("ngc")["max_slots"] == 7 + + +def test_an_unconfigured_platform_still_has_no_states(): + """The fallback keys off a configured container, so a platform nobody + streams stays out of the save-state UI.""" + with _streaming(): + assert streaming.platform_capabilities("psp") == { + **streaming._NO_CAPABILITIES, + "supports_disc_swap": False, + "has_manual_disc_swap": False, + } + + +@pytest.mark.parametrize("platform", ["dc", "saturn", "segacd", "turbografx-cd", "dos"]) +def test_the_disc_platforms_support_a_live_swap(platform): + assert platform_capabilities(platform)["supports_disc_swap"] is True + + +def test_ps2_gets_a_hint_instead_of_a_swap_control(): + caps = platform_capabilities("ps2") + assert caps["supports_disc_swap"] is False + assert caps["has_manual_disc_swap"] is True + + +def test_a_platform_with_no_tray_gets_neither(): + caps = platform_capabilities("xbox") + assert caps["supports_disc_swap"] is False + assert caps["has_manual_disc_swap"] is False + + +def test_disc_swap_does_not_disturb_the_slot_capabilities(): + """The disc flags are an overlay; a platform's slot semantics are + whatever its own table entry already said.""" + caps = platform_capabilities("ngc") + assert caps["max_slots"] == 7 + assert caps["autosave_slot"] == 8 + + +def test_the_retroarch_autosave_slot_passes_slot_validation(): + """The gate in front of every state route reads the same table, so a slot + the frontend is told about has to survive it.""" + container = { + "host": "http://192.168.1.10:3000", + "broker_host": "http://192.168.1.10:8000", + "platforms": {"psp": "retroarch"}, + } + with _streaming(container): + streaming._assert_valid_slot("psp", 10) + with pytest.raises(HTTPException): + streaming._assert_valid_slot("psp", 3) + + +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 + + +def test_memory_card_sync_ignored_on_a_platform_without_a_card(client, access_token): + """Wii saves live in NAND and sync per file. Honouring memory_card_sync + there would disable /save-file and silently strand every NAND save.""" + container = { + "platform": "wii", + "host": "http://192.168.1.10:3000", + "memory_card_sync": True, + } + with _streaming(container): + response = client.get("/api/streaming/config", headers=_auth(access_token)) + assert response.status_code == 200 + assert response.json()["containers"][0]["supports_memory_cards"] is False + + +def test_memory_card_sync_on_a_cardless_platform_warns_the_operator(caplog): + """The misconfiguration is silent otherwise, so the claim path logs it. + /config is polled continuously and deliberately stays quiet.""" + container = { + "platform": "wii", + "host": "http://192.168.1.10:3000", + "memory_card_sync": True, + } + romm_logger = logging.getLogger("romm") + romm_logger.addHandler(caplog.handler) + try: + with _streaming(container): + with caplog.at_level(logging.WARNING, logger="romm"): + found = _first_container("wii") + finally: + romm_logger.removeHandler(caplog.handler) + assert found is container + assert "has no memory card" in caplog.text + + +def test_memory_card_sync_honoured_on_a_platform_with_a_card(client, access_token): + """The guard rejects only the unsupported platforms, ngc keeps whole-card + sync because Dolphin serves a Slot-A card.""" + container = { + "platform": "ngc", + "host": "http://192.168.1.10:3000", + "memory_card_sync": True, + } + with _streaming(container): + response = client.get("/api/streaming/config", headers=_auth(access_token)) + assert response.status_code == 200 + assert response.json()["containers"][0]["supports_memory_cards"] is True + + +def test_get_config_hides_a_hidden_platform( + client, viewer_access_token, viewer_user: User, rom: Rom, platform +): + """The entry carries the platform's label and capabilities, so a platform + an admin hid from this user must not be listed.""" + _hide(PermEntity.PLATFORMS, platform.id, viewer_user.id) + with _streaming(_container_for(rom)): + response = client.get( + "/api/streaming/config", headers=_auth(viewer_access_token) + ) + assert response.status_code == 200 + assert response.json()["containers"] == [] + + +def test_get_config_keeps_a_platform_the_caller_can_see( + client, viewer_access_token, rom: Rom +): + with _streaming(_container_for(rom)): + response = client.get( + "/api/streaming/config", headers=_auth(viewer_access_token) + ) + assert response.status_code == 200 + listed = [c["platform"] for c in response.json()["containers"]] + assert listed == [rom.platform_slug] + + +def test_get_config_offers_disc_swap_only_on_a_webstation_container( + client, access_token +): + """Only the webstation broker has a tray route. A legacy container serving + a multi-disc platform would 502 on every swap it advertised.""" + legacy = {"platform": "dc", "host": "http://192.168.1.10:3000"} + with _streaming(legacy): + legacy_caps = client.get( + "/api/streaming/config", headers=_auth(access_token) + ).json()["containers"][0]["capabilities"] + + webstation = {**legacy, "protocol": "webstation"} + with _streaming(webstation): + ws_caps = client.get( + "/api/streaming/config", headers=_auth(access_token) + ).json()["containers"][0]["capabilities"] + + assert legacy_caps["supports_disc_swap"] is False + assert ws_caps["supports_disc_swap"] is True + + +# ── Nested platform config ──────────────────────────────────────────────────── + + +def _nested(**overrides): + """A webstation container serving several platforms from one host.""" + return { + "host": "http://192.168.1.10:3000", + "broker_host": "http://192.168.1.10:8000", + "platforms": {"ps2": "pcsx2", "ngc": "dolphin"}, + **overrides, + } + + +def test_nested_platforms_resolve_to_the_same_container(): + """One entry serves every platform in its map, each with its own emulator.""" + with _streaming(_nested()): + ps2 = _first_container("ps2") + ngc = _first_container("ngc") + assert ps2 is not None and ngc is not None + assert ps2["platform"] == "ps2" + assert streaming._emulator_for_container(ps2) == "pcsx2" + assert streaming._emulator_for_container(ngc) == "dolphin" + + +def test_nested_platforms_share_one_session_key(): + """Sessions key on the broker host, so the expanded copies collapse back + to the single session the container can actually serve.""" + with _streaming(_nested()): + ps2 = _first_container("ps2") + ngc = _first_container("ngc") + assert ps2 is not None and ngc is not None + assert streaming._container_key(ps2) == streaming._container_key(ngc) + + +def test_nested_platforms_reject_a_second_claim_across_platforms( + client, access_token, rom: Rom +): + """The end-to-end consequence: claiming ps2 blocks ngc on the same box.""" + ngc_rom = _rom_on("ngc") + ps2_rom = _rom_on("ps2") + with _streaming(_nested()): + first = _claim_ok(client, access_token, ps2_rom.id) + second = _claim_ok(client, access_token, ngc_rom.id) + assert first.status_code == 200 + assert second.status_code == 409 + + +def _webstation_nested(): + """A webstation container serving several platforms with a bare stream + host and no explicit broker_host, the shape the example config + documents as the headline case.""" + return { + "host": "http://box:3010", + "protocol": "webstation", + "platforms": { + "wii": "dolphin", + "ps2": {"emulator": "pcsx2", "label": "PCSX2"}, + }, } +def test_webstation_nested_platforms_share_one_session_key(): + """Tasks 6 and 7 each have their own tests; this pins the combination + the example config documents: no explicit broker_host still derives one + shared key across the container's expanded platform rows.""" + with _streaming(_webstation_nested()): + wii = _first_container("wii") + ps2 = _first_container("ps2") + assert wii is not None and ps2 is not None + assert streaming._container_key(wii) == streaming._container_key(ps2) + assert streaming._container_key(wii) == "http://box:3010" + + +def test_nested_platforms_ship_one_config_row_each(client, access_token): + """The frontend reads capabilities per platform, so expansion must reach + /config rather than stopping at the claim path.""" + with _streaming(_nested()): + r = client.get("/api/streaming/config", headers=_auth(access_token)) + assert r.status_code == 200 + rows = {c["platform"]: c for c in r.json()["containers"]} + assert set(rows) == {"ps2", "ngc"} + assert rows["ngc"]["emulator"] == "dolphin" + assert rows["ps2"]["capabilities"]["max_slots"] == 9 + + +def test_config_keeps_one_row_per_platform(client, access_token): + """Two containers serving the same platform are a pool, not two choices. + The claim picks which one serves, so /config must not offer both.""" + with _streaming(_nested(), _nested(host="http://192.168.1.11:3000")): + r = client.get("/api/streaming/config", headers=_auth(access_token)) + assert r.status_code == 200 + platforms = [c["platform"] for c in r.json()["containers"]] + assert sorted(platforms) == ["ngc", "ps2"] + + +def test_flat_container_config_still_works(rom: Rom): + """The per-emulator mods are still deployed on the flat shape.""" + with _streaming(_container_for(rom)): + found = _first_container(rom.platform_slug) + assert found is not None + assert found["platform"] == rom.platform_slug + + +def test_nested_platforms_wins_over_a_flat_platform(caplog): + """Declaring both is a half-finished migration, so say so rather than + silently serving one platform out of the map.""" + container = _nested(platform="xbox") + romm_logger = logging.getLogger("romm") + romm_logger.addHandler(caplog.handler) + try: + with _streaming(container): + with caplog.at_level(logging.WARNING, logger="romm"): + xbox = _first_container("xbox") + ps2 = _first_container("ps2") + finally: + romm_logger.removeHandler(caplog.handler) + assert xbox is None + assert ps2 is not None + assert "both `platform` and `platforms`" in caplog.text + + +def test_nested_platform_without_an_emulator_is_skipped(caplog): + """The emulator names the state namespace, so an entry missing one would + silently file saves under the wrong container.""" + container = _nested(platforms={"ps2": "pcsx2", "ngc": ""}) + romm_logger = logging.getLogger("romm") + romm_logger.addHandler(caplog.handler) + try: + with _streaming(container): + with caplog.at_level(logging.WARNING, logger="romm"): + assert _first_container("ngc") is None + assert _first_container("ps2") is not None + finally: + romm_logger.removeHandler(caplog.handler) + assert "no emulator" in caplog.text + + +def test_platforms_that_is_not_a_map_skips_the_container(caplog): + container = _nested(platforms=["ps2", "ngc"]) + romm_logger = logging.getLogger("romm") + romm_logger.addHandler(caplog.handler) + try: + with _streaming(container): + with caplog.at_level(logging.WARNING, logger="romm"): + assert _first_container("ps2") is None + finally: + romm_logger.removeHandler(caplog.handler) + assert "must be a map" in caplog.text + + # ── Claiming ────────────────────────────────────────────────────────────────── @@ -170,10 +601,72 @@ 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_appends_stream_token_to_host(client, access_token, rom: Rom): + """The broker's stream token comes back in the launch body and rides the + host URL to the iframe, it does not get discarded with the rest of the + launch response.""" + container = {**_container_for(rom), "host": "https://stream.example:3001"} + with _streaming(container): + with patch("endpoints.streaming._call_broker") as call_broker: + call_broker.return_value = { + "status": "launching", + "stream_token": "tok-abc", + } + r = _claim(client, access_token, rom.id) + assert r.status_code == 200 + assert r.json()["host"] == "https://stream.example:3001?stream_token=tok-abc" + + +def test_claim_appends_stream_token_with_ampersand_when_host_has_query( + client, access_token, rom: Rom +): + container = { + **_container_for(rom), + "host": "https://stream.example:3001/?path=abc", + } + with _streaming(container): + with patch("endpoints.streaming._call_broker") as call_broker: + call_broker.return_value = { + "status": "launching", + "stream_token": "tok-abc", + } + r = _claim(client, access_token, rom.id) + assert r.status_code == 200 + assert ( + r.json()["host"] == "https://stream.example:3001/?path=abc&stream_token=tok-abc" + ) + + +def test_claim_leaves_host_unchanged_when_broker_returns_no_token( + client, access_token, rom: Rom +): + """A bare MagicMock (the common stub in _claim_ok and older tests) must + not inject a token, its .get(...) is truthy but is not a real dict.""" + container = {**_container_for(rom), "host": "https://stream.example:3001"} + with _streaming(container): + with patch("endpoints.streaming._call_broker") as call_broker: + call_broker.return_value = {"status": "launching"} + r = _claim(client, access_token, rom.id) + assert r.status_code == 200 + assert r.json()["host"] == "https://stream.example:3001" + + def test_claim_unknown_rom_returns_404(client, access_token): with _streaming(): r = _claim(client, access_token, 999999) @@ -223,12 +716,36 @@ def test_claim_skips_container_missing_host(client, access_token, rom: Rom): assert r.status_code == 404 +def test_proxied_host_is_usable(rom: Rom): + """A host that is a path names a container reverse proxied onto RomM's own + origin, which is how the iframe ends up same origin as the player.""" + proxied = { + "platform": rom.platform_slug, + "host": "/streaming", + "broker_host": "http://192.168.1.10:8000", + } + with _streaming(proxied): + container = _first_container(rom.platform_slug) + assert container is not None + assert container["host"] == "/streaming" + # The key still comes from the broker address, so proxying a container does + # not move the session it already holds. + assert streaming._container_key(container) == "http://192.168.1.10:8000" + + +def test_claim_skips_proxied_host_without_broker_host(client, access_token, rom: Rom): + """A proxied host carries no address RomM can call, so without broker_host + the broker is unreachable and the entry must be skipped, not 500.""" + bad = {"platform": rom.platform_slug, "host": "/streaming"} + with _streaming(bad): + r = _claim(client, access_token, rom.id) + assert r.status_code == 404 + + @pytest.mark.asyncio 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 +757,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 +797,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,228 +824,4524 @@ 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] -# ── Release / ownership ─────────────────────────────────────────────────────── +# ── Container pool ──────────────────────────────────────────────────────────── -def test_release_uses_container_key_not_platform(client, access_token, rom: Rom): - """release_session must find the session by broker_host, not platform string.""" - with _streaming(_container_for(rom)): +def _pool_member(rom: Rom, index: int) -> dict: + """One member of a pool serving the ROM's platform. Distinct hosts, so both + the session key and the claim response say which member served. No label, + since the emulator falls back to it and pool members must agree on that.""" + return { + "platform": rom.platform_slug, + "host": f"http://192.168.1.1{index}:3000", + "broker_host": f"http://192.168.1.1{index}:8000", + } + + +def _volume(client, token, platform: str, level: int = 42): + return client.post( + f"/api/streaming/sessions/{platform}/volume", + json={"level": level}, + headers=_auth(token), + ) + + +def _session_raw(container: dict): + key = streaming._session_redis_key(streaming._container_key(container)) + return asyncio.run(async_cache.get(key)) + + +def test_pool_claim_falls_through_to_a_free_container(client, access_token, rom: Rom): + """A second claim is not a 409 when another container serves the platform.""" + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): + r1 = _claim_ok(client, access_token, rom.id) + r2 = _claim_ok(client, access_token, rom.id) + assert [r1.status_code, r2.status_code] == [200, 200] + # Config order, so the head of the pool stays warm. + assert r1.json()["host"] == "http://192.168.1.10:3000" + assert r2.json()["host"] == "http://192.168.1.11:3000" + + +def test_pool_409s_only_once_every_container_is_held(client, access_token, rom: Rom): + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): _claim_ok(client, access_token, rom.id) - with patch("endpoints.streaming._stop_broker"): - r = client.delete( - f"/api/streaming/sessions/{rom.platform_slug}", - headers=_auth(access_token), - ) - assert r.status_code == 200 - assert r.json()["status"] == "released" + _claim_ok(client, access_token, rom.id) + r3 = _claim_ok(client, access_token, rom.id) + assert r3.status_code == 409 + assert "2 containers" in r3.json()["detail"]["message"] -def test_release_by_other_user_is_forbidden( +def test_pool_never_evicts_a_stale_session_while_a_container_is_free( client, access_token, viewer_access_token, rom: Rom ): - """A session claimed by one user cannot be released by another non-admin.""" - with _streaming(_container_for(rom)): - # viewer claims the session; admin could override, a viewer cannot - r_claim = _claim_ok(client, access_token, rom.id) - r = client.delete( - f"/api/streaming/sessions/{rom.platform_slug}", - headers=_auth(viewer_access_token), - ) - assert r_claim.status_code == 200 - assert r.status_code == 403 + """Config order is a warm-cache preference, not a licence to displace a + player: an idle container has to be taken before a stale one is torn down.""" + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): + _claim_ok(client, access_token, rom.id) + _age_session_on(_pool_member(rom, 0), streaming._SESSION_STALE_SECONDS + 60) + with patch( + "endpoints.streaming._stop_broker", return_value=None + ) as stop_broker: + r2 = _claim_ok(client, viewer_access_token, rom.id) + assert r2.status_code == 200 + assert r2.json()["host"] == "http://192.168.1.11:3000" + stop_broker.assert_not_called() + assert _session_raw(_pool_member(rom, 0)) is not None -def test_save_state_by_other_user_is_forbidden( +def test_pool_takes_over_a_stale_session_once_every_container_is_held( client, access_token, viewer_access_token, rom: Rom ): - with _streaming(_container_for(rom)): + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): _claim_ok(client, access_token, rom.id) - r = client.post( - f"/api/streaming/sessions/{rom.platform_slug}/save-state", - json={"slot": 1}, - headers=_auth(viewer_access_token), - ) - assert r.status_code == 403 + _claim_ok(client, access_token, rom.id) + _age_session_on(_pool_member(rom, 1), streaming._SESSION_STALE_SECONDS + 60) + with patch( + "endpoints.streaming._stop_broker", return_value=None + ) as stop_broker: + r3 = _claim_ok(client, viewer_access_token, rom.id) + assert r3.status_code == 200 + assert r3.json()["host"] == "http://192.168.1.11:3000" + stop_broker.assert_called_once() -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.""" - rom = _rom_on("ngc") +def test_control_routes_follow_the_session_not_the_platform( + client, access_token, viewer_access_token, rom: Rom +): + """The second player is on the second container, so their volume call has + to reach that broker rather than the first one the platform lists.""" + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): + _claim_ok(client, access_token, rom.id) + _claim_ok(client, viewer_access_token, rom.id) + with patch("endpoints.streaming._volume_broker", return_value=True) as volume: + r = _volume(client, viewer_access_token, rom.platform_slug) + assert r.status_code == 200 + assert volume.call_args[0][0]["host"] == "http://192.168.1.11:3000" + + +def test_control_route_403s_when_every_session_belongs_to_someone_else( + client, access_token, viewer_access_token, rom: Rom +): + """The owner scan finding nothing must not read as "no session here".""" + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): + _claim_ok(client, access_token, rom.id) + r = _volume(client, viewer_access_token, rom.platform_slug) + assert r.status_code == 403 + + +def test_an_admin_controls_the_pools_one_active_session( + client, access_token, viewer_access_token, rom: Rom +): + """An admin holds nothing on the platform, so the scan finds nothing of + theirs and falls back to the session that is actually running.""" + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): + _claim_ok(client, viewer_access_token, rom.id) + with patch("endpoints.streaming._volume_broker", return_value=True) as volume: + r = _volume(client, access_token, rom.platform_slug) + assert r.status_code == 200 + assert volume.call_args[0][0]["host"] == "http://192.168.1.10:3000" + + +def test_an_admin_cannot_guess_which_of_two_sessions_to_control( + client, access_token, viewer_access_token, rom: Rom +): + """Two sessions and a path that names neither, so ask rather than pick.""" + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): + _claim_ok(client, viewer_access_token, rom.id) + _claim_ok(client, viewer_access_token, rom.id) + r = _volume(client, access_token, rom.platform_slug) + assert r.status_code == 409 + + +def test_admin_release_names_the_container( + client, access_token, viewer_access_token, rom: Rom +): + """`container` is the key GET /streaming/sessions reports, and it must + release that member and leave the rest of the pool playing.""" + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): + _claim_ok(client, viewer_access_token, rom.id) + _claim_ok(client, viewer_access_token, rom.id) + with patch("endpoints.streaming._stop_broker", return_value=None): + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + params={"container": streaming._container_key(_pool_member(rom, 1))}, + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert r.json()["status"] == "released" + assert _session_raw(_pool_member(rom, 1)) is None + assert _session_raw(_pool_member(rom, 0)) is not None + + +def test_admin_release_rejects_a_container_that_serves_another_platform( + client, access_token, rom: Rom +): + with _streaming(_pool_member(rom, 0)): + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + params={"container": "http://192.168.9.9:8000"}, + headers=_auth(access_token), + ) + assert r.status_code == 404 + + +def test_status_finds_the_termination_on_whichever_container_held_it( + client, access_token, viewer_access_token, rom: Rom +): + """The tombstone is keyed per container, so the poll has to look past the + first member of the pool to find the displaced player's notice.""" + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): + _claim_ok(client, access_token, rom.id) + _claim_ok(client, viewer_access_token, rom.id) + with patch("endpoints.streaming._stop_broker", return_value=None): + client.delete( + "/api/streaming/sessions", + params={"reason": "maintenance"}, + headers=_auth(access_token), + ) + r = client.get( + f"/api/streaming/sessions/{rom.platform_slug}/status", + headers=_auth(viewer_access_token), + ) + assert r.status_code == 200 + assert r.json()["status"] == "ended" + assert r.json()["termination"]["reason"] == "maintenance" + + +def test_heartbeat_refreshes_the_session_on_the_container_that_holds_it( + client, access_token, viewer_access_token, rom: Rom +): + with _streaming(_pool_member(rom, 0), _pool_member(rom, 1)): + _claim_ok(client, access_token, rom.id) + _claim_ok(client, viewer_access_token, rom.id) + _age_session_on(_pool_member(rom, 1), 120) + before = json.loads(_session_raw(_pool_member(rom, 1)))["last_seen"] + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(viewer_access_token), + ) + after = json.loads(_session_raw(_pool_member(rom, 1)))["last_seen"] + assert r.json()["status"] == "active" + assert after > before + + +def test_a_container_that_disagrees_on_the_emulator_is_not_a_pool_member(caplog): + """Pool members file states and cards in one place, so an entry naming a + different emulator is a separate setup rather than a spare container.""" + first = { + "platform": "ps2", + "host": "http://192.168.1.10:3000", + "broker_host": "http://192.168.1.10:8000", + "emulator": "pcsx2", + } + second = {**first, "host": "http://192.168.1.11:3000"} + second["broker_host"] = "http://192.168.1.11:8000" + second["emulator"] = "play" + romm_logger = logging.getLogger("romm") + romm_logger.addHandler(caplog.handler) + try: + with _streaming(first, second): + with caplog.at_level(logging.WARNING, logger="romm"): + candidates = streaming._containers_for_platform("ps2") + finally: + romm_logger.removeHandler(caplog.handler) + assert [c["emulator"] for c in candidates] == ["pcsx2"] + assert "not a pool" in caplog.text + + +def test_the_session_platform_picks_the_config_entry_for_its_container(): + """A container serving several platforms expands into one entry per + platform under one key, so the admin views must not read an arbitrary one: + the platform-keyed fields (emulator, card sync) differ between them.""" + with _streaming(_nested()): + grouped = streaming._containers_by_key() + key = streaming._container_key(_first_container("ps2")) + assert len(grouped[key]) == 2 + for platform in ("ps2", "ngc"): + entry = streaming._container_for_session(grouped, key, platform) + assert entry is not None + assert entry["platform"] == platform + # A session predating the platform field still resolves to a real entry. + assert streaming._container_for_session(grouped, key, None) is not None + assert streaming._container_for_session(grouped, "http://nope:8000", "ps2") is None + + +# ── Desktop sessions ────────────────────────────────────────────────────────── + + +def _webstation(**overrides): + """A container whose broker speaks the webstation protocol, the only one + that serves a desktop.""" + return _nested(protocol="webstation", label="Webstation", **overrides) + + +def _containers(client, token): + return client.get("/api/streaming/containers", headers=_auth(token)) + + +def _desktop(client, token, container_key: str, url="/streaming/room/abc"): + """Open a desktop with the broker activation stubbed.""" + with patch( + "endpoints.streaming._webstation_activate", return_value={"url": url} + ) as activate: + response = client.post( + "/api/streaming/desktop", + json={"container": container_key}, + headers=_auth(token), + ) + return response, activate + + +def _key_of(container: dict) -> str: + return streaming._container_key(container) + + +def _claim_webstation_ok(client, token, rom_id): + """Claim a game on a webstation container, whose launch goes through + activate rather than the per-emulator mods' /launch.""" + with patch( + "endpoints.streaming._webstation_activate", return_value={"url": "/room/x"} + ): + return _claim(client, token, rom_id) + + +def test_containers_lists_one_row_per_container(client, access_token): + """A container serves many platforms but hosts one session, so the fleet + view counts containers, not the platform rows /config ships.""" + second = _webstation( + host="http://192.168.1.11:3000", broker_host="http://192.168.1.11:8000" + ) + with _streaming(_webstation(), second): + response = _containers(client, access_token) + assert response.status_code == 200 + rows = response.json()["containers"] + assert len(rows) == 2 + assert sorted(rows[0]["platforms"]) == ["ngc", "ps2"] + assert rows[0]["supports_desktop"] is True + assert rows[0]["session"] is None + + +def test_containers_reports_a_container_that_can_never_be_claimed(client, access_token): + """A schemeless host derives no key, so the row says so rather than + sitting in the list looking idle.""" + with _streaming(_nested(host="192.168.1.10:3000", broker_host="")): + response = _containers(client, access_token) + assert response.status_code == 200 + assert response.json()["containers"][0]["configured"] is False + + +def test_containers_shows_what_is_running(client, access_token): + ps2_rom = _rom_on("ps2") + with _streaming(_nested()): + assert _claim_ok(client, access_token, ps2_rom.id).status_code == 200 + response = _containers(client, access_token) + session = response.json()["containers"][0]["session"] + assert session["rom_name"] == ps2_rom.name + assert session["desktop"] is False + assert session["username"] == "test_admin" + + +def test_containers_is_admin_only(client, viewer_access_token): + with _streaming(_webstation()): + assert _containers(client, viewer_access_token).status_code == 403 + + +def test_desktop_claims_the_named_container(client, access_token): + """The landing URL activate returns is resolved against the stream host, + the same way a game claim resolves its room URL.""" + with _streaming(_webstation()): + key = _key_of(_first_container("ps2")) + response, activate = _desktop(client, access_token, key) + assert response.status_code == 200 + body = response.json() + assert body["container"] == key + assert body["host"] == "http://192.168.1.10:3000/streaming/room/abc" + assert activate.call_args.kwargs["emulator"] == "desktop" + # No ROM: the broker registers the desktop with requires_rom False, and + # sending one would make exit try to sync saves that do not exist. + assert "rom" not in activate.call_args.kwargs + + +def test_desktop_and_a_game_block_each_other(client, access_token): + """Both claim the same key, which is the point: only one thing can drive + the container's display.""" + ps2_rom = _rom_on("ps2") + with _streaming(_webstation()): + key = _key_of(_first_container("ps2")) + assert _desktop(client, access_token, key)[0].status_code == 200 + assert _claim_webstation_ok(client, access_token, ps2_rom.id).status_code == 409 + + asyncio.run(async_cache.flushall()) + + with _streaming(_webstation()): + key = _key_of(_first_container("ps2")) + assert _claim_webstation_ok(client, access_token, ps2_rom.id).status_code == 200 + assert _desktop(client, access_token, key)[0].status_code == 409 + + +def test_desktop_is_admin_only(client, viewer_access_token): + with _streaming(_webstation()): + key = _key_of(_first_container("ps2")) + response, _ = _desktop(client, viewer_access_token, key) + assert response.status_code == 403 + + +def test_desktop_404s_on_a_container_that_is_not_configured(client, access_token): + with _streaming(_webstation()): + response, _ = _desktop(client, access_token, "http://192.168.9.9:8000") + assert response.status_code == 404 + + +def test_desktop_rejects_a_container_without_a_webstation_broker(client, access_token): + """The per-emulator mods have no activate route to ask for a desktop.""" + with _streaming(_nested()): + key = _key_of(_first_container("ps2")) + response, activate = _desktop(client, access_token, key) + assert response.status_code == 400 + activate.assert_not_called() + + +def test_desktop_frees_the_claim_when_activation_fails(client, access_token): + """A wedged claim would lock the container out until the TTL expires.""" + with _streaming(_webstation()): + container = _first_container("ps2") + key = _key_of(container) + with patch( + "endpoints.streaming._webstation_activate", + side_effect=HTTPException(status_code=503, detail="down"), + ): + response = client.post( + "/api/streaming/desktop", + json={"container": key}, + headers=_auth(access_token), + ) + assert response.status_code == 503 + assert _session_raw(container) is None + + +def test_releasing_a_desktop_session_syncs_nothing_to_the_library(client, access_token): + """No ROM means no saves, no states and no playtime to credit, so teardown + must stop at stopping the emulator.""" + with _streaming(_webstation()): + container = _first_container("ps2") + assert _desktop(client, access_token, _key_of(container))[0].status_code == 200 + with ( + patch("endpoints.streaming._stop_broker", return_value=None) as stop, + patch("endpoints.streaming._spawn_sync_task") as spawn, + ): + response = client.delete( + f"/api/streaming/sessions/{container['platform']}", + headers=_auth(access_token), + ) + assert response.status_code == 200 + stop.assert_called_once() + spawn.assert_not_called() + assert _session_raw(container) is None + + +def test_the_admin_session_list_flags_a_desktop(client, access_token): + """rom_name is null on a desktop session, so the list has to say what it + is rather than leaving the row blank.""" + with _streaming(_webstation()): + key = _key_of(_first_container("ps2")) + assert _desktop(client, access_token, key)[0].status_code == 200 + response = client.get("/api/streaming/sessions", headers=_auth(access_token)) + session = response.json()["sessions"][0] + assert session["desktop"] is True + assert session["rom_name"] is None + + +def test_webstation_capabilities_match_the_platform_table(client, access_token): + """The webstation broker serves the same slots and the same whole-card route + as the per-emulator ps2 mod, so nothing about it is special-cased.""" + with _streaming(_webstation()): + response = client.get("/api/streaming/config", headers=_auth(access_token)) + assert response.status_code == 200 + rows = {c["platform"]: c["capabilities"] for c in response.json()["containers"]} + assert rows["ps2"] == streaming.platform_capabilities("ps2") + + +def _webstation_ps2(): + """The resolved ps2 entry of a webstation container, as the routes see it.""" + with _streaming(_webstation()): + return _first_container("ps2") + + +def _webstation_json(body: dict): + """urlopen stub answering one webstation broker call with `body`.""" + resp = MagicMock() + resp.__enter__.return_value.read.side_effect = _reads(json.dumps(body).encode()) + return resp + + +def test_webstation_save_state_posts_under_the_subfolder(): + """The state routes live behind SUBFOLDER like the rest of the protocol, + not at the bare paths the per-emulator mods serve.""" + container = _webstation_ps2() + with patch( + "endpoints.streaming.urllib.request.urlopen", + return_value=_webstation_json({"status": "saved", "slot": 10, "saved": True}), + ) as urlopen: + assert streaming._save_state_broker(container, 10) is True + assert urlopen.call_args.args[0].full_url.endswith( + "/streaming/api/session/save-state" + ) + + +def test_webstation_save_state_reports_a_refused_save(): + """The broker answers 200 with saved false when the emulator never acked, + so the status field is what decides, not the HTTP code.""" + container = _webstation_ps2() + with patch( + "endpoints.streaming.urllib.request.urlopen", + return_value=_webstation_json({"status": "failed", "slot": 10, "saved": False}), + ): + assert streaming._save_state_broker(container, 10) is False + + +def test_webstation_load_state_posts_under_the_subfolder(): + container = _webstation_ps2() + with patch( + "endpoints.streaming.urllib.request.urlopen", + return_value=_webstation_json({"status": "loaded", "slot": 3, "loaded": True}), + ) as urlopen: + assert streaming._load_state_broker(container, 3) is True + assert urlopen.call_args.args[0].full_url.endswith( + "/streaming/api/session/load-state" + ) + + +def test_webstation_load_state_reports_an_empty_slot(): + """Loading a slot that holds no state file is a failed load, not an error.""" + container = _webstation_ps2() + with patch( + "endpoints.streaming.urllib.request.urlopen", + return_value=_webstation_json({"status": "failed", "slot": 3, "loaded": False}), + ): + assert streaming._load_state_broker(container, 3) is False + + +def test_webstation_swap_disc_posts_under_the_subfolder(): + container = _webstation_ps2() + with patch( + "endpoints.streaming.urllib.request.urlopen", + return_value=_webstation_json({"status": "ok", "path": "/library/disc2.chd"}), + ) as urlopen: + assert streaming._swap_disc_broker(container, "/library/disc2.chd") is True + assert urlopen.call_args.args[0].full_url.endswith( + "/streaming/api/session/swap-disc" + ) + assert json.loads(urlopen.call_args.args[0].data) == {"path": "/library/disc2.chd"} + + +def test_webstation_swap_disc_reports_a_broker_refusal(): + """A non-ok status (a bad path, no live session, an unsupported core) is a + failed swap, not an error.""" + container = _webstation_ps2() + with patch( + "endpoints.streaming.urllib.request.urlopen", + return_value=_webstation_json({"status": "error", "detail": "no session"}), + ): + assert streaming._swap_disc_broker(container, "/library/disc2.chd") is False + + +def test_swap_disc_broker_has_nothing_to_call_on_a_legacy_container(): + """Only the webstation broker speaks the tray protocol; the per-emulator + brokers this replaced never learned it.""" + container = _container_for(_rom_on("dc"), broker_host="http://192.168.1.10:8000") + with patch("endpoints.streaming.urllib.request.urlopen") as urlopen: + assert streaming._swap_disc_broker(container, "/library/disc2.chd") is False + urlopen.assert_not_called() + + +# ── Staleness / heartbeat ───────────────────────────────────────────────────── + + +def _age_session_on(container: dict, seconds: int) -> None: + """Rewrite one container's stored session last_seen to `seconds` ago.""" + key = streaming._session_redis_key(streaming._container_key(container)) + 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 _age_session(rom: Rom, seconds: int) -> None: + _age_session_on(_container_for(rom), seconds) + + +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", return_value=None + ) 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_takeover_leaves_the_displaced_owner_a_notice( + client, access_token, viewer_access_token, rom: Rom +): + """Their tab is still showing the stream. Without the note the picture just + stops with nothing to explain it.""" + container = _container_for(rom) + with _streaming(container): + _claim_ok(client, access_token, rom.id) + owner = json.loads( + asyncio.run( + async_cache.get( + streaming._session_redis_key(streaming._container_key(container)) + ) + ) + )["user_id"] + _age_session(rom, streaming._SESSION_STALE_SECONDS + 60) + with patch("endpoints.streaming._stop_broker", return_value=None): + _claim_ok(client, viewer_access_token, rom.id) + + notice = asyncio.run( + streaming._get_termination(streaming._container_key(container), owner) + ) + + assert notice is not None + assert notice["reason"] == "abandoned" + assert notice["rom_id"] == rom.id + + +def test_takeover_aborts_when_the_owner_comes_back_first( + client, access_token, viewer_access_token, rom: Rom +): + """The staleness check is older than the teardown it triggers. Re-checking + under the marker is what stops a returning owner's container being wiped.""" + container = _container_for(rom) + with _streaming(container): + _claim_ok(client, access_token, rom.id) + _age_session(rom, streaming._SESSION_STALE_SECONDS + 60) + + real_stale = streaming._session_is_stale + checked = False + + def stale_then_fresh(session): + # Stale for the scan that picks the candidate, fresh by the time + # the teardown re-checks it, as if a heartbeat landed in between. + nonlocal checked + if checked: + return False + checked = True + return real_stale(session) + + with patch.object(streaming, "_session_is_stale", stale_then_fresh): + with patch( + "endpoints.streaming._stop_broker", return_value=None + ) as stop_broker: + r = _claim_ok(client, viewer_access_token, rom.id) + + assert r.status_code == 409 + stop_broker.assert_not_called() + + +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", return_value=None + ) 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( - "/api/streaming/sessions/ngc/save-state", - json={"slot": 8}, + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert r.json()["status"] == "active" + 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_racing_a_teardown_reports_ended(client, access_token, rom: Rom): + """The refresh finds nothing when the claim was released between the lookup + and the write; answering "active" there would leave the client beating a + session it no longer holds.""" + container = _container_for(rom) + with _streaming(container): + _claim_ok(client, access_token, rom.id) + key = streaming._session_redis_key(streaming._container_key(container)) + + real_find = streaming._find_session_for_user + + async def find_then_drop(*args, **kwargs): + found = await real_find(*args, **kwargs) + await async_cache.delete(key) + return found + + with patch.object(streaming, "_find_session_for_user", find_then_drop): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert r.json()["status"] == "ended" + + +def test_heartbeat_does_not_revive_a_draining_session(client, access_token, rom: Rom): + """A container being torn down must not be made to look live again: the + emulator is already stopped and its card evacuated.""" + container = _container_for(rom) + with _streaming(container): + _claim_ok(client, access_token, rom.id) + key = streaming._session_redis_key(streaming._container_key(container)) + session = json.loads(asyncio.run(async_cache.get(key))) + session["draining"] = True + asyncio.run(async_cache.set(key, json.dumps(session))) + + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert r.json()["status"] == "ended" + + +def test_heartbeat_keeps_a_disc_swap_that_landed_first(client, access_token, rom: Rom): + """Heartbeat and swap rewrite the same session blob. Writing back the copy + read at the start of the request would drop the disc the swap just set.""" + container = _container_for(rom) + with _streaming(container): + _claim_ok(client, access_token, rom.id) + session_key = streaming._container_key(container) + key = streaming._session_redis_key(session_key) + + real_find = streaming._find_session_for_user + + async def find_then_swap(*args, **kwargs): + # The swap lands after the heartbeat read its copy of the session. + found = await real_find(*args, **kwargs) + await streaming._set_session_disc(session_key, 4242) + return found + + with patch.object(streaming, "_find_session_for_user", find_then_swap): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(access_token), + ) + session = json.loads(asyncio.run(async_cache.get(key))) + + assert r.json()["status"] == "active" + assert session["disc_file_id"] == 4242 + assert not streaming._session_is_stale(session) + + +def test_heartbeat_without_session_reports_ended(client, access_token, rom: Rom): + """No session at all still answers 200/ended: the poll is how a player + learns their stream is gone, so it must not look like a route error.""" + with _streaming(_container_for(rom)): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", headers=_auth(access_token), ) - assert r.status_code == 422 + assert r.status_code == 200 + assert r.json()["status"] == "ended" + assert r.json()["termination"] is None + + +def test_heartbeat_by_other_user_reports_ended( + client, access_token, viewer_access_token, rom: Rom +): + """A non-owner's heartbeat must not refresh or 403 the session; it just + reports that the caller does not hold it.""" + 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 == 200 + assert r.json()["status"] == "ended" + + +def test_heartbeat_for_unknown_platform_returns_404(client, access_token, rom: Rom): + with _streaming(_container_for(rom)): + r = client.post( + "/api/streaming/sessions/not-a-platform/heartbeat", + headers=_auth(access_token), + ) + assert r.status_code == 404 + + +# ── Session status / termination notices ────────────────────────────────────── + + +def test_status_reports_active_for_owner(client, access_token, rom: Rom): + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + r = client.get( + f"/api/streaming/sessions/{rom.platform_slug}/status", + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert r.json() == {"status": "active", "platform": rom.platform_slug} + + +def test_status_does_not_refresh_the_session(client, access_token, rom: Rom): + """Status is read-only: polling it must not extend a claim, otherwise a + background tab could keep a container hostage without playing.""" + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + _age_session(rom, streaming._SESSION_STALE_SECONDS + 60) + client.get( + f"/api/streaming/sessions/{rom.platform_slug}/status", + headers=_auth(access_token), + ) + key = streaming._session_redis_key(streaming._container_key(_container_for(rom))) + session = json.loads(asyncio.run(async_cache.get(key))) + assert streaming._session_is_stale(session) + + +def test_admin_release_leaves_termination_notice( + client, access_token, viewer_access_token, rom: Rom +): + """The displaced player's next poll must name who ended the session and + why, since nothing about the dead stream itself says so.""" + with _streaming(_container_for(rom)): + _claim_ok(client, viewer_access_token, rom.id) + released = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + params={"reason": "maintenance window"}, + headers=_auth(access_token), + ) + assert released.status_code == 200 + r = client.get( + f"/api/streaming/sessions/{rom.platform_slug}/status", + headers=_auth(viewer_access_token), + ) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ended" + assert body["termination"]["reason"] == "maintenance window" + assert body["termination"]["ended_by"] + + +def test_heartbeat_carries_termination_notice( + client, access_token, viewer_access_token, rom: Rom +): + """The heartbeat is the poll a player is already making, so it must carry + the same notice as the status route: that is the path a force-released + browser actually learns the reason on.""" + with _streaming(_container_for(rom)): + _claim_ok(client, viewer_access_token, rom.id) + client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + params={"reason": "patching the host"}, + headers=_auth(access_token), + ) + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(viewer_access_token), + ) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ended" + assert body["termination"]["reason"] == "patching the host" + + +def test_force_release_all_leaves_termination_notice( + client, access_token, viewer_access_token, rom: Rom +): + """The sweep is the other admin path out of a session, so it must leave the + same notice as the platform-keyed release.""" + with _streaming(_container_for(rom)): + _claim_ok(client, viewer_access_token, rom.id) + swept = client.delete( + "/api/streaming/sessions", + params={"reason": "server restart"}, + headers=_auth(access_token), + ) + assert swept.status_code == 200 + r = client.get( + f"/api/streaming/sessions/{rom.platform_slug}/status", + headers=_auth(viewer_access_token), + ) + assert r.json()["termination"]["reason"] == "server restart" + + +def test_self_release_leaves_no_termination_notice(client, access_token, rom: Rom): + """A user who closed their own session already knows why it stopped. The + player's own release path sends no reason, which is what marks it as such.""" + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + headers=_auth(access_token), + ) + r = client.get( + f"/api/streaming/sessions/{rom.platform_slug}/status", + headers=_auth(access_token), + ) + assert r.json()["termination"] is None + + +def test_admin_release_of_own_session_leaves_notice(client, access_token, rom: Rom): + """An admin can be logged in as the account that is playing in another tab, + so a panel release must still notify: only that path sends the reason + param, which is how it is told apart from the player closing their game.""" + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + params={"reason": "clearing the container"}, + headers=_auth(access_token), + ) + r = client.get( + f"/api/streaming/sessions/{rom.platform_slug}/status", + headers=_auth(access_token), + ) + body = r.json() + assert body["status"] == "ended" + assert body["termination"]["reason"] == "clearing the container" + assert body["termination"]["ended_by"] + + +def test_admin_release_with_blank_reason_still_names_the_admin( + client, access_token, viewer_access_token, rom: Rom +): + """The panel sends the param even when the field is left empty, so the + player is still told who ended it.""" + with _streaming(_container_for(rom)): + _claim_ok(client, viewer_access_token, rom.id) + client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + params={"reason": ""}, + headers=_auth(access_token), + ) + r = client.get( + f"/api/streaming/sessions/{rom.platform_slug}/status", + headers=_auth(viewer_access_token), + ) + body = r.json() + assert body["termination"]["ended_by"] + assert body["termination"]["reason"] is None + + +def test_reclaim_clears_termination_notice( + client, access_token, viewer_access_token, rom: Rom +): + """Once the player is back in a session the old notice is spent, so a + later poll must not resurface it.""" + with _streaming(_container_for(rom)): + _claim_ok(client, viewer_access_token, rom.id) + client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + params={"reason": "maintenance window"}, + headers=_auth(access_token), + ) + _claim_ok(client, viewer_access_token, rom.id) + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/heartbeat", + headers=_auth(viewer_access_token), + ) + assert r.json()["status"] == "active" + + +# ── Release / ownership ─────────────────────────────────────────────────────── + + +def test_release_uses_container_key_not_platform(client, access_token, rom: Rom): + """release_session must find the session by broker_host, not platform string.""" + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + with patch("endpoints.streaming._stop_broker", return_value=None): + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert r.json()["status"] == "released" + + +def test_release_by_other_user_is_forbidden( + client, access_token, viewer_access_token, rom: Rom +): + """A session claimed by one user cannot be released by another non-admin.""" + with _streaming(_container_for(rom)): + # viewer claims the session; admin could override, a viewer cannot + r_claim = _claim_ok(client, access_token, rom.id) + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + headers=_auth(viewer_access_token), + ) + assert r_claim.status_code == 200 + assert r.status_code == 403 + + +def test_save_state_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}/save-state", + json={"slot": 1}, + headers=_auth(viewer_access_token), + ) + assert r.status_code == 403 + + +async def _run_spawned(tasks: list) -> None: + """Run what the route handed to the mocked _spawn_sync_task.""" + for task in tasks: + if asyncio.iscoroutine(task): + await task + + +def test_save_and_exit_releases_session_once_the_state_is_pulled( + client, access_token, rom: Rom +): + """The broker keeps the exited session's state only until the next + activate, so the claim holds while the pull runs and goes when it lands.""" + spawned: list = [] + 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._pull_state_to_library", new=AsyncMock()), + # 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_saves_to_library", new=MagicMock()), + patch("endpoints.streaming._spawn_sync_task", side_effect=spawned.append), + ): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", + json={"slot": 0, "wait": True}, + headers=_auth(access_token), + ) + # Still held: the state has not come back out of the container yet. + held = _claim_ok(client, access_token, rom.id) + asyncio.run(_run_spawned(spawned)) + r2 = _claim_ok(client, access_token, rom.id) + assert r.status_code == 200 + assert r.json()["saved"] is True + assert held.status_code == 409 + 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": 0, "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_save_and_exit_rejects_a_slot_the_platform_lacks(client, access_token): + """The exit save writes to a slot like any other save, so a slot the + platform does not expose is refused here too rather than at the broker.""" + rom = _rom_on("ngc") + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + with patch( + "endpoints.streaming._save_and_exit_broker", return_value=(True, 9) + ) as broker: + r = client.post( + "/api/streaming/sessions/ngc/save-and-exit", + json={"slot": 9, "wait": True}, + headers=_auth(access_token), + ) + assert r.status_code == 422 + broker.assert_not_called() + + +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", return_value=None + ) 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's slots stop at the autosave (8); slot 9 clears the coarse union + bound (<=10) but must be rejected against the platform's real ceiling.""" + rom = _rom_on("ngc") + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + r = client.post( + "/api/streaming/sessions/ngc/save-state", + json={"slot": 9}, + headers=_auth(access_token), + ) + assert r.status_code == 422 + + +def test_save_state_allows_platform_autosave_slot(client, access_token): + """The player writes through the autosave slot, so it is a valid target.""" + rom = _rom_on("ngc") + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + with patch("endpoints.streaming._save_state_broker", return_value=True): + r = client.post( + "/api/streaming/sessions/ngc/save-state", + json={"slot": 8}, + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert r.json()["slot"] == 8 + + +def test_load_state_allows_platform_autosave_slot(client, access_token): + """Dolphin's slot 8 is not manually savable but is loadable as the autosave.""" + rom = _rom_on("wii") + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + with patch("endpoints.streaming._load_state_broker", return_value=True): + r = client.post( + "/api/streaming/sessions/wii/load-state", + json={"slot": 8}, + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert r.json()["loaded"] is True + + +def test_load_state_rejects_slot_between_max_and_autosave(client, access_token): + """Dolphin: slot 9 is neither a manual slot (1-7) nor the autosave (8).""" + rom = _rom_on("wiiu") + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + r = client.post( + "/api/streaming/sessions/wiiu/load-state", + json={"slot": 9}, + headers=_auth(access_token), + ) + assert r.status_code == 422 + + +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_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": 0, "wait": True}, + headers=_auth(access_token), + ) + assert r.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_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, 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": 0, "wait": True}, + headers=_auth(access_token), + ) + assert r.status_code == 200 + state_pull.assert_not_called() + save_pull.assert_called_once() + spawn.assert_called_once() + + +def test_save_and_exit_holds_the_container_until_the_state_is_pulled( + client, access_token, rom: Rom +): + """A session with a rom always has an exit state to collect, so the key is + replaced by the marker that guards the pull rather than deleted: a re-claim + landing first would let the container overwrite the state on its next + launch. The marker is refreshed while the pull runs, so it only has to + outlive one refresh interval.""" + 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._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}, + headers=_auth(access_token), + ) + # The drain key briefly holds the container. + r2 = _claim_ok(client, access_token, rom.id) + assert r.status_code == 200 + # Re-claim during the drain window is rejected (409), not accepted (200). + assert r2.status_code == 409 + # Nobody holds the container, so the 409 has to say that rather than name a + # holder it does not have. + assert r2.json()["detail"]["draining"] is True + container = _container_for(rom) + key = streaming._session_redis_key(streaming._container_key(container)) + ttl = asyncio.run(async_cache.ttl(key)) + # Long enough that a refresh has room to land, short enough that a backend + # dying mid-pull does not park the container for the length of a transfer + # nobody is doing. + assert streaming._DRAIN_MARKER_TTL > 2 * streaming._DRAIN_MARKER_REFRESH + assert streaming.SESSION_DRAIN_SECONDS < ttl <= streaming._DRAIN_MARKER_TTL + + +def test_save_and_exit_without_a_rom_drains_only_briefly( + client, access_token, rom: Rom +): + """A session with no rom (a desktop) has no exit state to collect, so + wait=false leaves the short marker that keeps a new launch off a not-yet-dead + emulator, not the long one that guards a pull.""" + container = _container_for(rom) + key = streaming._session_redis_key(streaming._container_key(container)) + with _streaming(container): + _claim_ok(client, access_token, rom.id) + session = json.loads(asyncio.run(async_cache.get(key))) + session.pop("rom_id") + asyncio.run( + async_cache.set(key, json.dumps(session), ex=streaming.SESSION_TTL_SECONDS) + ) + with ( + patch("endpoints.streaming._save_and_exit_broker", return_value=(True, 10)), + patch("endpoints.streaming._spawn_sync_task") as spawn, + ): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", + json={"slot": 0, "wait": False}, + headers=_auth(access_token), + ) + assert r.status_code == 200 + spawn.assert_not_called() + ttl = asyncio.run(async_cache.ttl(key)) + assert 0 < ttl <= streaming.SESSION_DRAIN_SECONDS + + +def _session_at(key: str, **fields) -> dict: + """Put a session on the key and hand back the claim a route would hold.""" + session = {"user_id": 1, "claimed_at": "2026-01-01T00:00:00+00:00", **fields} + asyncio.run( + async_cache.set(key, json.dumps(session), ex=streaming.SESSION_TTL_SECONDS) + ) + return session + + +def test_drain_marker_is_not_claimed_over_a_takeover(): + """Save-and-exit blocks on the broker for as long as the emulator takes to + die, and a force-release plus a fresh claim fit in that window. The marker + would bury a session somebody is playing.""" + key = streaming._session_redis_key("cas-takeover") + claim = _session_at(key) + _session_at(key, claimed_at="2026-01-01T00:05:00+00:00") + try: + assert asyncio.run(streaming._claim_drain_marker("cas-takeover", claim)) is None + # The claim that took over is still there, untouched. + current = json.loads(asyncio.run(async_cache.get(key))) + assert current["claimed_at"] == "2026-01-01T00:05:00+00:00" + assert "draining" not in current + finally: + asyncio.run(async_cache.delete(key)) + + +def test_a_stale_drain_token_frees_nobody(): + """A pull that outlived its own marker must not release whoever holds the + container now.""" + key = streaming._session_redis_key("cas-stale-token") + token = asyncio.run( + streaming._claim_drain_marker("cas-stale-token", _session_at(key)) + ) + assert token is not None + try: + # The marker expired and a new claim took the container. + _session_at(key, claimed_at="2026-01-01T00:05:00+00:00") + asyncio.run(streaming._drop_drain_marker("cas-stale-token", token)) + assert asyncio.run(async_cache.get(key)) is not None + # The drain that owns the marker still clears it. + retaken = asyncio.run( + streaming._claim_drain_marker( + "cas-stale-token", + {"user_id": 1, "claimed_at": "2026-01-01T00:05:00+00:00"}, + ) + ) + assert retaken is not None + asyncio.run(streaming._drop_drain_marker("cas-stale-token", retaken)) + assert asyncio.run(async_cache.get(key)) is None + finally: + asyncio.run(async_cache.delete(key)) + + +def test_releasing_a_session_somebody_else_holds_reports_failure(): + """The container is not this claim's to give back, and a release that says + otherwise ends a session that had just begun.""" + key = streaming._session_redis_key("cas-release") + claim = _session_at(key) + _session_at(key, claimed_at="2026-01-01T00:05:00+00:00") + try: + assert ( + asyncio.run(streaming._release_own_session("cas-release", claim)) is False + ) + assert asyncio.run(async_cache.get(key)) is not None + finally: + asyncio.run(async_cache.delete(key)) + + +def test_a_write_that_lands_on_nothing_is_contention_not_success(): + """A key expiring between the WATCH and the EXEC does not abort the + transaction, so an xx write can report success having set nothing. Treating + that as a landed marker leaves the container held by a claim the caller has + already stopped.""" + + class _NoOpPipe: + async def watch(self, key): + return True + + async def get(self, key): + return json.dumps({"user_id": 1, "claimed_at": "x"}) + + def multi(self): + return None + + async def set(self, *args, **kwargs): + return None + + async def execute(self): + # What redis returns for a SET xx against a key that is gone. + return [None] + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + with patch.object(async_cache, "pipeline", lambda: _NoOpPipe()): + with pytest.raises(streaming._SessionContended): + asyncio.run( + streaming._claim_drain_marker( + "cas-noop", {"user_id": 1, "claimed_at": "x"} + ) + ) + + +def test_work_running_under_a_claim_keeps_it_off_the_stale_list(): + """The exit paths that could not write a drain marker run under the claim + itself, and the player whose heartbeat kept it fresh is gone: an unrefreshed + claim reads as abandoned and the next claimant tears the container down. The + refresh stops at the ceiling, so a wedged step cannot hold it forever.""" + key = streaming._session_redis_key("cas-hold-claim") + claim = _session_at(key, last_seen="2026-01-01T00:00:00+00:00") + + try: + # A zero ceiling ends the loop on the first pass, so exactly one refresh + # runs and the test never waits on a clock. + with ( + patch.object(streaming, "_CLAIM_REFRESH_SECONDS", 0), + patch.object(streaming, "_HOLD_CEILING_SECONDS", 0), + ): + asyncio.run(streaming._hold_session_claim("cas-hold-claim", claim)) + current = json.loads(asyncio.run(async_cache.get(key))) + assert current["last_seen"] != "2026-01-01T00:00:00+00:00" + assert not streaming._session_is_stale(current) + finally: + asyncio.run(async_cache.delete(key)) + + +def test_holding_a_claim_stops_once_it_is_somebody_else_s(): + """Refreshing past a takeover would keep another player's session alive on + a stamp nobody is producing.""" + key = streaming._session_redis_key("cas-hold-lost") + claim = _session_at(key) + _session_at(key, claimed_at="2026-01-01T00:05:00+00:00") + try: + with patch.object(streaming, "_CLAIM_REFRESH_SECONDS", 0): + # Returns rather than looping forever against a claim it lost. + asyncio.run( + asyncio.wait_for( + streaming._hold_session_claim("cas-hold-lost", claim), 5 + ) + ) + current = json.loads(asyncio.run(async_cache.get(key))) + assert current["claimed_at"] == "2026-01-01T00:05:00+00:00" + assert "last_seen" not in current + finally: + asyncio.run(async_cache.delete(key)) + + +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._fetch_state_screenshot", return_value=None), + 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() + # The library keeps every capture, so the stored name carries a stamp ahead of + # the slot token; the container-side name is recovered by dropping it. + stored_name = wf.await_args_list[0].kwargs["filename"] + assert re.fullmatch(r"Game\.\d{8}-\d{12}\.03\.p2s", stored_name) + assert streaming._container_state_filename(stored_name) == "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 db_state is not None + assert db_state.emulator == "pcsx2" + + +def test_pull_state_falls_back_to_broker_screenshot(rom: Rom, admin_user: User): + """Dolphin states embed no frame, so the pull takes the broker's capture.""" + container = {**_container_for(rom), "label": "Dolphin"} + scanned = _state_for(rom, admin_user, "Game.s03", "dolphin") + scanned_shot = Screenshot( + file_name="Game.s03.png", + file_name_no_tags="Game.s03", + file_name_no_ext="Game.s03", + file_extension="png", + file_path=f"{rom.platform_slug}/screenshots", + file_size_bytes=7, + ) + with ( + patch( + "endpoints.streaming._fetch_state_file", + return_value=("Game.s03", b"state-bytes"), + ), + patch( + "endpoints.streaming._fetch_state_screenshot", return_value=_PNG + ) as fetch_shot, + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()), + patch("endpoints.streaming.scan_state", new=AsyncMock(return_value=scanned)), + patch( + "endpoints.streaming.scan_screenshot", + new=AsyncMock(return_value=scanned_shot), + ) as scan_shot, + ): + ok = asyncio.run( + streaming._pull_state_to_library(admin_user.id, rom.id, container, 3) + ) + assert ok is True + fetch_shot.assert_called_once() + scan_shot.assert_awaited_once() + db_state = db_state_handler.get_state_by_filename( + user_id=admin_user.id, rom_id=rom.id, file_name="Game.s03" + ) + assert db_state is not None + assert db_state.screenshot is not None + + +def test_pull_state_prefers_browser_frame(rom: Rom, admin_user: User): + """A frame the browser grabbed off the canvas beats asking the broker.""" + container = {**_container_for(rom), "label": "Dolphin"} + scanned = _state_for(rom, admin_user, "Game.s04", "dolphin") + scanned_shot = Screenshot( + file_name="Game.s04.png", + file_name_no_tags="Game.s04", + file_name_no_ext="Game.s04", + file_extension="png", + file_path=f"{rom.platform_slug}/screenshots", + file_size_bytes=7, + ) + with ( + patch( + "endpoints.streaming._fetch_state_file", + return_value=("Game.s04", b"state-bytes"), + ), + patch( + "endpoints.streaming._take_state_frame", + new=AsyncMock(return_value=_PNG), + ), + patch("endpoints.streaming._fetch_state_screenshot") as fetch_shot, + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()), + patch("endpoints.streaming.scan_state", new=AsyncMock(return_value=scanned)), + patch( + "endpoints.streaming.scan_screenshot", + new=AsyncMock(return_value=scanned_shot), + ), + ): + ok = asyncio.run( + streaming._pull_state_to_library(admin_user.id, rom.id, container, 4) + ) + assert ok is True + fetch_shot.assert_not_called() + + +def test_state_frame_stashes_capture(client, access_token): + """The endpoint holds the frame for the save that follows it.""" + rom = _rom_on("ps2") + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + with patch("endpoints.streaming._stash_state_frame", new=AsyncMock()) as stash: + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/state-frame", + content=_PNG, + headers={**_auth(access_token), "Content-Type": "image/png"}, + ) + assert r.status_code == 200 + stash.assert_awaited_once() + assert stash.await_args_list[0].args[2] == _PNG + + +def test_state_frame_rejects_non_png(client, access_token): + """Only PNG survives the asset pipeline, so anything else is refused here.""" + rom = _rom_on("ps2") + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/state-frame", + content=b"GIF89a-not-a-png", + headers={**_auth(access_token), "Content-Type": "image/png"}, + ) + assert r.status_code == 400 + + +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 _add_state_at(rom: Rom, user: User, file_name: str, day: int) -> State: + """Add a state with an explicit updated_at, so history order is deterministic.""" + state = _state_for(rom, user, file_name, "pcsx2") + stored = db_state_handler.add_state(state) + db_state_handler.update_state( + stored.id, {"updated_at": datetime(2026, 1, day, tzinfo=timezone.utc)} + ) + return stored + + +def test_hydrate_skipped_when_resume_state_already_pushed(rom: Rom, admin_user: User): + """Every history entry collapses to the same container-side name, so pushing + anything here would overwrite the state the player picked to resume from.""" + db_state_handler.add_state(_state_for(rom, admin_user, "Game.01.p2s", "pcsx2")) + container = {**_container_for(rom), "label": "PCSX2"} + with 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, resume_pushed=True + ) + ) + assert pushed == 0 + push.assert_not_called() + + +def test_hydrate_pushes_newest_state_under_container_name(rom: Rom, admin_user: User): + """Only the newest capture is hydrated, and it lands under the unstamped name + the emulator expects on disk.""" + _add_state_at(rom, admin_user, "Game.20260101-000000000000.01.p2s", 1) + newest = _add_state_at(rom, admin_user, "Game.20260202-000000000000.01.p2s", 2) + container = {**_container_for(rom), "label": "PCSX2"} + with ( + # Both stamps collapse to the same destination name, so only the bytes + # say which source was read; a constant here would pass either way. + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(side_effect=lambda path: path.encode()), + ), + 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" + assert push.call_args[0][2] == newest.full_path.encode() + + +def test_pull_state_skips_capture_identical_to_previous(rom: Rom, admin_user: User): + """Saving twice without playing in between produces the same bytes, and the + duplicate must not take a history slot.""" + content = b"state-bytes" + existing = _state_for(rom, admin_user, "Game.20260101-000000000000.03.p2s", "pcsx2") + existing.file_size_bytes = len(content) + db_state_handler.add_state(existing) + container = {**_container_for(rom), "label": "PCSX2"} + with ( + patch( + "endpoints.streaming._fetch_state_file", + return_value=("Game.03.p2s", content), + ), + patch("endpoints.streaming._fetch_state_screenshot", return_value=None), + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(return_value=content), + ), + 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, 3) + ) + assert ok is True + wf.assert_not_awaited() + + +def test_prune_state_history_drops_oldest_past_limit(rom: Rom, admin_user: User): + """Once the retention limit is reached the oldest captures go, newest first + order preserved.""" + for day in range(1, 4): + _add_state_at(rom, admin_user, f"Game.2026010{day}-000000000000.01.p2s", day) + with ( + patch("endpoints.streaming.STREAMING_STATE_HISTORY_LIMIT", 2), + patch( + "endpoints.streaming.fs_asset_handler.remove_file", new=AsyncMock() + ) as remove, + ): + pruned = asyncio.run(streaming._prune_state_history(admin_user, rom, "pcsx2")) + assert pruned == 1 + remove.assert_awaited_once() + remaining = { + s.file_name + for s in db_state_handler.get_states(user_id=admin_user.id, rom_id=rom.id) + } + assert remaining == { + "Game.20260102-000000000000.01.p2s", + "Game.20260103-000000000000.01.p2s", + } + + +# _store_state_screenshot rejects anything without PNG magic, so fixtures that +# reach it need real header bytes rather than a stand-in string. +_PNG = b"\x89PNG\r\n\x1a\n" + b"pixels" + + +def _p2s_bytes(screenshot: bytes | None = _PNG) -> 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(_PNG)) == _PNG + + +def test_extract_state_screenshot_non_pcsx2_returns_none(): + # Dolphin states embed no frame; its broker serves one from /state-screenshot. + 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_state_transfer_limits_default_for_an_unlisted_emulator(): + assert ( + streaming._state_transfer_limits({"emulator": "pcsx2"}) + == streaming._DEFAULT_STATE_TRANSFER + ) + + +def test_state_transfer_limits_are_larger_for_xemu(): + """A xemu state is the whole Xbox hard disk, not a RAM snapshot.""" + default = streaming._DEFAULT_STATE_TRANSFER + xemu = streaming._state_transfer_limits({"emulator": "xemu"}) + assert xemu["max_bytes"] > default["max_bytes"] + # The ceiling is useless if the body cannot finish arriving inside it. + assert xemu["timeout"] > default["timeout"] + + +def test_fetch_state_file_reads_and_waits_to_the_emulator_limits(rom: Rom): + resp = MagicMock() + inner = resp.__enter__.return_value + inner.read.side_effect = _reads(b"state-bytes") + inner.headers = {"X-State-Filename": "game.xemu.state"} + container = dict(_container_for(rom), emulator="xemu") + + with patch( + "endpoints.streaming.urllib.request.urlopen", return_value=resp + ) as urlopen: + assert streaming._fetch_state_file(container, 1) == ( + "game.xemu.state", + b"state-bytes", + ) + + limits = streaming._STATE_TRANSFER_LIMITS["xemu"] + assert urlopen.call_args.kwargs["timeout"] == limits["timeout"] + # The read is chunked, but never asks for more in total than the ceiling it + # will accept, plus the one byte that proves the body overran it. + requested = sum(call.args[0] for call in inner.read.call_args_list) + assert requested <= limits["max_bytes"] + 1 + + +def test_push_state_file_waits_to_the_emulator_limits(rom: Rom): + resp = MagicMock() + resp.__enter__.return_value.read.side_effect = _reads(b'{"status": "ok"}') + container = dict(_container_for(rom), emulator="xemu") + + with patch( + "endpoints.streaming.urllib.request.urlopen", return_value=resp + ) as urlopen: + assert streaming._push_state_file(container, "game.xemu.state", b"bytes") + + assert ( + urlopen.call_args.kwargs["timeout"] + == streaming._STATE_TRANSFER_LIMITS["xemu"]["timeout"] + ) + + +def test_fetch_state_screenshot_returns_png(rom: Rom): + resp = MagicMock() + resp.__enter__.return_value.read.side_effect = _reads(_PNG) + with patch("endpoints.streaming.urllib.request.urlopen", return_value=resp): + assert streaming._fetch_state_screenshot(_container_for(rom), 1) == _PNG + + +def test_fetch_state_screenshot_404_returns_none(rom: Rom): + """A broker that captures no frames answers 404; that is not an error.""" + with patch( + "endpoints.streaming.urllib.request.urlopen", side_effect=_http_error(404) + ): + assert streaming._fetch_state_screenshot(_container_for(rom), 1) is None + + +def test_fetch_state_screenshot_transport_error_returns_none(rom: Rom): + import urllib.error + + with patch( + "endpoints.streaming.urllib.request.urlopen", + side_effect=urllib.error.URLError("broker down"), + ): + assert streaming._fetch_state_screenshot(_container_for(rom), 1) 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", _PNG) + ) + 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_screenshot_rejects_non_png(admin_user: User, rom: Rom): + """A broker error page must never be written out as a thumbnail.""" + db_state_handler.add_state(_state_for(rom, admin_user, "Game.05.p2s", "pcsx2")) + with ( + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()) as wf, + patch("endpoints.streaming.scan_screenshot", new=AsyncMock()) as scan, + ): + asyncio.run( + streaming._store_state_screenshot( + admin_user, rom, "Game.05.p2s", b"404" + ) + ) + wf.assert_not_awaited() + scan.assert_not_awaited() + state = db_state_handler.get_state_by_filename( + user_id=admin_user.id, rom_id=rom.id, file_name="Game.05.p2s" + ) + assert state.screenshot is None + + +def test_store_state_asset_binds_screenshot(admin_user: User, rom: Rom): + """End to end: storing a state with a frame binds it as the 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(_PNG), _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 state with no frame syncs 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 + + +def test_store_state_asset_collision_keeps_disc_file_id_in_sync( + admin_user: User, rom: Rom +): + """A same-second capture collides with the row already on disk instead of + adding a new one; the update must also refresh which disc it was captured + on, not just the file size.""" + disc = _add_rom_file(rom, "Game (Disc 2).chd") + when = datetime(2026, 1, 1, tzinfo=timezone.utc) + stamped = streaming._stamped_state_filename("pcsx2", "Game.03.p2s", when) + existing = db_state_handler.add_state(_state_for(rom, admin_user, stamped, "pcsx2")) + scanned_state = _state_for(rom, admin_user, stamped, "pcsx2") + scanned_state.file_size_bytes = 999 + 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.datetime") as mock_dt, + ): + mock_dt.now.return_value = when + asyncio.run( + streaming._store_state_asset( + admin_user, + rom, + "pcsx2", + "Game.03.p2s", + _p2s_bytes(None), + disc_file_id=disc.id, + ) + ) + updated = db_state_handler.get_state_by_id(existing.id) + assert updated.disc_file_id == disc.id + assert updated.file_size_bytes == 999 + + +# ── 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") + ) + newest = 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 ( + # Path-derived bytes, so the assertion below names which of the four + # saves was actually read rather than just that something was pushed. + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(side_effect=lambda path: path.encode()), + ), + 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 newest pcsx2 .zip, never the .mcr, the dolphin save, or the older zip. + assert push.call_args[0][1] == newest.full_path.encode() + + +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", return_value=None), + 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 + 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("xemu", "MechAssault (USA).x03") == 3 + assert streaming._slot_from_state_filename("xemu", "MechAssault (USA).x10") == 10 + assert ( + streaming._slot_from_state_filename("xemu", "MechAssault (USA).qcow2") is None + ) + # RetroArch leaves the number off its default slot, and unlike the others + # it really does work in slot 0, so an empty token resolves rather than + # reading as an unrecognizable name. + assert streaming._slot_from_state_filename("retroarch", "Game.state") == 0 + assert streaming._slot_from_state_filename("retroarch", "Game.state7") == 7 + assert streaming._slot_from_state_filename("retroarch", "Game.srm") is None + + +def test_stamped_state_filename_round_trips_for_xemu(): + when = datetime(2026, 7, 21, 4, 56, 45, 123456, tzinfo=timezone.utc) + stamped = streaming._stamped_state_filename("xemu", "MechAssault.x03", when) + assert re.fullmatch(r"MechAssault\.\d{8}-\d{12}\.x03", stamped) + assert streaming._container_state_filename(stamped) == "MechAssault.x03" + + +def test_stamped_state_filename_round_trips_for_retroarch(): + """The stamp goes before the slot token even when the token is empty, so + every capture is its own file and the container name is still recoverable.""" + when = datetime(2026, 7, 21, 4, 56, 45, 123456, tzinfo=timezone.utc) + stamped = streaming._stamped_state_filename("retroarch", "Super Mario.state", when) + assert re.fullmatch(r"Super Mario\.\d{8}-\d{12}\.state", stamped) + assert streaming._container_state_filename(stamped) == "Super Mario.state" + + +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["resume_pushed"] is True + + +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["resume_pushed"] is False + + +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 + + +# ── Webstation state sync ───────────────────────────────────────────────────── + + +def _webstation_for(rom: Rom) -> dict: + """The container a claim for this ROM's platform lands on, webstation side.""" + return {**_container_for(rom), "protocol": "webstation", "label": "PCSX2"} + + +def test_state_transfers_reach_the_webstation_broker_under_its_subfolder(rom: Rom): + """This broker answers behind a subfolder, so an unprefixed path would land + on the room's web server rather than on the broker.""" + container = _webstation_for(rom) + resp = MagicMock() + inner = resp.__enter__.return_value + inner.headers = {"X-State-Filename": "Game.03.p2s"} + + with patch( + "endpoints.streaming.urllib.request.urlopen", return_value=resp + ) as urlopen: + inner.read.side_effect = _reads(b"state-bytes") + streaming._fetch_state_file(container, 3) + inner.read.side_effect = _reads(_PNG) + streaming._fetch_state_screenshot(container, 3) + inner.read.side_effect = _reads(b'{"status": "ok"}') + streaming._push_state_file(container, "Game.03.p2s", b"bytes") + + root = "http://192.168.1.10:8000/streaming/api/session" + assert [call.args[0].full_url for call in urlopen.call_args_list] == [ + f"{root}/state-file?slot=3", + f"{root}/state-screenshot?slot=3", + f"{root}/state-file?filename=Game.03.p2s", + ] + + +def test_pull_state_to_library_runs_for_a_webstation_container( + rom: Rom, admin_user: User +): + """RomM is the library of states on this protocol too, so a save has to come + back out of the container rather than wait for the exit archive.""" + container = _webstation_for(rom) + 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._fetch_state_screenshot", return_value=None), + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()), + 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 + assert ( + db_state_handler.get_state_by_filename( + user_id=admin_user.id, rom_id=rom.id, file_name="Game.03.p2s" + ) + is not None + ) + + +def test_webstation_resume_state_is_pushed_after_activate( + client, access_token, rom: Rom, admin_user: User +): + """The state-file route only answers while a session is up, and the session + starts at activate, so pushing first would be refused. The broker's deferred + load waits for the file, which is what makes the later push still land.""" + state = db_state_handler.add_state( + _state_for(rom, admin_user, "Game.03.p2s", "pcsx2") + ) + order = MagicMock() + order.activate.return_value = {"url": "/room/x"} + order.push.return_value = True + with _streaming(_webstation_for(rom)): + with ( + patch("endpoints.streaming._webstation_activate", order.activate), + patch("endpoints.streaming._push_state_file", order.push), + patch( + "endpoints.streaming._hydrate_saves_to_webstation", + new=AsyncMock(return_value=None), + ), + 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()), + ): + r = _claim(client, access_token, rom.id, state_id=state.id) + assert r.status_code == 200 + assert r.json()["resume"] is True + assert [c[0] for c in order.mock_calls if c[0] in ("activate", "push")] == [ + "activate", + "push", + ] + assert order.activate.call_args.kwargs["resume_slot"] == 3 + assert order.push.call_args[0][1] == "Game.03.p2s" + + +def test_stopping_a_webstation_broker_reports_the_state_it_captured(rom: Rom): + """Stopping this broker is an exit and its exit saves, so the slot has to + come back out: the caller is the only one who can file that state.""" + container = _webstation_for(rom) + with patch( + "endpoints.streaming._webstation_exit", + return_value={"state_saved": True, "state_slot": 10}, + ): + assert streaming._stop_broker(container) == 10 + with patch( + "endpoints.streaming._webstation_exit", + return_value={"state_saved": False, "state_slot": 10}, + ): + assert streaming._stop_broker(container) is None + with patch("endpoints.streaming._webstation_exit", return_value=None): + assert streaming._stop_broker(container) is None + + +def test_stopping_without_saving_asks_the_broker_to_write_no_state(rom: Rom): + """A player leaving without saving must not have a state written for them, + and nothing comes back for the caller to file.""" + container = _webstation_for(rom) + with patch( + "endpoints.streaming._webstation_exit", + return_value={"state_saved": False, "state_slot": None}, + ) as exit_call: + assert streaming._stop_broker(container, save=False) is None + assert exit_call.call_args.kwargs["save"] is False + + +def test_a_webstation_exit_carries_slot_zero_rather_than_dropping_it(rom: Rom): + """Slot 0 is this broker's working slot, so it has to reach the request: + omitting it would silently fall back to the broker's own default.""" + container = _webstation_for(rom) + with patch("endpoints.streaming._broker_request_safe", return_value={}) as req: + streaming._webstation_exit(container, slot=0) + assert "slot=0" in req.call_args[0][1] + assert "save=0" not in req.call_args[0][1] + streaming._webstation_exit(container, slot=0, save=False) + assert "save=0" in req.call_args[0][1] + + +def test_stopping_a_legacy_broker_reports_no_state(rom: Rom): + """The per-emulator brokers stop without saving, so nothing is pulled.""" + with patch("endpoints.streaming._broker_request_safe", return_value={}): + assert streaming._stop_broker(_container_for(rom)) is None + + +def test_releasing_a_webstation_session_pulls_the_exit_state( + client, access_token, rom: Rom +): + """A player who closes the tab still exits the broker, and that exit takes a + state. Leaving it in the container is how the last minutes of a session got + lost whenever the save-and-exit button was not the way out.""" + pull = AsyncMock(return_value=True) + with _streaming(_webstation_for(rom)): + with ( + patch( + "endpoints.streaming._webstation_activate", + return_value={"url": "/room/x"}, + ), + patch( + "endpoints.streaming._hydrate_saves_to_webstation", + new=AsyncMock(return_value=None), + ), + patch("endpoints.streaming._hydrate_states_to_broker", new=MagicMock()), + patch("endpoints.streaming._spawn_sync_task"), + ): + _claim_ok(client, access_token, rom.id) + with ( + patch( + "endpoints.streaming._webstation_exit", + return_value={"state_saved": True, "state_slot": 10}, + ), + patch("endpoints.streaming._pull_state_to_library", pull), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + headers=_auth(access_token), + ) + assert r.status_code == 200 + pull.assert_awaited_once() + assert pull.await_args is not None + assert pull.await_args.args[1:] == (rom.id, _webstation_for(rom), 10) + + +def test_releasing_without_saving_files_no_state(client, access_token, rom: Rom): + """The stop button is the deliberate way out without saving, so the exit + writes nothing and there is no state to pull into the library.""" + pull = AsyncMock(return_value=True) + stop = MagicMock(return_value=None) + with _streaming(_webstation_for(rom)): + with ( + patch( + "endpoints.streaming._webstation_activate", + return_value={"url": "/room/x"}, + ), + patch( + "endpoints.streaming._hydrate_saves_to_webstation", + new=AsyncMock(return_value=None), + ), + patch("endpoints.streaming._hydrate_states_to_broker", new=MagicMock()), + patch("endpoints.streaming._spawn_sync_task"), + ): + _claim_ok(client, access_token, rom.id) + with ( + patch("endpoints.streaming._stop_broker", stop), + patch("endpoints.streaming._pull_state_to_library", pull), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}?save=false", + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert stop.call_args[0][1] is False + pull.assert_not_awaited() + + +# ── Auth guards ─────────────────────────────────────────────────────────────── + + +def test_claim_session_requires_auth(client): + assert client.post("/api/streaming/sessions", json={"rom_id": 1}).status_code == 401 + + +def test_release_session_requires_auth(client): + assert client.delete("/api/streaming/sessions/ps2").status_code == 401 + + +def test_force_release_all_requires_auth(client): + assert client.delete("/api/streaming/sessions").status_code == 401 + + +def test_list_sessions_requires_auth(client): + assert client.get("/api/streaming/sessions").status_code == 401 + + +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", return_value=None) 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, card_import=None): + body: dict = {"rom_id": rom_id} + if memory_card_id is not None: + body["memory_card_id"] = memory_card_id + if card_import is not None: + body["card_import"] = card_import + 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("utils.memory_cards.fs_asset_handler.write_file", new=AsyncMock()) as wf, + patch( + "utils.memory_cards.scan_memory_card_version", + new=AsyncMock(return_value=scanned), + ), + ): + stored = asyncio.run( + streaming.store_memory_card_version(admin_user, card, b"card-bytes") + ) + assert stored is not None + 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("utils.memory_cards.fs_asset_handler.write_file", new=AsyncMock()), + patch( + "utils.memory_cards.scan_memory_card_version", + new=AsyncMock(return_value=scanned), + ), + patch("utils.memory_cards.fs_asset_handler.remove_file", new=AsyncMock()) as rm, + ): + stored = asyncio.run( + streaming.store_memory_card_version(admin_user, card, b"card-bytes") + ) + assert stored is None + 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, _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, _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, _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.side_effect = _reads(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._fetch_memory_card", return_value=None), + 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._fetch_memory_card", return_value=None), + 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._fetch_memory_card", return_value=None), + 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._fetch_memory_card", return_value=None), + 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", return_value=None) 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_release_frees_the_claim_when_teardown_raises(client, access_token, rom: Rom): + """The API has already reported the release, so a step that blows up must + not leave the claim behind: the container would read occupied to everyone + else until stale takeover or the TTL expires.""" + container = _mc_container_for(rom) + with _streaming(container): + with ( + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._fetch_memory_card", return_value=None), + 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", return_value=None), + patch( + "endpoints.streaming._evacuate_session_card", + new=AsyncMock(side_effect=OSError("broker went away")), + ), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = client.delete( + f"/api/streaming/sessions/{rom.platform_slug}", + headers=_auth(access_token), + ) + assert r.status_code == 200 + assert ( + asyncio.run(streaming._get_session(streaming._container_key(container))) is None + ) + + +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._fetch_memory_card", return_value=None), + 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._fetch_memory_card", return_value=None), + 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") == [] + + +# ── First-claim card adoption ───────────────────────────────────────────────── + + +def _gci_card_bytes() -> bytes: + """A container card holding one GameCube save.""" + from tests._zipfile_shim import reload_zipfile + + reload_zipfile() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("01-GXCE-CustomRobo-BattleRevolution.gci", b"x" * 100) + return buf.getvalue() + + +@contextmanager +def _adoption_storage(card_bytes: bytes): + """Run the real store-then-hydrate round trip against stubbed disk I/O, so + the assertion is on what actually gets pushed back to the container.""" + + async def _scan(file_name, user, emulator, card_id): + return _card_version(card_id, file_name, "adopted-hash") + + with ( + patch("endpoints.streaming.fs_asset_handler.write_file", new=AsyncMock()), + patch( + "endpoints.streaming.fs_asset_handler.read_file", + new=AsyncMock(return_value=card_bytes), + ), + patch( + "utils.memory_cards.scan_memory_card_version", + new=AsyncMock(side_effect=_scan), + ), + ): + yield + + +def test_first_claim_with_existing_card_asks_before_wiping( + client, access_token, rom: Rom +): + """An unadopted container card must never be wiped without an answer.""" + with ( + _streaming(_mc_container_for(rom)), + patch("endpoints.streaming._fetch_memory_card", return_value=_gci_card_bytes()), + patch("endpoints.streaming._push_memory_card") as push, + patch("endpoints.streaming._call_broker") as launch, + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id) + assert r.status_code == 428 + detail = r.json()["detail"] + assert detail["code"] == "memory_card_import_required" + assert detail["outcome"] == "found" + assert detail["summary"]["game_codes"] == ["GXCE"] + push.assert_not_called() + launch.assert_not_called() + # The prompt is not a session: an abandoned dialog must leave no claim. + assert ( + asyncio.run( + streaming._get_session(streaming._container_key(_mc_container_for(rom))) + ) + is None + ) + + +def test_unreadable_card_blocks_the_claim(client, access_token, rom: Rom): + """A transport hiccup must not be read as an empty card.""" + with ( + _streaming(_mc_container_for(rom)), + patch( + "endpoints.streaming._fetch_memory_card", + side_effect=streaming._MemoryCardUnavailable("broker exploded"), + ), + patch("endpoints.streaming._push_memory_card") as push, + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id) + assert r.status_code == 428 + detail = r.json()["detail"] + assert detail["outcome"] == "unreadable" + # The broker host and port must not leak to the client. + assert "broker exploded" not in detail["reason"] + assert detail["reason"] == streaming._CARD_UNREADABLE_REASON + push.assert_not_called() + assert ( + asyncio.run( + streaming._get_session(streaming._container_key(_mc_container_for(rom))) + ) + is None + ) + + +def test_absent_card_claims_without_prompting(client, access_token, rom: Rom): + """A genuinely empty slot is not a decision, so do not interrupt the user.""" + container = _mc_container_for(rom) + with ( + _streaming(container), + patch("endpoints.streaming._fetch_memory_card", return_value=None), + patch("endpoints.streaming._push_memory_card", return_value=True), + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id) + assert r.status_code == 200 + # The absent answer is recorded too, so the probe never runs again here. + adoption = db_container_adoption_handler.get_adoption( + streaming._container_key(container) + ) + assert adoption is not None and adoption.outcome == "discard" + + +def test_decided_container_does_not_probe_again( + client, access_token, admin_user: User, rom: Rom +): + """After the one-time decision the claim path costs no broker round trip.""" + container = _mc_container_for(rom) + db_container_adoption_handler.add_adoption( + container_key=streaming._container_key(container), + outcome="discard", + user_id=admin_user.id, + ) + with ( + _streaming(container), + patch("endpoints.streaming._fetch_memory_card") as fetch, + patch("endpoints.streaming._push_memory_card", return_value=True), + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id) + assert r.status_code == 200 + fetch.assert_not_called() + + +def test_sync_disabled_container_does_not_probe(client, access_token, rom: Rom): + """Containers without whole-card sync are untouched by any of this.""" + with ( + _streaming(_container_for(rom)), + patch("endpoints.streaming._fetch_memory_card") as fetch, + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._hydrate_saves_to_broker", new=AsyncMock()), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id) + assert r.status_code == 200 + fetch.assert_not_called() + + +def test_adopt_stores_the_container_card_as_version_one( + client, access_token, admin_user: User, rom: Rom +): + """Adopting must establish a version before hydrate, or the wipe still wins.""" + card_bytes = _gci_card_bytes() + container = _mc_container_for(rom) + with ( + _streaming(container), + _adoption_storage(card_bytes), + patch("endpoints.streaming._fetch_memory_card", return_value=card_bytes), + patch("endpoints.streaming._push_memory_card", return_value=True) as push, + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id, card_import="adopt") + assert r.status_code == 200 + # The card pushed back down is the adopted one, not a blank. + assert push.call_args[0][1] == card_bytes + assert push.call_args[0][1] != streaming._EMPTY_MEMORY_CARD + adoption = db_container_adoption_handler.get_adoption( + streaming._container_key(container) + ) + assert adoption is not None and adoption.outcome == "adopt" + cards = db_memory_card_handler.get_cards(admin_user.id, "pcsx2") + assert len(cards) == 1 + assert db_memory_card_handler.get_latest_version(cards[0].id) is not None + + +def test_discard_wipes_and_records_the_decision(client, access_token, rom: Rom): + """Choosing fresh must be remembered, or the prompt returns every claim.""" + container = _mc_container_for(rom) + with ( + _streaming(container), + patch("endpoints.streaming._fetch_memory_card", return_value=_gci_card_bytes()), + patch("endpoints.streaming._push_memory_card", return_value=True) as push, + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id, card_import="discard") + assert r.status_code == 200 + assert push.call_args[0][1] == streaming._EMPTY_MEMORY_CARD + adoption = db_container_adoption_handler.get_adoption( + streaming._container_key(container) + ) + assert adoption is not None and adoption.outcome == "discard" + + +def test_unreadable_card_with_override_starts_fresh(client, access_token, rom: Rom): + """The escape hatch: the user accepted the wipe, so proceed to a blank.""" + container = _mc_container_for(rom) + with ( + _streaming(container), + patch( + "endpoints.streaming._fetch_memory_card", + side_effect=streaming._MemoryCardUnavailable("broker exploded"), + ), + patch("endpoints.streaming._push_memory_card", return_value=True) as push, + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id, card_import="discard") + assert r.status_code == 200 + assert push.call_args[0][1] == streaming._EMPTY_MEMORY_CARD + adoption = db_container_adoption_handler.get_adoption( + streaming._container_key(container) + ) + assert adoption is not None and adoption.outcome == "discard" + + +def test_failed_adopt_aborts_the_claim_without_wiping( + client, access_token, admin_user: User, rom: Rom +): + """If the import cannot be stored, hydrate must never get to wipe the card.""" + container = _mc_container_for(rom) + with ( + _streaming(container), + patch("endpoints.streaming._fetch_memory_card", return_value=_gci_card_bytes()), + patch( + "endpoints.streaming.store_memory_card_version", + new=AsyncMock(side_effect=OSError("disk full")), + ), + patch("endpoints.streaming._push_memory_card", return_value=True) as push, + patch("endpoints.streaming._call_broker") as launch, + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id, card_import="adopt") + assert r.status_code == 502 + push.assert_not_called() + launch.assert_not_called() + # Nothing is recorded, so the next claim asks again instead of wiping. + assert ( + db_container_adoption_handler.get_adoption(streaming._container_key(container)) + is None + ) + assert db_memory_card_handler.get_cards(admin_user.id, "pcsx2") == [] + assert ( + asyncio.run(streaming._get_session(streaming._container_key(container))) is None + ) + + +def test_adopt_retry_recovers_when_the_version_was_already_stored( + client, access_token, admin_user: User, rom: Rom +): + """A claim that stored the version but died before recording the decision + must not wedge. The retry reads the same container card, dedup refuses a + second copy, and that is the idempotent case: hydrate would push back the + very bytes already on the container, so record the decision and continue. + """ + card_bytes = _gci_card_bytes() + container = _mc_container_for(rom) + card = _make_card(admin_user) + db_memory_card_handler.add_version( + _card_version( + card.id, + "My PS2 card [stored].card.zip", + streaming.content_hash_of_bytes(card_bytes), + ) + ) + with ( + _streaming(container), + _adoption_storage(card_bytes), + patch("endpoints.streaming._fetch_memory_card", return_value=card_bytes), + patch("endpoints.streaming._push_memory_card", return_value=True), + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id, card_import="adopt") + assert r.status_code == 200 + adoption = db_container_adoption_handler.get_adoption( + streaming._container_key(container) + ) + assert adoption is not None and adoption.outcome == "adopt" + # Dedup still holds: the retry adds no second copy of the same content. + assert len(db_memory_card_handler.get_versions(card.id)) == 1 + + +def test_adopt_aborts_when_dedup_matches_an_older_version( + client, access_token, admin_user: User, rom: Rom +): + """A match against a version that is NOT the latest still has to abort: + hydrate would push the newer version over the card asked to be kept.""" + card_bytes = _gci_card_bytes() + container = _mc_container_for(rom) + card = _make_card(admin_user) + older = _card_version( + card.id, + "My PS2 card [old].card.zip", + streaming.content_hash_of_bytes(card_bytes), + ) + older.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + newer = _card_version(card.id, "My PS2 card [newer].card.zip", "newer-hash") + newer.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc) + db_memory_card_handler.add_version(older) + db_memory_card_handler.add_version(newer) + with ( + _streaming(container), + _adoption_storage(card_bytes), + patch("endpoints.streaming._fetch_memory_card", return_value=card_bytes), + patch("endpoints.streaming._push_memory_card", return_value=True) as push, + patch("endpoints.streaming._call_broker") as launch, + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim( + client, access_token, rom.id, memory_card_id=card.id, card_import="adopt" + ) + assert r.status_code == 502 + push.assert_not_called() + launch.assert_not_called() + assert ( + db_container_adoption_handler.get_adoption(streaming._container_key(container)) + is None + ) + assert ( + asyncio.run(streaming._get_session(streaming._container_key(container))) is None + ) + + +def test_adopt_with_unreadable_card_aborts_without_recording( + client, access_token, admin_user: User, rom: Rom +): + """ "Keep this card" on a card that cannot be read must never wipe it: only + an explicit discard may override an unreadable card.""" + container = _mc_container_for(rom) + with ( + _streaming(container), + patch( + "endpoints.streaming._fetch_memory_card", + side_effect=streaming._MemoryCardUnavailable("broker exploded"), + ), + patch("endpoints.streaming._push_memory_card", return_value=True) as push, + patch("endpoints.streaming._call_broker") as launch, + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id, card_import="adopt") + assert r.status_code == 502 + push.assert_not_called() + launch.assert_not_called() + # No decision recorded, so the next claim asks again instead of wiping. + assert ( + db_container_adoption_handler.get_adoption(streaming._container_key(container)) + is None + ) + assert db_memory_card_handler.get_cards(admin_user.id, "pcsx2") == [] + assert ( + asyncio.run(streaming._get_session(streaming._container_key(container))) is None + ) + + +def test_adopt_with_absent_card_aborts_without_recording( + client, access_token, admin_user: User, rom: Rom +): + """The card vanished between the prompt and the answer, so the import the + user asked for cannot happen. Say so instead of starting on a blank.""" + container = _mc_container_for(rom) + with ( + _streaming(container), + patch("endpoints.streaming._fetch_memory_card", return_value=None), + patch("endpoints.streaming._push_memory_card", return_value=True) as push, + patch("endpoints.streaming._call_broker") as launch, + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id, card_import="adopt") + assert r.status_code == 502 + push.assert_not_called() + launch.assert_not_called() + assert ( + db_container_adoption_handler.get_adoption(streaming._container_key(container)) + is None + ) + assert db_memory_card_handler.get_cards(admin_user.id, "pcsx2") == [] + assert ( + asyncio.run(streaming._get_session(streaming._container_key(container))) is None + ) + + +def test_occupied_undecided_container_returns_409_not_428( + client, viewer_access_token, admin_user: User, rom: Rom +): + """The probe belongs to the claim winner: a second player must not be shown + a prompt describing the card of whoever is playing right now.""" + container = _mc_container_for(rom) + key = streaming._session_redis_key(streaming._container_key(container)) + asyncio.run( + async_cache.set( + key, + json.dumps( + { + "rom_id": rom.id, + "rom_name": rom.name, + "platform": rom.platform_slug, + "claimed_at": datetime.now(timezone.utc).isoformat(), + "last_seen": datetime.now(timezone.utc).isoformat(), + "user_id": admin_user.id, + "memory_card_id": None, + } + ), + ) + ) + with ( + _streaming(container), + patch( + "endpoints.streaming._fetch_memory_card", return_value=_gci_card_bytes() + ) as fetch, + patch("endpoints.streaming._push_memory_card") as push, + patch("endpoints.streaming._call_broker") as launch, + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, viewer_access_token, rom.id) + assert r.status_code == 409 + fetch.assert_not_called() + push.assert_not_called() + launch.assert_not_called() + + +def _mc_webstation_for(rom: Rom): + """A webstation container on whole-card sync, serving ps2 through pcsx2.""" + return {**_mc_container_for(rom), "protocol": "webstation", "subfolder": "/stream"} + + +def test_memory_card_route_names_the_emulator_on_a_webstation_container(rom: Rom): + """One webstation container hosts several emulators, so the card it serves + has to be named; the per-emulator brokers serve the one card they have.""" + with _streaming(_mc_webstation_for(rom)): + nested = _first_container(rom.platform_slug) + with _streaming(_mc_container_for(rom)): + flat = _first_container(rom.platform_slug) + assert ( + streaming._memory_card_route(nested) + == "/stream/api/session/memory-card?emulator=pcsx2" + ) + assert streaming._memory_card_route(flat) == "/memory-card" + + +def test_webstation_claim_hydrates_the_card_and_the_states( + client, access_token, rom: Rom +): + """A webstation container takes both hydrates: the card carries the game's + own saves, the archive carries the state the last session ended on.""" + with _streaming(_mc_webstation_for(rom)): + with ( + patch("endpoints.streaming._webstation_activate", return_value={}), + patch("endpoints.streaming._fetch_memory_card", return_value=None), + patch( + "endpoints.streaming._hydrate_memory_card_to_broker", + new=AsyncMock(return_value=True), + ) as card, + patch( + "endpoints.streaming._hydrate_states_to_broker", new=AsyncMock() + ) as states, + 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 + card.assert_awaited_once() + # The state hydrate is spawned rather than awaited inline, so the claim can + # return while the push is still in flight. + states.assert_called_once() + legacy.assert_not_called() + + +def test_webstation_claim_tells_the_broker_the_card_is_synced( + client, access_token, rom: Rom +): + """Without the flag the broker would restore and dump the card inside the + save archive too, fighting the image the card routes just laid down.""" + with _streaming(_mc_webstation_for(rom)): + with ( + patch( + "endpoints.streaming._webstation_activate", return_value={} + ) as activate, + patch("endpoints.streaming._fetch_memory_card", return_value=None), + patch( + "endpoints.streaming._hydrate_memory_card_to_broker", + new=AsyncMock(return_value=True), + ), + patch("endpoints.streaming._hydrate_states_to_broker", new=AsyncMock()), + patch("endpoints.streaming._spawn_sync_task"), + ): + r = _mc_claim(client, access_token, rom.id) + assert r.status_code == 200 + assert activate.call_args.kwargs["memory_card_synced"] is True + + +def test_concurrent_adopts_record_one_decision(admin_user: User, rom: Rom): + """The unique constraint decides, so the loser must not 500.""" + key = streaming._container_key(_mc_container_for(rom)) + first = db_container_adoption_handler.add_adoption( + container_key=key, outcome="adopt", user_id=admin_user.id + ) + second = db_container_adoption_handler.add_adoption( + container_key=key, outcome="discard", user_id=admin_user.id + ) + assert first is not None + assert second is None + assert db_container_adoption_handler.get_adoption(key).outcome == "adopt" + + +# ── 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 + + +def test_summarize_memory_card_reports_files_and_game_codes(): + """The dialog names games, so the summary lifts gamecodes from .gci names.""" + from tests._zipfile_shim import reload_zipfile + + reload_zipfile() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("01-GXCE-CustomRobo-BattleRevolution.gci", b"x" * 100) + zf.writestr("01-GALE-SuperSmashBros.gci", b"y" * 50) + summary = streaming._summarize_memory_card(buf.getvalue()) + assert summary["file_count"] == 2 + assert summary["total_bytes"] == 150 + assert summary["game_codes"] == ["GALE", "GXCE"] + + +def test_summarize_memory_card_handles_unparsable_content(): + """A card we cannot parse describes nothing, and never raises into the claim.""" + summary = streaming._summarize_memory_card(b"not a zip") + assert summary["file_count"] == 0 + assert summary["total_bytes"] == 0 + assert summary["game_codes"] == [] + + +@pytest.mark.parametrize( + ("method", "path", "body"), + [ + ("post", "/api/streaming/sessions", {"rom_id": 1}), + ("post", "/api/streaming/sessions/ps2/save-and-exit", {}), + ("post", "/api/streaming/sessions/ps2/heartbeat", {}), + ("post", "/api/streaming/sessions/ps2/volume", {"level": 50}), + ("post", "/api/streaming/sessions/ps2/mute", {"mute": True}), + ("post", "/api/streaming/sessions/ps2/save-state", {"slot": 1}), + ("post", "/api/streaming/sessions/ps2/load-state", {"slot": 1}), + ("delete", "/api/streaming/sessions/ps2", None), + ("delete", "/api/streaming/sessions", None), + ], +) +def test_kiosk_mode_cannot_mutate_sessions(client, method, path, body): + """KIOSK_MODE hands anonymous visitors READ_SCOPES, which must not suffice. + + Every kiosk visitor resolves to the same synthetic user (id=-1), so session + ownership cannot separate them -- without a write scope on these routes an + anonymous visitor could claim sessions and overwrite others' save states. + """ + with patch("handler.auth.hybrid_auth.KIOSK_MODE", True): + kwargs = {"json": body} if body is not None else {} + assert getattr(client, method)(path, **kwargs).status_code == 403 + + +def test_kiosk_mode_can_still_read_config(client): + """The read side of streaming stays open to kiosk visitors.""" + with patch("handler.auth.hybrid_auth.KIOSK_MODE", True), _streaming(): + assert client.get("/api/streaming/config").status_code == 200 + + +# ── multiplayer flag ───────────────────────────────────────────────────────── + + +def _claim_multiplayer(client, token, rom_id, multiplayer=True): + with patch("endpoints.streaming._call_broker"): + return client.post( + "/api/streaming/sessions", + json={"rom_id": rom_id, "multiplayer": multiplayer}, + headers=_auth(token), + ) + + +def test_a_multiplayer_claim_is_recorded_on_the_session(client, access_token, rom: Rom): + container = {"host": "http://192.168.1.10:3000", "platform": rom.platform_slug} + with _streaming(container): + _claim_multiplayer(client, access_token, rom.id) + raw = _session_raw(container) + + assert json.loads(raw)["multiplayer"] is True + + +def test_the_activate_body_carries_the_multiplayer_flag(client, access_token, rom: Rom): + """The broker gates its comms surface on this field, so stub the transport + rather than the activate helper: the body itself is what matters.""" + with _streaming(_ws_for(rom)): + with patch( + "endpoints.streaming._broker_request", return_value={"url": "/room/x"} + ) as request: + client.post( + "/api/streaming/sessions", + json={"rom_id": rom.id, "multiplayer": True}, + headers=_auth(access_token), + ) + + assert request.call_args.kwargs["body"]["multiplayer"] is True + + +def test_a_claim_is_solo_unless_asked_otherwise(client, access_token, rom: Rom): + container = {"host": "http://192.168.1.10:3000", "platform": rom.platform_slug} + with _streaming(container): + _claim_ok(client, access_token, rom.id) + raw = _session_raw(container) + + assert json.loads(raw)["multiplayer"] is False + + +# ── joinable sessions ──────────────────────────────────────────────────────── + + +def _joinable(client, token, rom_id=None): + params = {} if rom_id is None else {"rom_id": rom_id} + return client.get( + "/api/streaming/sessions/joinable", params=params, headers=_auth(token) + ) + + +def test_joinable_lists_someone_elses_multiplayer_session( + client, access_token, viewer_access_token, rom: Rom +): + container = {"host": "http://192.168.1.10:3000", "platform": rom.platform_slug} + with _streaming(container): + _claim_multiplayer(client, access_token, rom.id) + body = _joinable(client, viewer_access_token).json() + + assert [s["rom_id"] for s in body["sessions"]] == [rom.id] + + +def test_joinable_hides_a_solo_session( + client, access_token, viewer_access_token, rom: Rom +): + container = {"host": "http://192.168.1.10:3000", "platform": rom.platform_slug} + with _streaming(container): + _claim_ok(client, access_token, rom.id) + body = _joinable(client, viewer_access_token).json() + + assert body["sessions"] == [] + + +def test_joinable_hides_your_own_session(client, access_token, rom: Rom): + """Nobody needs a Join button for the game they are already hosting.""" + container = {"host": "http://192.168.1.10:3000", "platform": rom.platform_slug} + with _streaming(container): + _claim_multiplayer(client, access_token, rom.id) + body = _joinable(client, access_token).json() + + assert body["sessions"] == [] + + +def test_joinable_filters_by_rom(client, access_token, viewer_access_token, rom: Rom): + container = {"host": "http://192.168.1.10:3000", "platform": rom.platform_slug} + with _streaming(container): + _claim_multiplayer(client, access_token, rom.id) + body = _joinable(client, viewer_access_token, rom_id=rom.id + 1).json() + + assert body["sessions"] == [] + + +def test_joinable_requires_auth(client): + assert client.get("/api/streaming/sessions/joinable").status_code == 401 + + +def test_joinable_hides_a_session_whose_rom_is_hidden( + client, access_token, viewer_access_token, viewer_user: User, rom: Rom +): + """The listing leaks rom_name and host_username, so a ROM the caller + cannot see must not appear in it.""" + _hide(PermEntity.ROMS, rom.id, viewer_user.id) + container = {"host": "http://192.168.1.10:3000", "platform": rom.platform_slug} + with _streaming(container): + _claim_multiplayer(client, access_token, rom.id) + body = _joinable(client, viewer_access_token).json() + + assert body["sessions"] == [] + + +def test_joinable_hides_a_session_on_a_hidden_platform( + client, access_token, viewer_access_token, viewer_user: User, rom: Rom, platform +): + _hide(PermEntity.PLATFORMS, platform.id, viewer_user.id) + container = {"host": "http://192.168.1.10:3000", "platform": rom.platform_slug} + with _streaming(container): + _claim_multiplayer(client, access_token, rom.id) + body = _joinable(client, viewer_access_token).json() + + assert body["sessions"] == [] + + +# ── joining a session ───────────────────────────────────────────────────────── + + +def _ws_for(rom: Rom): + """A webstation container serving this rom's platform, since only the + webstation broker mints viewer seats.""" + return _webstation(platforms={rom.platform_slug: "pcsx2"}) + + +def _claim_ws_multiplayer(client, token, rom_id, multiplayer=True): + with patch( + "endpoints.streaming._webstation_activate", return_value={"url": "/room/x"} + ): + return client.post( + "/api/streaming/sessions", + json={"rom_id": rom_id, "multiplayer": multiplayer}, + headers=_auth(token), + ) -def test_load_state_allows_platform_autosave_slot(client, access_token): - """Dolphin's slot 8 is not manually savable but is loadable as the autosave.""" - rom = _rom_on("wii") - with _streaming(_container_for(rom)): - _claim_ok(client, access_token, rom.id) - with patch("endpoints.streaming._load_state_broker", return_value=True): +def _join(client, token, platform, container=None): + params = {} if container is None else {"container": container} + return client.post( + f"/api/streaming/sessions/{platform}/join", + params=params, + headers=_auth(token), + ) + + +def test_joining_a_multiplayer_session_returns_its_room_url( + client, access_token, viewer_access_token, rom: Rom +): + with _streaming(_ws_for(rom)): + _claim_ws_multiplayer(client, access_token, rom.id) + with patch( + "endpoints.streaming._webstation_join", + return_value={"url": "/webstation/?token=abc"}, + ): + response = _join(client, viewer_access_token, rom.platform_slug) + + assert response.status_code == 200 + assert response.json()["host"] == "http://192.168.1.10:3000/webstation/?token=abc" + + +def test_joining_a_hidden_rom_is_404_masked( + client, access_token, viewer_access_token, viewer_user: User, rom: Rom +): + """Joining streams the host's ROM, so it needs the same visibility policy + the claim route enforces; masked as the not-found so nothing leaks.""" + _hide(PermEntity.ROMS, rom.id, viewer_user.id) + with _streaming(_ws_for(rom)): + _claim_ws_multiplayer(client, access_token, rom.id) + with patch("endpoints.streaming._webstation_join") as join_broker: + response = _join(client, viewer_access_token, rom.platform_slug) + + assert response.status_code == 404 + join_broker.assert_not_called() + + +def test_joining_a_rom_on_a_hidden_platform_is_404_masked( + client, access_token, viewer_access_token, viewer_user: User, rom: Rom, platform +): + _hide(PermEntity.PLATFORMS, platform.id, viewer_user.id) + with _streaming(_ws_for(rom)): + _claim_ws_multiplayer(client, access_token, rom.id) + with patch("endpoints.streaming._webstation_join") as join_broker: + response = _join(client, viewer_access_token, rom.platform_slug) + + assert response.status_code == 404 + join_broker.assert_not_called() + + +def test_joining_a_solo_session_finds_nothing_to_join( + client, access_token, viewer_access_token, rom: Rom +): + """The scan skips solo sessions outright, so there is nothing to refuse.""" + with _streaming(_ws_for(rom)): + _claim_ws_multiplayer(client, access_token, rom.id, multiplayer=False) + response = _join(client, viewer_access_token, rom.platform_slug) + + assert response.status_code == 404 + + +def test_joining_a_named_solo_container_is_refused( + client, access_token, viewer_access_token, rom: Rom +): + """Naming the container skips the scan, so the refusal is explicit.""" + container = _ws_for(rom) + with _streaming(container): + _claim_ws_multiplayer(client, access_token, rom.id, multiplayer=False) + response = _join( + client, viewer_access_token, rom.platform_slug, container=_key_of(container) + ) + + assert response.status_code == 403 + + +def test_joining_when_nothing_is_running_is_a_404( + client, viewer_access_token, rom: Rom +): + with _streaming(_ws_for(rom)): + response = _join(client, viewer_access_token, rom.platform_slug) + + assert response.status_code == 404 + + +def test_a_joiner_cannot_drive_the_session( + client, access_token, viewer_access_token, rom: Rom +): + """Joining hands out a room URL, never control of the container.""" + with _streaming(_ws_for(rom)): + _claim_ws_multiplayer(client, access_token, rom.id) + with patch( + "endpoints.streaming._webstation_join", + return_value={"url": "/webstation/?token=abc"}, + ): + assert ( + _join(client, viewer_access_token, rom.platform_slug).status_code == 200 + ) + response = _volume(client, viewer_access_token, rom.platform_slug) + + assert response.status_code == 403 + + +def test_a_refused_mint_is_a_502(client, access_token, viewer_access_token, rom: Rom): + """The broker answering with no URL must not read as a successful join.""" + with _streaming(_ws_for(rom)): + _claim_ws_multiplayer(client, access_token, rom.id) + with patch("endpoints.streaming._webstation_join", return_value=None): + response = _join(client, viewer_access_token, rom.platform_slug) + + assert response.status_code == 502 + + +def test_joining_requires_auth(client, rom: Rom): + with _streaming(_ws_for(rom)): + r = client.post(f"/api/streaming/sessions/{rom.platform_slug}/join") + assert r.status_code == 401 + + +# ── Container expansion ─────────────────────────────────────────────────────── + + +def test_expand_platform_block_overrides_container_defaults(): + """A platform block is the per-platform default, the container is the + fallback, so one webstation can label each emulator for itself.""" + expanded = streaming._expand_containers( + [ + { + "host": "http://box:3010", + "label": "Emulation station", + "memory_card_sync": False, + "platforms": { + "ps2": { + "emulator": "pcsx2", + "label": "PCSX2", + "memory_card_sync": True, + }, + "wii": {"emulator": "dolphin"}, + "snes": "retroarch", + }, + } + ] + ) + + by_platform = {row["platform"]: row for row in expanded} + assert by_platform["ps2"]["emulator"] == "pcsx2" + assert by_platform["ps2"]["label"] == "PCSX2" + assert by_platform["ps2"]["memory_card_sync"] is True + # A block that omits a key falls through to the container. + assert by_platform["wii"]["emulator"] == "dolphin" + assert by_platform["wii"]["label"] == "Emulation station" + assert by_platform["wii"]["memory_card_sync"] is False + # The bare string form keeps inheriting everything from the container. + assert by_platform["snes"]["emulator"] == "retroarch" + assert by_platform["snes"]["label"] == "Emulation station" + assert by_platform["snes"]["memory_card_sync"] is False + + +def test_expand_platform_block_without_an_emulator_is_skipped(): + """The emulator names the state and card namespace, so a block that omits + it is dropped rather than guessed, and its siblings still expand.""" + expanded = streaming._expand_containers( + [ + { + "host": "http://box:3010", + "platforms": {"ps2": {"label": "PCSX2"}, "snes": "retroarch"}, + } + ] + ) + + assert [row["platform"] for row in expanded] == ["snes"] + + +def test_expand_platform_block_ignores_an_unknown_option(): + expanded = streaming._expand_containers( + [ + { + "host": "http://box:3010", + "platforms": {"ps2": {"emulator": "pcsx2", "nonsense": 1}}, + } + ] + ) + + assert len(expanded) == 1 + assert "nonsense" not in expanded[0] + + +def test_expand_platform_value_that_is_neither_name_nor_block_is_skipped(): + expanded = streaming._expand_containers( + [{"host": "http://box:3010", "platforms": {"ps2": 42, "snes": "retroarch"}}] + ) + + assert [row["platform"] for row in expanded] == ["snes"] + + +# ── Broker host derivation ──────────────────────────────────────────────────── + + +def test_webstation_broker_host_defaults_to_the_stream_host(): + """Selkies and the broker share one port on the webstation container, and + the subfolder is added later, so the stream host is the broker host.""" + assert ( + streaming._derive_broker_host( + {"host": "http://box:3010", "protocol": "webstation"} + ) + == "http://box:3010" + ) + + +def test_legacy_broker_host_still_defaults_to_port_8000(): + assert ( + streaming._derive_broker_host({"host": "http://box:3001"}) == "http://box:8000" + ) + + +def test_an_explicit_broker_host_wins_on_either_protocol(): + for protocol in ("webstation", "broker"): + assert ( + streaming._derive_broker_host( + { + "host": "https://box:3010", + "broker_host": "http://box:9000", + "protocol": protocol, + } + ) + == "http://box:9000" + ) + + +def test_a_proxied_webstation_host_derives_nothing(): + """A bare path carries no address RomM can dial, so `broker_host` stays + required there.""" + assert ( + streaming._derive_broker_host({"host": "/streaming", "protocol": "webstation"}) + is None + ) + + +# ── /sessions/{platform}/swap-disc ────────────────────────────────────────── + + +def _tray_container(rom: Rom, **overrides): + """Only the webstation broker has a tray route, so every swap that is meant + to reach the broker starts from one of these.""" + return {**_container_for(rom), "protocol": "webstation", **overrides} + + +def test_swap_disc_calls_the_broker_and_records_the_disc(client, access_token): + rom = _rom_on("dc") + disc = _add_rom_file(rom, "Game (Disc 2).chd") + container = _tray_container(rom) + with _streaming(container): + _claim_webstation_ok(client, access_token, rom.id) + with patch("endpoints.streaming._swap_disc_broker", return_value=True) as swap: r = client.post( - "/api/streaming/sessions/wii/load-state", - json={"slot": 8}, + f"/api/streaming/sessions/{rom.platform_slug}/swap-disc", + json={"file_id": disc.id}, headers=_auth(access_token), ) + raw = _session_raw(container) assert r.status_code == 200 - assert r.json()["loaded"] is True + assert r.json() == { + "status": "ok", + "file_id": disc.id, + "platform": rom.platform_slug, + } + assert swap.call_args.args[1].endswith(disc.full_path) + assert json.loads(raw)["disc_file_id"] == disc.id -def test_load_state_rejects_slot_between_max_and_autosave(client, access_token): - """Dolphin: slot 9 is neither a manual slot (1-7) nor the autosave (8).""" - rom = _rom_on("wiiu") - with _streaming(_container_for(rom)): - _claim_ok(client, access_token, rom.id) +def test_swap_disc_reports_a_broker_failure(client, access_token): + rom = _rom_on("dc") + disc = _add_rom_file(rom, "Game (Disc 2).chd") + container = _tray_container(rom) + with _streaming(container): + _claim_webstation_ok(client, access_token, rom.id) + with patch("endpoints.streaming._swap_disc_broker", return_value=False): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/swap-disc", + json={"file_id": disc.id}, + headers=_auth(access_token), + ) + raw = _session_raw(container) + assert r.status_code == 502 + assert "disc_file_id" not in json.loads(raw) + + +def test_swap_disc_refuses_a_file_from_another_rom(client, access_token, rom): + streamed = _rom_on("dc") + stranger = _add_rom_file(rom, "Other.chd") + with _streaming(_tray_container(streamed)): + _claim_webstation_ok(client, access_token, streamed.id) r = client.post( - "/api/streaming/sessions/wiiu/load-state", - json={"slot": 9}, + f"/api/streaming/sessions/{streamed.platform_slug}/swap-disc", + json={"file_id": stranger.id}, headers=_auth(access_token), ) - assert r.status_code == 422 + assert r.status_code == 404 -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): +def test_swap_disc_refuses_the_m3u_playlist(client, access_token): + """The .m3u is the playlist, not a disc; mounting it would hand the broker + a path the emulator's tray cannot take.""" + rom = _rom_on("dc") + playlist = _add_rom_file(rom, "Game.m3u") + _add_rom_file(rom, "Game (Disc 2).chd") + with _streaming(_tray_container(rom)): + _claim_webstation_ok(client, access_token, rom.id) + with patch("endpoints.streaming._swap_disc_broker") as swap: r = client.post( - f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", - json={"slot": 10, "wait": True}, + f"/api/streaming/sessions/{rom.platform_slug}/swap-disc", + json={"file_id": playlist.id}, 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 + assert r.status_code == 400 + swap.assert_not_called() -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): +def test_swap_disc_refuses_a_raw_track_when_cues_are_present(client, access_token): + """With .cue sheets present the raw .bin tracks they reference are not + swap targets, matching the download endpoint's playlist filtering.""" + rom = _rom_on("dc") + _add_rom_file(rom, "Game (Disc 2).cue") + track = _add_rom_file(rom, "Game (Disc 2) (Track 01).bin") + with _streaming(_tray_container(rom)): + _claim_webstation_ok(client, access_token, rom.id) + with patch("endpoints.streaming._swap_disc_broker") as swap: r = client.post( - f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", - json={"slot": 10, "wait": True}, + f"/api/streaming/sessions/{rom.platform_slug}/swap-disc", + json={"file_id": track.id}, 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 + assert r.status_code == 400 + swap.assert_not_called() -def test_save_and_exit_wait_false_drains_instead_of_freeing( - client, access_token, rom: Rom +def test_swap_disc_by_other_user_is_forbidden( + client, access_token, viewer_access_token ): - """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 + rom = _rom_on("dc") + disc = _add_rom_file(rom, "Game (Disc 2).chd") + with _streaming(_container_for(rom)): + _claim_ok(client, access_token, rom.id) + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/swap-disc", + json={"file_id": disc.id}, + headers=_auth(viewer_access_token), + ) + assert r.status_code == 403 + + +def test_swap_disc_needs_a_session(client, access_token): + rom = _rom_on("dc") + disc = _add_rom_file(rom, "Game (Disc 2).chd") + with _streaming(_container_for(rom)): + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/swap-disc", + json={"file_id": disc.id}, + headers=_auth(access_token), + ) + assert r.status_code == 404 + + +def test_swap_disc_rejects_a_platform_with_no_tray(client, access_token): + rom = _rom_on("ps2") + disc = _add_rom_file(rom, "Game (Disc 2).chd") + with _streaming(_tray_container(rom)): + _claim_webstation_ok(client, access_token, rom.id) + r = client.post( + f"/api/streaming/sessions/{rom.platform_slug}/swap-disc", + json={"file_id": disc.id}, + headers=_auth(access_token), + ) + assert r.status_code == 400 + +def test_swap_disc_rejects_a_container_with_no_tray_route(client, access_token): + """The platform swaps discs but this broker has no tray route, and /config + told the frontend as much, so the refusal comes from RomM and not as a 502 + from a broker asked for a route it does not serve.""" + rom = _rom_on("dc") + disc = _add_rom_file(rom, "Game (Disc 2).chd") 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._swap_disc_broker") as swap: r = client.post( - f"/api/streaming/sessions/{rom.platform_slug}/save-and-exit", - json={"slot": 0, "wait": False}, + f"/api/streaming/sessions/{rom.platform_slug}/swap-disc", + json={"file_id": disc.id}, headers=_auth(access_token), ) - # The drain key briefly holds the container. - r2 = _claim_ok(client, access_token, rom.id) - assert r.status_code == 200 - # 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"])) + assert r.status_code == 400 + swap.assert_not_called() + + +def test_a_state_captured_after_a_swap_records_the_disc(client, access_token): + rom = _rom_on("dc") + disc = _add_rom_file(rom, "Game (Disc 2).chd") + # "dc" is in no capability table, so without an explicit emulator the + # container resolves to the slug and every slot fails validation. + container = _tray_container(rom, emulator="retroarch") + with _streaming(container): + _claim_webstation_ok(client, access_token, rom.id) + with patch("endpoints.streaming._swap_disc_broker", return_value=True): + client.post( + f"/api/streaming/sessions/{rom.platform_slug}/swap-disc", + json={"file_id": disc.id}, + headers=_auth(access_token), + ) + with ( + patch("endpoints.streaming._save_state_broker", return_value=True), + patch("endpoints.streaming._spawn_sync_task"), + patch( + "endpoints.streaming._pull_state_to_library", new=MagicMock() + ) as pull, + ): + client.post( + f"/api/streaming/sessions/{rom.platform_slug}/save-state", + json={"slot": 10}, + headers=_auth(access_token), + ) + assert pull.call_args.kwargs["disc_file_id"] == disc.id + + +def _retroarch_resume(client, token, rom, state_id): + """Claim with a resume state on a retroarch container, launch mocked. + Returns (response, restore mock).""" + container = {**_container_for(rom), "emulator": "retroarch"} + with _streaming(container): + with ( + patch("endpoints.streaming._call_broker"), + patch("endpoints.streaming._push_resume_state", return_value=True), + patch("endpoints.streaming._spawn_sync_task"), + patch("endpoints.streaming._hydrate_states_to_broker", new=MagicMock()), + patch( + "endpoints.streaming._restore_session_disc", new=MagicMock() + ) as restore, + ): + r = _claim(client, token, rom.id, state_id=state_id) + return r, restore + + +def test_resuming_a_state_puts_its_disc_back(client, access_token, admin_user: User): + rom = _rom_on("dc") + disc = _add_rom_file(rom, "Game (Disc 2).chd") + state = db_state_handler.add_state( + _state_for(rom, admin_user, "Game.state", "retroarch") ) - assert 0 < ttl <= SESSION_DRAIN_SECONDS + db_state_handler.update_state(state.id, {"disc_file_id": disc.id}) + r, restore = _retroarch_resume(client, access_token, rom, state.id) -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 - + assert restore.call_args.kwargs["file_id"] == disc.id -# ── Auth guards ─────────────────────────────────────────────────────────────── +def test_resuming_a_state_with_no_disc_swaps_nothing( + client, access_token, admin_user: User +): + rom = _rom_on("dc") + state = db_state_handler.add_state( + _state_for(rom, admin_user, "Game.state", "retroarch") + ) -def test_claim_session_requires_auth(client): - assert client.post("/api/streaming/sessions", json={"rom_id": 1}).status_code == 401 + r, restore = _retroarch_resume(client, access_token, rom, state.id) + assert r.status_code == 200 + restore.assert_not_called() -def test_release_session_requires_auth(client): - assert client.delete("/api/streaming/sessions/ps2").status_code == 401 +# ── _restore_session_disc (direct) ────────────────────────────────────────── -def test_force_release_all_requires_auth(client): - assert client.delete("/api/streaming/sessions").status_code == 401 +def _session_for(container: dict, rom: Rom, user: User) -> str: + """Seed a redis session for `container` and return its (unprefixed) + session key, the form `_restore_session_disc` and friends take.""" + session_key = streaming._container_key(container) + asyncio.run( + async_cache.set( + streaming._session_redis_key(session_key), + json.dumps( + { + "rom_id": rom.id, + "rom_name": rom.name, + "platform": rom.platform_slug, + "claimed_at": datetime.now(timezone.utc).isoformat(), + "last_seen": datetime.now(timezone.utc).isoformat(), + "user_id": user.id, + } + ), + ) + ) + return session_key -def test_list_sessions_requires_auth(client): - assert client.get("/api/streaming/sessions").status_code == 401 +def test_restore_session_disc_swaps_and_records_the_disc(admin_user: User): + rom = _rom_on("dc") + disc = _add_rom_file(rom, "Game (Disc 2).chd") + container = _container_for(rom) + session_key = _session_for(container, rom, admin_user) + with patch("endpoints.streaming._swap_disc_broker", return_value=True) as swap: + ok = asyncio.run( + streaming._restore_session_disc( + rom.id, container, session_key, file_id=disc.id + ) + ) + assert ok is True + assert swap.call_args.args[1].endswith(disc.full_path) + raw = _session_raw(container) + assert json.loads(raw)["disc_file_id"] == disc.id -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_restore_session_disc_refuses_a_file_from_another_rom( + admin_user: User, rom: Rom +): + streamed = _rom_on("dc") + stranger = _add_rom_file(rom, "Other.chd") + container = _container_for(streamed) + session_key = _session_for(container, streamed, admin_user) + with patch("endpoints.streaming._swap_disc_broker") as swap: + ok = asyncio.run( + streaming._restore_session_disc( + streamed.id, container, session_key, file_id=stranger.id + ) + ) + assert ok is False + swap.assert_not_called() + raw = _session_raw(container) + assert "disc_file_id" not in json.loads(raw) -@pytest.mark.parametrize( - ("method", "path", "body"), - [ - ("post", "/api/streaming/sessions", {"rom_id": 1}), - ("post", "/api/streaming/sessions/ps2/save-and-exit", {}), - ("post", "/api/streaming/sessions/ps2/volume", {"level": 50}), - ("post", "/api/streaming/sessions/ps2/mute", {"mute": True}), - ("post", "/api/streaming/sessions/ps2/save-state", {"slot": 1}), - ("post", "/api/streaming/sessions/ps2/load-state", {"slot": 1}), - ("delete", "/api/streaming/sessions/ps2", None), - ("delete", "/api/streaming/sessions", None), - ], -) -def test_kiosk_mode_cannot_mutate_sessions(client, method, path, body): - """KIOSK_MODE hands anonymous visitors READ_SCOPES, which must not suffice. - Every kiosk visitor resolves to the same synthetic user (id=-1), so session - ownership cannot separate them -- without a write scope on these routes an - anonymous visitor could claim sessions and overwrite others' save states. - """ - with patch("handler.auth.hybrid_auth.KIOSK_MODE", True): - kwargs = {"json": body} if body is not None else {} - assert getattr(client, method)(path, **kwargs).status_code == 403 +def test_restore_session_disc_bails_out_when_the_file_is_gone(admin_user: User, caplog): + rom = _rom_on("dc") + container = _container_for(rom) + session_key = _session_for(container, rom, admin_user) + romm_logger = logging.getLogger("romm") + romm_logger.addHandler(caplog.handler) + try: + with ( + patch("endpoints.streaming._swap_disc_broker") as swap, + caplog.at_level(logging.WARNING, logger="romm"), + ): + ok = asyncio.run( + streaming._restore_session_disc( + rom.id, container, session_key, file_id=999999 + ) + ) + finally: + romm_logger.removeHandler(caplog.handler) + assert ok is False + swap.assert_not_called() + assert "not in the library" in caplog.text -def test_kiosk_mode_can_still_read_config(client): - """The read side of streaming stays open to kiosk visitors.""" - with patch("handler.auth.hybrid_auth.KIOSK_MODE", True), _streaming(): - assert client.get("/api/streaming/config").status_code == 200 +def test_restore_session_disc_does_not_record_on_broker_failure(admin_user: User): + rom = _rom_on("dc") + disc = _add_rom_file(rom, "Game (Disc 2).chd") + container = _container_for(rom) + session_key = _session_for(container, rom, admin_user) + with patch("endpoints.streaming._swap_disc_broker", return_value=False): + ok = asyncio.run( + streaming._restore_session_disc( + rom.id, container, session_key, file_id=disc.id + ) + ) + assert ok is False + raw = _session_raw(container) + assert "disc_file_id" not in json.loads(raw) diff --git a/backend/tests/models/test_assets.py b/backend/tests/models/test_assets.py index 92406ecfcf..fb0d4241c8 100644 --- a/backend/tests/models/test_assets.py +++ b/backend/tests/models/test_assets.py @@ -1,4 +1,6 @@ +from handler.database import db_rom_handler, db_state_handler from models.assets import Save, Screenshot, State +from models.rom import Rom def test_save(save: Save): @@ -16,3 +18,23 @@ def test_screenshot(screenshot: Screenshot): assert screenshot.download_path.startswith( f"/api/screenshots/{screenshot.id}/content" ) + + +def test_a_state_defaults_to_no_disc(state: State): + """A single-disc game records nothing, so the column stays empty.""" + assert state.disc_file_id is None + + +def test_losing_the_disc_file_does_not_take_the_state_with_it( + state: State, multi_file_rom: Rom +): + """A rescan that drops a file row must cost the state its disc hint, not the + save itself, so the column is SET NULL rather than CASCADE.""" + disc = multi_file_rom.files[1] + db_state_handler.update_state(state.id, {"disc_file_id": disc.id}) + + db_rom_handler.delete_rom_file(disc.id) + + survivor = db_state_handler.get_state(user_id=state.user_id, id=state.id) + assert survivor is not None + assert survivor.disc_file_id is None diff --git a/backend/tools/import_memory_card.py b/backend/tools/import_memory_card.py new file mode 100644 index 0000000000..b3e309bbf1 --- /dev/null +++ b/backend/tools/import_memory_card.py @@ -0,0 +1,110 @@ +#!/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 + +from handler.database import db_memory_card_handler, db_user_handler +from models.assets import MemoryCard +from utils.memory_cards import ( + MEMORY_CARD_MAX_BYTES, + UnsafeCardArchive, + assert_card_archive_safe, + store_memory_card_version, +) + + +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}") + + version = await store_memory_card_version(user, card, content) + print( + f"stored={version is not None} card_id={card.id} " + f"version={version.file_name if version else None} " + f"hash={version.content_hash if version 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(MEMORY_CARD_MAX_BYTES + 1) + if not content: + print(f"error: {zip_path} is empty", file=sys.stderr) + return 2 + # The upload route enforces the same ceiling, and the broker refuses + # anything larger; importing past it just moves where the transfer fails. + if len(content) > MEMORY_CARD_MAX_BYTES: + print( + f"error: {zip_path} exceeds the {MEMORY_CARD_MAX_BYTES} byte card limit", + 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 + # The same gate the upload route applies: a card imported here hydrates onto + # a container exactly like an uploaded one. + try: + assert_card_archive_safe(content) + except UnsafeCardArchive as exc: + print(f"error: {zip_path} rejected, {exc}", file=sys.stderr) + return 2 + + return asyncio.run(_import(username, emulator, content)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/utils/memory_cards.py b/backend/utils/memory_cards.py new file mode 100644 index 0000000000..bde9dafba2 --- /dev/null +++ b/backend/utils/memory_cards.py @@ -0,0 +1,225 @@ +"""Storage for whole memory card images. Shared by the streaming teardown that +evacuates a card off a container and by the upload route that takes one from +the user, so both agree on how a version is hashed, named and deduplicated.""" + +import hashlib +import io +import ntpath +import re +import zipfile +from datetime import datetime, timezone +from pathlib import PurePosixPath + +from handler.database import db_memory_card_handler +from handler.filesystem import fs_asset_handler +from handler.filesystem.assets_handler import hash_zip_entry +from handler.scan_handler import scan_memory_card_version +from logger.logger import log +from models.assets import MemoryCard, MemoryCardVersion +from models.user import User +from utils.filesystem import sanitize_filename + +# The broker caps card transfers at the same figure. Raising one side alone +# just moves where the transfer fails. +MEMORY_CARD_MAX_BYTES = 256 * 1024 * 1024 + + +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_hash = hash_zip_entry(zf, name) + 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 + + +class UnsafeCardArchive(ValueError): + """A card archive the broker must not be asked to unpack.""" + + +# The unix mode a zip entry carries in the top half of its external attributes, +# and the bits that mark it a symlink. +_ZIP_MODE_SHIFT = 16 +_S_IFMT = 0o170000 +_S_IFLNK = 0o120000 + +# What a card archive may add up to once unpacked, over the whole archive rather +# than per entry: a card set is several files and the container's disk pays for +# the total. The archive's own size says nothing about it, since a few hundred +# compressed megabytes of zeros expand to hundreds of gigabytes. Held to the +# transfer cap, which a real card of a few megabytes comes nowhere near. +_CARD_MAX_UNPACKED_BYTES = MEMORY_CARD_MAX_BYTES + +# Enough that a card-sized entry is a handful of reads, small enough that the +# check never holds much more than this per entry. +_UNPACK_CHUNK_BYTES = 1024 * 1024 + + +def _assert_entry_fits(zf: zipfile.ZipFile, entry: zipfile.ZipInfo, budget: int) -> int: + """Decompress one entry against what is left of the archive's budget, and + return what it consumed. + + Decompressed rather than trusting `file_size`: that header is whatever the + uploader put there, and an unpacker writes what actually comes out. + """ + read = 0 + with zf.open(entry, "r") as stream: + while True: + chunk = stream.read(_UNPACK_CHUNK_BYTES) + if not chunk: + return read + read += len(chunk) + if read > budget: + raise UnsafeCardArchive( + f"unpacks to over {_CARD_MAX_UNPACKED_BYTES} bytes" + ) + + +def assert_card_archive_safe(content: bytes) -> None: + """Refuse an archive the broker must not be asked to unpack: one whose + entries would escape the directory they land in, or fill the disk they land + on. RomM stores the zip whole, and this is the last point that can look at + what is inside before a container unpacks it. + """ + try: + with zipfile.ZipFile(io.BytesIO(content), "r") as zf: + budget = _CARD_MAX_UNPACKED_BYTES + for entry in zf.infolist(): + name = entry.filename + # Zip names are meant to be slash-separated, so a hand-written + # entry can hide `..\..\evil` in what PurePosixPath reads as one + # opaque part. Both separators are split before the parts are + # judged. + parts = re.split(r"[\\/]", name) + if ( + PurePosixPath(name).is_absolute() + or ntpath.isabs(name) + or ".." in parts + ): + raise UnsafeCardArchive(f"unsafe path: {name}") + # A symlink's own name is harmless; its target is not, and an + # unpacker that follows it writes wherever the target points on + # the next entry. + mode = entry.external_attr >> _ZIP_MODE_SHIFT + if mode & _S_IFMT == _S_IFLNK: + raise UnsafeCardArchive(f"symlink entry: {name}") + if not name.endswith("/"): + budget -= _assert_entry_fits(zf, entry, budget) + # Encrypted entries and unsupported compression raise on the read rather + # than on the open, and an archive this cannot look inside is one the broker + # must not be handed either. + except (zipfile.BadZipFile, NotImplementedError, RuntimeError): + raise UnsafeCardArchive("not a readable zip archive") from None + + +# Names only ever collide when two snapshots land in the same millisecond, so +# the walk exists to break that tie, not to search. +_FILENAME_COLLISION_ATTEMPTS = 20 + + +async def _free_version_filename(cards_path: str, card_name: str, ts: str) -> str: + """A version filename no archive already occupies. + + `write_file` overwrites silently, so a name reused by a second snapshot + would replace the first one's bytes on disk while its row lived on + describing content that is no longer there. + """ + for attempt in range(1, _FILENAME_COLLISION_ATTEMPTS + 1): + suffix = "" if attempt == 1 else f" ({attempt})" + filename = sanitize_filename(f"{card_name} [{ts}{suffix}].card.zip") + if not await fs_asset_handler.file_exists(f"{cards_path}/{filename}"): + return filename + raise RuntimeError(f"could not find a free filename for card {card_name}") + + +async def _discard_version_file(cards_path: str, filename: str) -> None: + """Drop an archive no version row will reference. Best effort: it is + already unreachable, and raising here would mask the reason we are here.""" + try: + await fs_asset_handler.remove_file(f"{cards_path}/{filename}") + except OSError as exc: + log.warning("could not remove unreferenced card archive %s, %s", filename, exc) + + +async def store_memory_card_version( + user: User, + card: MemoryCard, + content: bytes, + deduplicate: bool = True, +) -> MemoryCardVersion | None: + """Store card content 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 the version that was written, or None when the content was already + in the history. + + `deduplicate` is off for an upload, where the user is asking for exactly + this content to become current: matching an older snapshot would leave that + snapshot where it is and the newer one still at the head, so the card the + next claim hydrates would not be the one that was just uploaded. + """ + # 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 deduplicate else None + 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 None + + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H-%M-%S-%f")[:-3] + cards_path = fs_asset_handler.build_memory_cards_file_path( + user=user, emulator=card.emulator, card_id=card.id + ) + filename = await _free_version_filename(cards_path, card.name, ts) + await fs_asset_handler.write_file(file=content, path=cards_path, filename=filename) + + try: + version = await scan_memory_card_version( + file_name=filename, user=user, emulator=card.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: MemoryCardVersion | None = None + if ( + deduplicate + and version.content_hash + and db_memory_card_handler.get_version_by_content_hash( + card_id=card.id, content_hash=version.content_hash + ) + is not None + ): + await _discard_version_file(cards_path, filename) + else: + stored = db_memory_card_handler.add_version(version) + except Exception: + # No row points at the archive yet, so leaving it behind strands bytes + # that nothing can reach and no delete would ever clean up. + await _discard_version_file(cards_path, filename) + raise + + # 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 diff --git a/env.template b/env.template index 730f83dc81..d7fe5b08dc 100644 --- a/env.template +++ b/env.template @@ -156,3 +156,4 @@ AUTHENTIK_BOOTSTRAP_PASSWORD= # Initial Authentik admin bootstrap password # Emulator Streaming STREAMING_BROKER_SECRET= STREAMING_SAVE_TIMEOUT=45 # Seconds to wait for a broker save-and-exit (raise if a broker has SAVE_WAIT > 45) +STREAMING_STATE_HISTORY_LIMIT=50 # Save states kept per ROM, emulator and user; oldest are pruned past this (0 disables) diff --git a/examples/config.example.yml b/examples/config.example.yml index d6baa570a5..54fec17395 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -191,21 +191,62 @@ # streaming: # enabled: true # containers: -# - platform: ps2 -# # Browser-facing Selkies web UI, MUST be served over https +# # One container serving many platforms. Every key on the container is the +# # default for every platform it serves; a platform block overrides it. +# # A platform block may only override emulator, label, and memory_card_sync. +# - # Browser-facing Selkies web UI, MUST be served over https # # (use a reverse proxy or the built in self signed cert for linuxserver) # # can also be a FQDN eg. https://your.example.com -# host: https://192.168.1.51:3001 +# host: https://192.168.1.56:3010 +# protocol: webstation +# # URL prefix the broker is served under, matching the container's +# # SUBFOLDER setting. +# subfolder: /streaming +# # Where the container mounts your ROM library. +# library_path: /romm +# # Fallback for the text on the play button. A platform that sets its +# # own `label` below overrides this, which is what makes a PS2 game say +# # "PCSX2" instead of the container's name. +# label: Emulation station # -# # server-to-broker URL does NOT need to be served over https -# # If pcsx2 emulator is on a different host, use its LAN IP. -# # If on the same Docker network, use the container name: http://pcsx2:8000 -# broker_host: http://192.168.1.51:8000 -# # what shows up on the play button -# label: PCSX2 +# # server-to-broker URL. Optional on `protocol: webstation`, where the +# # broker shares the host and port above. Still required when `host` is +# # a reverse-proxied path (eg. /streaming), which carries no address +# # RomM can dial, and when `host` is https with a self-signed +# # certificate (as above), since the broker calls verify certificates. +# # In those cases point it at the plain-http address: +# broker_host: http://192.168.1.56:3010 # -# # Add more emulator containers here as needed: -# # - platform: psx -# # host: http://192.168.1.51:3002 -# # broker_host: http://192.168.1.51:8001 -# # label: DuckStation +# platforms: +# # A platform is either the emulator that serves it... +# wii: dolphin +# xbox: xemu +# snes: retroarch +# psp: ppsspp +# # ...or a block overriding container keys for that platform alone. +# ps2: +# emulator: pcsx2 +# 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. +# # +# # Only ps2 and ngc have a memory card. On any other platform the +# # flag is ignored with a warning, because whole-card sync replaces +# # the per-file save sync that wii and xbox actually need. +# memory_card_sync: true +# ngc: +# emulator: dolphin +# label: Dolphin +# memory_card_sync: true +# +# # One container per emulator still works. Here the container serves a +# # single platform, so its keys are that platform's settings. +# # - platform: ps2 +# # host: https://192.168.1.51:3001 +# # # If the emulator is on a different host, use its LAN IP. If on the +# # # same Docker network, use the container name: http://pcsx2:8000 +# # broker_host: http://192.168.1.51:8000 +# # label: PCSX2 +# # memory_card_sync: true diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 12762d7482..f149d9eaf8 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'; @@ -98,6 +101,9 @@ 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'; @@ -198,6 +204,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'; 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 index 679317aa97..80a6192b95 100644 --- a/frontend/src/__generated__/models/ClaimSessionRequest.ts +++ b/frontend/src/__generated__/models/ClaimSessionRequest.ts @@ -4,5 +4,7 @@ /* eslint-disable */ export type ClaimSessionRequest = { rom_id: number; + state_id?: (number | null); + memory_card_id?: (number | null); }; 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/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/locales/bg_BG/activity.json b/frontend/src/locales/bg_BG/activity.json index 7a2780ae9f..41df9795c2 100644 --- a/frontend/src/locales/bg_BG/activity.json +++ b/frontend/src/locales/bg_BG/activity.json @@ -10,5 +10,14 @@ "now-playing": "Сега играе", "playing-on": "Играе на {device}", "playing-since": "Играе от {time}", - "total-sessions": "Общо сесии" + "release-failed": "Сесията не можа да бъде освободена", + "release-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/bg_BG/platform.json index 4c0ec3d3f3..bbbc919ebb 100644 --- a/frontend/src/locales/bg_BG/platform.json +++ b/frontend/src/locales/bg_BG/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Стари квадратни кутии", "on-disk": "На диск", "only-with-games": "Само платформи с игри", + "playable-both": "Може да се играе в браузъра и да се стриймва от {label}", + "playable-browser-dosbox": "Може да се играе в браузъра чрез DOSBox", + "playable-browser-emulatorjs": "Може да се играе в браузъра чрез EmulatorJS", + "playable-browser-ruffle": "Може да се играе в браузъра чрез Ruffle", + "playable-none": "Не може да се играе в браузъра или чрез стрийминг", + "playable-stream": "Може да се стриймва от {label}", "player-count": "Брой играчи", "properties": "Свойства", "random-rom": "Случаен ROM", diff --git a/frontend/src/locales/bg_BG/play.json b/frontend/src/locales/bg_BG/play.json index 5930885258..e6c249fb74 100644 --- a/frontend/src/locales/bg_BG/play.json +++ b/frontend/src/locales/bg_BG/play.json @@ -4,15 +4,96 @@ "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": "Отмени избрания бърз запис", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Изтегляне на картата", + "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": "Цял екран", + "join-closed": "Тази сесия вече не е отворена за други играчи.", + "join-ended": "Тази сесия приключи.", + "keep-playing": "Продължи играта", + "leave-dialog-text": "Домакинът продължава да играе. Вие ще напуснете сесията.", + "leave-dialog-title": "Напускане на сесията?", + "leave-session": "Напускане на сесията", + "load-autosave": "Зареждане на автоматичното запазване", + "load-state": "Зареждане на състояние", + "manage-memory-cards": "Управление на картите памет", + "manual-disc-swap-hint": "Този емулатор сменя дисковете от собственото си меню, не оттук.", + "memory-card": "Карта памет", + "memory-card-count": "{count} карти", + "memory-card-create-failed": "Картата памет не може да бъде създадена", + "memory-card-created": "Картата памет е създадена", + "memory-card-delete-failed": "Картата памет не може да бъде изтрита", + "memory-card-deleted": "Картата памет е изтрита", + "memory-card-download-failed": "Картата памет не може да бъде изтеглена", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "По подразбиране се зарежда най-новата карта. Напредъкът се синхронизира при изход.", + "memory-card-import-adopt": "Импортирай тази карта", + "memory-card-import-body": "Този контейнер вече има карта памет с {count} файл(а) със записани игри. Да я импортираме ли в библиотеката ви, или да започнете с нова карта?", + "memory-card-import-discard": "Започни начисто", + "memory-card-import-discard-body": "Картата памет в този контейнер ще бъде изтрита. Нищо запазено на нея няма да може да се възстанови.", + "memory-card-import-discard-confirm": "Изтрий и започни начисто", + "memory-card-import-discard-title": "Да се изтрие ли съществуващата карта?", + "memory-card-import-games": "Игри на тази карта: {games}", + "memory-card-import-size": "Общо {size}", + "memory-card-import-title": "Открита е карта памет", + "memory-card-no-data": "Тази карта все още няма запазени данни", + "memory-card-no-versions": "Все още няма запазени версии", + "memory-card-rename-failed": "Картата памет не може да бъде преименувана", + "memory-card-renamed": "Картата памет е преименувана", + "memory-card-share-failed": "Споделянето на картата памет не може да бъде променено", + "memory-card-share-label": "Споделена с други потребители", + "memory-card-shared": "Споделена", + "memory-card-unreadable-body": "RomM не успя да прочете картата памет в този контейнер, затова не може да определи дали съдържа записани игри. Опитайте по-късно или започнете начисто, като изтриете всичко на нея.", + "memory-card-unreadable-override": "Започни начисто въпреки това", + "memory-card-unreadable-reason": "Причина: {reason}", + "memory-card-unreadable-title": "Картата памет не можа да бъде прочетена", + "memory-card-unreadable-warning": "Съществуващата карта ще бъде изтрита и няма да може да се възстанови.", + "memory-card-updated": "Обновена {when}", + "memory-card-upload-failed": "Картата памет не може да бъде качена", + "memory-card-uploaded": "Картата памет е качена", + "memory-card-versions": "История на версиите", + "memory-cards": "Карти памет", + "memory-cards-empty": "Все още нямате карти памет за този емулатор.", + "multiplayer": "Мултиплейър режим", + "multiplayer-hint": "Показва бутон „Присъединяване“ на страницата на играта и запазва чата и уеб камерата видими.", + "mute": "Заглушаване", + "new-memory-card": "Нова карта", + "no-memory-cards": "Все още няма карти памет", "no-save-selected": "Няма избран запис", "no-saves-available": "Няма налични записи", "no-screenshot-available": "Няма налична екранна снимка", @@ -20,17 +101,37 @@ "no-states-available": "Няма налични бързи записи", "page-title": "Играй {name}", "play": "Играй", + "play-on": "Игра на {label}", "powered-by": "Захранено от", "quit": "Излез", + "rename-memory-card": "Преименуване на карта памет", + "resume-failed": "Избраното състояние не можа да бъде заредено. Играта започна отначало.", "resume-from-save": "Продължи от запис", "resume-from-state": "Продължи от бърз запис", "save-and-quit": "Запази и излез", + "save-data": "Записани данни", + "save-data-detail": "Обновено {time} · {size}", + "save-data-none": "Все още няма записани данни", + "save-data-none-hint": "{platform} пази напредъка в собствения запис на играта", + "save-data-none-note": "Играйте и запишете в играта. Ще се синхронизира при края на сесията.", + "save-data-note": "Възстановено на конзолата преди стартиране. Заредете го от менюто на самата игра.", + "save-data-synced": "Синхронизирано", + "save-slot": "Слот за запазване", + "save-state": "Запазване на състояние", "select-background-color": "Избери цвят на фона", "select-save": "Избери запис", "select-state": "Избери бърз запис", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Показване на рамка", "slot": "Слот", "start-fresh-hint": "Избери един по-долу, за да продължиш, или натисни Играй, за да започнеш отначало.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Възникна неочаквана грешка.", "stream-error-load-rom": "Данните за ROM не можаха да се заредят.", "stream-error-not-configured": "Няма конфигуриран стрийминг контейнер за {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Изход от цял екран", "stream-frame-title": "Стрийм на играта", "stream-fullscreen": "Цял екран", - "stream-load-autosave": "Зареждане на автоматично записване", "stream-load-state": "Зареждане на състояние", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Заглушаване", "stream-occupied-body": "{rom} се играе от {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "В момента играе някой друг. Опитайте отново по-късно.", "stream-occupied-title": "Сесията е заета", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Запис и изход", - "stream-save-slot": "Слот за запис", "stream-save-state": "Запис на състояние", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "Емулаторът не потвърди записа. Скорошният напредък може да бъде загубен.", - "stream-slot-n": "Слот {n}", "stream-stop": "Спиране", "stream-subtitle": "Стрийминг", + "stream-swap-disc": "Смяна на диск", "stream-try-again": "Опитайте отново", "stream-unknown-game": "Неизвестна игра", "stream-unmute": "Включване на звука", - "stream-volume": "Сила на звука" + "stream-volume": "Сила на звука", + "streaming-description": "Играта работи в специален контейнер {label} и се предава директно към вашия браузър.", + "swap-disc-confirm": "Смени", + "swap-disc-failed": "Смяната на диска не бе успешна. Конзолата може още да е по средата на смяната, опитайте отново.", + "swap-disc-text": "Изберете диска за зареждане. Играта продължава да работи, затова първо запазете напредъка си в нея.", + "swap-disc-title": "Смяна на диск", + "upload-memory-card": "Качване на карта" } diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json index 8142f163ba..863d50a6d1 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -59,6 +59,9 @@ "completion": "Завършеност", "completionist": "Перфекционист", "confirm-delete-note": "Сигурен ли си, че искаш да изтриеш бележката \"{title}\"?", + "confirm-join-body": "Ще бъдете добавени към „{name}“ като допълнителен играч. Домакинът запазва контрола над сесията и нейните записи.", + "confirm-join-title": "Присъединяване към тази сесия?", + "confirm-join-title-of": "Присъединяване към сесията на {user}?", "confirm-launch-protected-body": "Отбелязахте „{name}“ като {status}. Искате ли все пак да я играете?", "confirm-launch-protected-title": "Стартиране на тази игра?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Скрито", "how-long-to-beat": "How Long to Beat", "info": "Информация", + "join-session": "Присъединяване към сесия", + "join-session-of": "Присъединяване към сесията на {user}", "languages": "Езици", "last-played": "Последно играна", "launchbox-cloud": "Облак", @@ -426,6 +431,8 @@ "status-never-playing": "Неиграна никога", "status-now-playing": "Играна в момента", "status-retired": "Изоставена", + "stream": "Стрийминг", + "stream-on": "Стрийминг на {container}", "summary": "Резюме", "switch-version": "Смени версията", "tab-achievements": "Постижения", diff --git a/frontend/src/locales/bg_BG/settings.json b/frontend/src/locales/bg_BG/settings.json index b7fd0bfd74..e02eb1e103 100644 --- a/frontend/src/locales/bg_BG/settings.json +++ b/frontend/src/locales/bg_BG/settings.json @@ -437,6 +437,25 @@ "sort-size": "Размер", "states": "Бързи записи", "stopped": "Спряно", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Резюме", "task-failed": "Задачата е неуспешна", "task-history": "История на задачите", diff --git a/frontend/src/locales/cs_CZ/activity.json b/frontend/src/locales/cs_CZ/activity.json index b4d9ff2ab8..55d2a85966 100644 --- a/frontend/src/locales/cs_CZ/activity.json +++ b/frontend/src/locales/cs_CZ/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/cs_CZ/platform.json index a93696d86f..507b63222a 100644 --- a/frontend/src/locales/cs_CZ/platform.json +++ b/frontend/src/locales/cs_CZ/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Staré čtvercové obaly", "on-disk": "Na disku", "only-with-games": "Pouze platformy s hrami", + "playable-both": "Hratelné v prohlížeči a streamovatelné z {label}", + "playable-browser-dosbox": "Hratelné v prohlížeči přes DOSBox", + "playable-browser-emulatorjs": "Hratelné v prohlížeči přes EmulatorJS", + "playable-browser-ruffle": "Hratelné v prohlížeči přes Ruffle", + "playable-none": "Nelze hrát v prohlížeči ani streamovat", + "playable-stream": "Streamovatelné z {label}", "player-count": "Počet hráčů", "properties": "Vlastnosti", "random-rom": "Náhodné ROM", diff --git a/frontend/src/locales/cs_CZ/play.json b/frontend/src/locales/cs_CZ/play.json index 6b5f99f112..9a0f37ffa5 100644 --- a/frontend/src/locales/cs_CZ/play.json +++ b/frontend/src/locales/cs_CZ/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Stáhnout kartu", + "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", + "join-closed": "Tato relace už není otevřená dalším hráčům.", + "join-ended": "Tato relace skončila.", + "keep-playing": "Pokračovat ve hře", + "leave-dialog-text": "Hostitel hraje dál. Vy z relace odejdete.", + "leave-dialog-title": "Opustit relaci?", + "leave-session": "Opustit relaci", + "load-autosave": "Načíst automatické uložení", + "load-state": "Načíst stav", + "manage-memory-cards": "Spravovat paměťové karty", + "manual-disc-swap-hint": "Tento emulátor mění disky ve své vlastní nabídce, ne odsud.", + "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-download-failed": "Paměťovou kartu se nepodařilo stáhnout", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "Ve výchozím nastavení se načte nejnovější karta. Postup se synchronizuje při ukončení.", + "memory-card-import-adopt": "Importovat tuto kartu", + "memory-card-import-body": "Tento kontejner už má paměťovou kartu s {count} soubory uložených pozic. Chcete ji importovat do své knihovny, nebo začít s novou kartou?", + "memory-card-import-discard": "Začít znovu", + "memory-card-import-discard-body": "Paměťová karta v tomto kontejneru bude vymazána. Nic, co je na ní uloženo, nepůjde obnovit.", + "memory-card-import-discard-confirm": "Vymazat a začít znovu", + "memory-card-import-discard-title": "Vymazat stávající kartu?", + "memory-card-import-games": "Hry na této kartě: {games}", + "memory-card-import-size": "Celkem {size}", + "memory-card-import-title": "Nalezena paměťová karta", + "memory-card-no-data": "Tato karta zatím neobsahuje žádná uložená data", + "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-unreadable-body": "RomM nedokázal přečíst paměťovou kartu v tomto kontejneru, takže nelze zjistit, zda obsahuje uložené pozice. Zkuste to později, nebo začněte znovu a vymažte vše, co je na ní.", + "memory-card-unreadable-override": "Přesto začít znovu", + "memory-card-unreadable-reason": "Důvod: {reason}", + "memory-card-unreadable-title": "Paměťovou kartu se nepodařilo přečíst", + "memory-card-unreadable-warning": "Stávající karta bude vymazána a nepůjde obnovit.", + "memory-card-updated": "Aktualizováno {when}", + "memory-card-upload-failed": "Paměťovou kartu se nepodařilo nahrát", + "memory-card-uploaded": "Paměťová karta nahrána", + "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.", + "multiplayer": "Režim pro více hráčů", + "multiplayer-hint": "Na stránce hry se zobrazí tlačítko Připojit se a chat i webkamera zůstanou viditelné.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "Nejsou k dispozici žádné stavy", "page-title": "Hrát {name}", "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-data": "Uložená data", + "save-data-detail": "Aktualizováno {time} · {size}", + "save-data-none": "Zatím žádná uložená data", + "save-data-none-hint": "{platform} ukládá postup do vlastní pozice hry", + "save-data-none-note": "Hrajte a uložte ve hře. Po skončení relace se data synchronizují.", + "save-data-note": "Obnoveno do konzole před spuštěním. Načtěte je z nabídky samotné hry.", + "save-data-synced": "Synchronizováno", + "save-slot": "Pozice uložení", + "save-state": "Uložit stav", "select-background-color": "Vybrat barvu pozadí", "select-save": "Vybrat uloženou pozici", "select-state": "Vybrat stav", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Zobrazit rámeček", "slot": "Slot", "start-fresh-hint": "Vyberte níže pro pokračování, nebo stiskněte Hrát pro nový začátek.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Došlo k neočekávané chybě.", "stream-error-load-rom": "Podrobnosti o ROM se nepodařilo načíst.", "stream-error-not-configured": "Pro platformu {platform} není nakonfigurován žádný streamovací kontejner.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Ukončit celou obrazovku", "stream-frame-title": "Stream hry", "stream-fullscreen": "Celá obrazovka", - "stream-load-autosave": "Načíst automatické uložení", "stream-load-state": "Načíst stav", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Ztlumit", "stream-occupied-body": "{rom} se hraje od {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Právě hraje někdo jiný. Zkuste to později.", "stream-occupied-title": "Relace se používá", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Uložit a ukončit", - "stream-save-slot": "Slot uložení", "stream-save-state": "Uložit stav", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "Emulátor nepotvrdil uložení. Nedávný postup může být ztracen.", - "stream-slot-n": "Slot {n}", "stream-stop": "Zastavit", "stream-subtitle": "Streamování", + "stream-swap-disc": "Vyměnit disk", "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.", + "swap-disc-confirm": "Vyměnit", + "swap-disc-failed": "Výměna disku selhala. Konzole může být stále uprostřed výměny, zkuste to znovu.", + "swap-disc-text": "Vyberte disk, který se má načíst. Hra běží dál, takže si nejprve uložte postup ve hře.", + "swap-disc-title": "Výměna disku", + "upload-memory-card": "Nahrát kartu" } diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index 0bc0bb6a6f..c203c1a15a 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -59,6 +59,9 @@ "completion": "Dokončení", "completionist": "Kompletista", "confirm-delete-note": "Opravdu chcete smazat poznámku \"{title}\"?", + "confirm-join-body": "Budete přidáni do hry „{name}“ jako další hráč. Hostitel si ponechá kontrolu nad relací a jejími uloženými pozicemi.", + "confirm-join-title": "Připojit se k této relaci?", + "confirm-join-title-of": "Připojit se k relaci uživatele {user}?", "confirm-launch-protected-body": "Označili jste „{name}“ jako {status}. Přesto ji chcete hrát?", "confirm-launch-protected-title": "Spustit tuto hru?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Skryté", "how-long-to-beat": "Délka do dokončení", "info": "Info", + "join-session": "Připojit se k relaci", + "join-session-of": "Připojit se k relaci uživatele {user}", "languages": "Jazyky", "last-played": "Naposledy hráno", "launchbox-cloud": "Cloud", @@ -426,6 +431,8 @@ "status-never-playing": "Nikdy nebudu hrát", "status-now-playing": "Právě hraji", "status-retired": "Opuštěno", + "stream": "Streamovat", + "stream-on": "Streamovat na {container}", "summary": "Souhrn", "switch-version": "Přepnout verzi", "tab-achievements": "Achievementy", diff --git a/frontend/src/locales/cs_CZ/settings.json b/frontend/src/locales/cs_CZ/settings.json index a494853038..6f223cbf8b 100644 --- a/frontend/src/locales/cs_CZ/settings.json +++ b/frontend/src/locales/cs_CZ/settings.json @@ -437,6 +437,25 @@ "sort-size": "Velikost", "states": "Stavy", "stopped": "Zastaveno", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Souhrn", "task-failed": "Úloha selhala", "task-history": "Historie úloh", diff --git a/frontend/src/locales/de_DE/activity.json b/frontend/src/locales/de_DE/activity.json index 48eca97d1f..1c893905be 100644 --- a/frontend/src/locales/de_DE/activity.json +++ b/frontend/src/locales/de_DE/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/de_DE/platform.json index 5443acfd07..d46e474c7c 100644 --- a/frontend/src/locales/de_DE/platform.json +++ b/frontend/src/locales/de_DE/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Alte quadratische Hüllen", "on-disk": "Auf Datenträger", "only-with-games": "Nur Plattformen mit Spielen", + "playable-both": "Im Browser spielbar und streambar über {label}", + "playable-browser-dosbox": "Im Browser spielbar über DOSBox", + "playable-browser-emulatorjs": "Im Browser spielbar über EmulatorJS", + "playable-browser-ruffle": "Im Browser spielbar über Ruffle", + "playable-none": "Weder im Browser spielbar noch streambar", + "playable-stream": "Streambar über {label}", "player-count": "Spieleranzahl", "properties": "Eigenschaften", "random-rom": "Zufälliges ROM", diff --git a/frontend/src/locales/de_DE/play.json b/frontend/src/locales/de_DE/play.json index c3424681d6..d579806af2 100644 --- a/frontend/src/locales/de_DE/play.json +++ b/frontend/src/locales/de_DE/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Karte herunterladen", + "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", + "join-closed": "Diese Sitzung ist nicht mehr für andere Spieler offen.", + "join-ended": "Diese Sitzung ist beendet.", + "keep-playing": "Weiterspielen", + "leave-dialog-text": "Der Gastgeber spielt weiter. Du verlässt die Sitzung.", + "leave-dialog-title": "Sitzung verlassen?", + "leave-session": "Sitzung verlassen", + "load-autosave": "Automatische Speicherung laden", + "load-state": "Spielstand laden", + "manage-memory-cards": "Speicherkarten verwalten", + "manual-disc-swap-hint": "Dieser Emulator wechselt Discs über sein eigenes Menü, nicht von hier aus.", + "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-download-failed": "Speicherkarte konnte nicht heruntergeladen werden", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "Die neueste Karte wird standardmäßig geladen. Der Fortschritt wird beim Beenden zurücksynchronisiert.", + "memory-card-import-adopt": "Diese Karte importieren", + "memory-card-import-body": "Dieser Container hat bereits eine Speicherkarte mit {count} Speicherdatei(en). Möchtest du sie in deine Bibliothek importieren oder mit einer neuen Karte starten?", + "memory-card-import-discard": "Neu anfangen", + "memory-card-import-discard-body": "Die Speicherkarte auf diesem Container wird gelöscht. Alles, was darauf gespeichert ist, kann nicht wiederhergestellt werden.", + "memory-card-import-discard-confirm": "Löschen und neu anfangen", + "memory-card-import-discard-title": "Vorhandene Karte löschen?", + "memory-card-import-games": "Spiele auf dieser Karte: {games}", + "memory-card-import-size": "{size} insgesamt", + "memory-card-import-title": "Speicherkarte gefunden", + "memory-card-no-data": "Diese Karte enthält noch keine gespeicherten Daten", + "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-unreadable-body": "RomM konnte die Speicherkarte auf diesem Container nicht lesen und kann daher nicht feststellen, ob sie Spielstände enthält. Versuche es später erneut oder fange neu an und lösche alles, was darauf ist.", + "memory-card-unreadable-override": "Trotzdem neu anfangen", + "memory-card-unreadable-reason": "Grund: {reason}", + "memory-card-unreadable-title": "Speicherkarte konnte nicht gelesen werden", + "memory-card-unreadable-warning": "Die vorhandene Karte wird gelöscht und kann nicht wiederhergestellt werden.", + "memory-card-updated": "Aktualisiert {when}", + "memory-card-upload-failed": "Speicherkarte konnte nicht hochgeladen werden", + "memory-card-uploaded": "Speicherkarte hochgeladen", + "memory-card-versions": "Versionsverlauf", + "memory-cards": "Speicherkarten", + "memory-cards-empty": "Du hast noch keine Speicherkarten für diesen Emulator.", + "multiplayer": "Mehrspielermodus", + "multiplayer-hint": "Zeigt auf der Seite dieses Spiels eine Schaltfläche zum Beitreten und hält Chat und Webcam sichtbar.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "Keine Zustände verfügbar", "page-title": "{name} spielen", "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-data": "Spielstand", + "save-data-detail": "Aktualisiert {time} · {size}", + "save-data-none": "Noch kein Spielstand", + "save-data-none-hint": "{platform} speichert den Fortschritt im eigenen Spielstand des Spiels", + "save-data-none-note": "Spiele und speichere im Spiel. Der Stand wird am Ende der Sitzung synchronisiert.", + "save-data-note": "Vor dem Start auf die Konsole übertragen. Lade ihn im Menü des Spiels.", + "save-data-synced": "Synchronisiert", + "save-slot": "Speicherplatz", + "save-state": "Spielstand speichern", "select-background-color": "Hintergrundfarbe auswählen", "select-save": "Speicherstand auswählen", "select-state": "Speicherstand auswählen", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Bezel anzeigen", "slot": "Slot", "start-fresh-hint": "Wähle unten einen, um fortzufahren, oder klicke auf Play, um neu zu starten.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Ein unerwarteter Fehler ist aufgetreten.", "stream-error-load-rom": "ROM-Details konnten nicht geladen werden.", "stream-error-not-configured": "Für {platform} ist kein Streaming-Container konfiguriert.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Vollbild beenden", "stream-frame-title": "Spiel-Stream", "stream-fullscreen": "Vollbild", - "stream-load-autosave": "Automatische Speicherung laden", "stream-load-state": "Spielstand laden", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Stummschalten", "stream-occupied-body": "{rom} wird seit {time} gespielt.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Jemand anderes spielt gerade. Versuchen Sie es später erneut.", "stream-occupied-title": "Sitzung in Benutzung", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Speichern und beenden", - "stream-save-slot": "Speicherplatz", "stream-save-state": "Spielstand speichern", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "Der Emulator hat die Speicherung nicht bestätigt. Der letzte Fortschritt könnte verloren gehen.", - "stream-slot-n": "Platz {n}", "stream-stop": "Stopp", "stream-subtitle": "Streaming", + "stream-swap-disc": "Disc wechseln", "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.", + "swap-disc-confirm": "Wechseln", + "swap-disc-failed": "Der Disc-Wechsel ist fehlgeschlagen. Die Konsole steckt möglicherweise noch im Wechsel, versuche es erneut.", + "swap-disc-text": "Wähle die Disc, die geladen werden soll. Das Spiel läuft weiter, speichere deinen Fortschritt also zuerst im Spiel.", + "swap-disc-title": "Disc wechseln", + "upload-memory-card": "Karte hochladen" } diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index 73a1e6b2be..3fcc1f5ca6 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -59,6 +59,9 @@ "completion": "Durchgespielt", "completionist": "Vollendung", "confirm-delete-note": "Sind Sie sicher, dass Sie die Notiz \"{title}\" löschen möchten?", + "confirm-join-body": "Du wirst „{name}“ als zusätzlicher Spieler hinzugefügt. Der Host behält die Kontrolle über die Sitzung und ihre Spielstände.", + "confirm-join-title": "Dieser Sitzung beitreten?", + "confirm-join-title-of": "Sitzung von {user} beitreten?", "confirm-launch-protected-body": "Du hast „{name}“ als {status} markiert. Möchtest du es trotzdem spielen?", "confirm-launch-protected-title": "Dieses Spiel starten?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Versteckt", "how-long-to-beat": "Spieldauer", "info": "Info", + "join-session": "Sitzung beitreten", + "join-session-of": "Sitzung von {user} beitreten", "languages": "Sprachen", "last-played": "Zuletzt gespielt", "launchbox-cloud": "Cloud", @@ -426,6 +431,8 @@ "status-never-playing": "Nie spielen", "status-now-playing": "Wird gespielt", "status-retired": "Aufgegeben", + "stream": "Streamen", + "stream-on": "Auf {container} streamen", "summary": "Zusammenfassung", "switch-version": "Version wechseln", "tab-achievements": "Erfolge", diff --git a/frontend/src/locales/de_DE/settings.json b/frontend/src/locales/de_DE/settings.json index 21172fa166..723c1b2e1f 100644 --- a/frontend/src/locales/de_DE/settings.json +++ b/frontend/src/locales/de_DE/settings.json @@ -437,6 +437,25 @@ "sort-size": "Größe", "states": "Zustände", "stopped": "Gestoppt", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Zusammenfassung", "task-failed": "Aufgabe fehlgeschlagen", "task-history": "Aufgabenhistorie", diff --git a/frontend/src/locales/en_GB/activity.json b/frontend/src/locales/en_GB/activity.json index e9af67bf1b..bcd2acb7ab 100644 --- a/frontend/src/locales/en_GB/activity.json +++ b/frontend/src/locales/en_GB/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/en_GB/platform.json index 771008554b..8002cc4cab 100644 --- a/frontend/src/locales/en_GB/platform.json +++ b/frontend/src/locales/en_GB/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Old squared cases", "on-disk": "On disk", "only-with-games": "Only platforms with games", + "playable-both": "Playable in browser and streamable from {label}", + "playable-browser-dosbox": "Playable in browser through DOSBox", + "playable-browser-emulatorjs": "Playable in browser through EmulatorJS", + "playable-browser-ruffle": "Playable in browser through Ruffle", + "playable-none": "Not playable in browser or by streaming", + "playable-stream": "Streamable from {label}", "player-count": "Player count", "properties": "Properties", "random-rom": "Random ROM", diff --git a/frontend/src/locales/en_GB/play.json b/frontend/src/locales/en_GB/play.json index c4d88ca752..59794bea1a 100644 --- a/frontend/src/locales/en_GB/play.json +++ b/frontend/src/locales/en_GB/play.json @@ -4,15 +4,96 @@ "back-to-gallery": "Back to gallery", "back-to-game-details": "Back to game details", "background-color": "Background colour", + "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Download card", + "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", + "join-closed": "That session is no longer open to other players.", + "join-ended": "That session has ended.", + "keep-playing": "Keep playing", + "leave-dialog-text": "The host keeps playing. You will drop out of the session.", + "leave-dialog-title": "Leave session?", + "leave-session": "Leave session", + "load-autosave": "Load autosave", + "load-state": "Load state", + "manage-memory-cards": "Manage memory cards", + "manual-disc-swap-hint": "This emulator changes discs from its own menu, not from here.", + "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-download-failed": "Could not download the memory card", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "The newest card loads by default. Progress syncs back when you exit.", + "memory-card-import-adopt": "Import this card", + "memory-card-import-body": "This container already has a memory card holding {count} save file(s). Import it into your library, or start with a fresh card?", + "memory-card-import-discard": "Start fresh", + "memory-card-import-discard-body": "The memory card on this container will be erased. Anything saved on it cannot be recovered.", + "memory-card-import-discard-confirm": "Erase and start fresh", + "memory-card-import-discard-title": "Erase the existing card?", + "memory-card-import-games": "Games on this card: {games}", + "memory-card-import-size": "{size} in total", + "memory-card-import-title": "Memory card found", + "memory-card-no-data": "This card has no saved data yet", + "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-unreadable-body": "RomM could not read the memory card on this container, so it cannot tell whether it holds saves. Try again later, or start fresh and erase whatever is on it.", + "memory-card-unreadable-override": "Start fresh anyway", + "memory-card-unreadable-reason": "Reason: {reason}", + "memory-card-unreadable-title": "Memory card could not be read", + "memory-card-unreadable-warning": "The existing card will be erased and cannot be recovered.", + "memory-card-updated": "Updated {when}", + "memory-card-upload-failed": "Could not upload the memory card", + "memory-card-uploaded": "Memory card uploaded", + "memory-card-versions": "Version history", + "memory-cards": "Memory cards", + "memory-cards-empty": "You don't have any memory cards for this emulator yet.", + "multiplayer": "Multiplayer mode", + "multiplayer-hint": "Shows a Join button on this game's page and keeps chat and webcam visible.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "No states available", "page-title": "Play {name}", "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-data": "Save data", + "save-data-detail": "Updated {time} · {size}", + "save-data-none": "No save data yet", + "save-data-none-hint": "{platform} keeps progress in the game's own save", + "save-data-none-note": "Play, then save in-game. It syncs back when the session ends.", + "save-data-note": "Restored to the console before launch. Load it from the game's own menu.", + "save-data-synced": "Synced", + "save-slot": "Save slot", + "save-state": "Save state", "select-background-color": "Select background colour", "select-save": "Select save", "select-state": "Select state", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Show bezel", "slot": "Slot", "start-fresh-hint": "Pick one below to resume, or hit Play to start fresh.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "An unexpected error occurred.", "stream-error-load-rom": "Could not load ROM details.", "stream-error-not-configured": "No streaming container is configured for {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Exit fullscreen", "stream-frame-title": "Game stream", "stream-fullscreen": "Fullscreen", - "stream-load-autosave": "Load autosave", "stream-load-state": "Load state", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Mute", "stream-occupied-body": "{rom} has been playing since {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Someone else is currently playing. Try again later.", "stream-occupied-title": "Session in use", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Save and exit", - "stream-save-slot": "Save slot", "stream-save-state": "Save state", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "The emulator did not confirm the save. Recent progress may be lost.", - "stream-slot-n": "Slot {n}", "stream-stop": "Stop", "stream-subtitle": "Streaming", + "stream-swap-disc": "Swap disc", "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.", + "swap-disc-confirm": "Swap", + "swap-disc-failed": "Disc swap failed. The console may still be mid-swap, try again.", + "swap-disc-text": "Choose the disc to load. The game keeps running, so save your progress in-game first.", + "swap-disc-title": "Swap disc", + "upload-memory-card": "Upload card" } diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index 52e0c5ad66..57a653e41e 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -59,6 +59,9 @@ "completion": "Completion", "completionist": "Completionist", "confirm-delete-note": "Are you sure you want to delete the note \"{title}\"?", + "confirm-join-body": "You will be added to \"{name}\" as an extra player. The host keeps control of the session and its saves.", + "confirm-join-title": "Join this session?", + "confirm-join-title-of": "Join {user}'s session?", "confirm-launch-protected-body": "You marked \"{name}\" as {status}. Do you want to play it anyway?", "confirm-launch-protected-title": "Launch this game?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Hidden", "how-long-to-beat": "How Long to Beat", "info": "Info", + "join-session": "Join session", + "join-session-of": "Join {user}'s session", "languages": "Languages", "last-played": "Last played", "launchbox-cloud": "Cloud", @@ -426,6 +431,8 @@ "status-never-playing": "Never Playing", "status-now-playing": "Now Playing", "status-retired": "Retired", + "stream": "Stream", + "stream-on": "Stream on {container}", "summary": "Summary", "switch-version": "Switch version", "tab-achievements": "Achievements", diff --git a/frontend/src/locales/en_GB/settings.json b/frontend/src/locales/en_GB/settings.json index 54ef74511d..03e7afba1b 100644 --- a/frontend/src/locales/en_GB/settings.json +++ b/frontend/src/locales/en_GB/settings.json @@ -437,6 +437,25 @@ "sort-size": "Size", "states": "States", "stopped": "Stopped", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Summary", "task-failed": "Task failed", "task-history": "Task History", diff --git a/frontend/src/locales/en_US/activity.json b/frontend/src/locales/en_US/activity.json index e9af67bf1b..bcd2acb7ab 100644 --- a/frontend/src/locales/en_US/activity.json +++ b/frontend/src/locales/en_US/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/en_US/platform.json index b30da50184..3e7a531e66 100644 --- a/frontend/src/locales/en_US/platform.json +++ b/frontend/src/locales/en_US/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Old squared cases", "on-disk": "On disk", "only-with-games": "Only platforms with games", + "playable-both": "Playable in browser and streamable from {label}", + "playable-browser-dosbox": "Playable in browser through DOSBox", + "playable-browser-emulatorjs": "Playable in browser through EmulatorJS", + "playable-browser-ruffle": "Playable in browser through Ruffle", + "playable-none": "Not playable in browser or by streaming", + "playable-stream": "Streamable from {label}", "player-count": "Player count", "properties": "Properties", "random-rom": "Random ROM", diff --git a/frontend/src/locales/en_US/play.json b/frontend/src/locales/en_US/play.json index f10b27d60e..8ac5f6a427 100644 --- a/frontend/src/locales/en_US/play.json +++ b/frontend/src/locales/en_US/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Download card", + "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", + "join-closed": "That session is no longer open to other players.", + "join-ended": "That session has ended.", + "keep-playing": "Keep playing", + "leave-dialog-text": "The host keeps playing. You will drop out of the session.", + "leave-dialog-title": "Leave session?", + "leave-session": "Leave session", + "load-autosave": "Load autosave", + "load-state": "Load state", + "manage-memory-cards": "Manage memory cards", + "manual-disc-swap-hint": "This emulator changes discs from its own menu, not from here.", + "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-download-failed": "Could not download the memory card", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "The newest card loads by default. Progress syncs back when you exit.", + "memory-card-import-adopt": "Import this card", + "memory-card-import-body": "This container already has a memory card holding {count} save file(s). Import it into your library, or start with a fresh card?", + "memory-card-import-discard": "Start fresh", + "memory-card-import-discard-body": "The memory card on this container will be erased. Anything saved on it cannot be recovered.", + "memory-card-import-discard-confirm": "Erase and start fresh", + "memory-card-import-discard-title": "Erase the existing card?", + "memory-card-import-games": "Games on this card: {games}", + "memory-card-import-size": "{size} in total", + "memory-card-import-title": "Memory card found", + "memory-card-no-data": "This card has no saved data yet", + "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-unreadable-body": "RomM could not read the memory card on this container, so it cannot tell whether it holds saves. Try again later, or start fresh and erase whatever is on it.", + "memory-card-unreadable-override": "Start fresh anyway", + "memory-card-unreadable-reason": "Reason: {reason}", + "memory-card-unreadable-title": "Memory card could not be read", + "memory-card-unreadable-warning": "The existing card will be erased and cannot be recovered.", + "memory-card-updated": "Updated {when}", + "memory-card-upload-failed": "Could not upload the memory card", + "memory-card-uploaded": "Memory card uploaded", + "memory-card-versions": "Version history", + "memory-cards": "Memory cards", + "memory-cards-empty": "You don't have any memory cards for this emulator yet.", + "multiplayer": "Multiplayer mode", + "multiplayer-hint": "Shows a Join button on this game's page and keeps chat and webcam visible.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "No states available", "page-title": "Play {name}", "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-data": "Save data", + "save-data-detail": "Updated {time} · {size}", + "save-data-none": "No save data yet", + "save-data-none-hint": "{platform} keeps progress in the game's own save", + "save-data-none-note": "Play, then save in-game. It syncs back when the session ends.", + "save-data-note": "Restored to the console before launch. Load it from the game's own menu.", + "save-data-synced": "Synced", + "save-slot": "Save slot", + "save-state": "Save state", "select-background-color": "Select background color", "select-save": "Select save", "select-state": "Select state", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Show bezel", "slot": "Slot", "start-fresh-hint": "Pick one below to resume, or hit Play to start fresh.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "An unexpected error occurred.", "stream-error-load-rom": "Could not load ROM details.", "stream-error-not-configured": "No streaming container is configured for {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Exit fullscreen", "stream-frame-title": "Game stream", "stream-fullscreen": "Fullscreen", - "stream-load-autosave": "Load autosave", "stream-load-state": "Load state", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Mute", "stream-occupied-body": "{rom} has been playing since {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Someone else is currently playing. Try again later.", "stream-occupied-title": "Session in use", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Save and exit", - "stream-save-slot": "Save slot", "stream-save-state": "Save state", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "The emulator did not confirm the save. Recent progress may be lost.", - "stream-slot-n": "Slot {n}", "stream-stop": "Stop", "stream-subtitle": "Streaming", + "stream-swap-disc": "Swap disc", "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.", + "swap-disc-confirm": "Swap", + "swap-disc-failed": "Disc swap failed. The console may still be mid-swap, try again.", + "swap-disc-text": "Choose the disc to load. The game keeps running, so save your progress in-game first.", + "swap-disc-title": "Swap disc", + "upload-memory-card": "Upload card" } diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index aae1cd4ff8..9fb52aff77 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -59,6 +59,9 @@ "completion": "Completion", "completionist": "Completionist", "confirm-delete-note": "Are you sure you want to delete the note \"{title}\"?", + "confirm-join-body": "You will be added to \"{name}\" as an extra player. The host keeps control of the session and its saves.", + "confirm-join-title": "Join this session?", + "confirm-join-title-of": "Join {user}'s session?", "confirm-launch-protected-body": "You marked \"{name}\" as {status}. Do you want to play it anyway?", "confirm-launch-protected-title": "Launch this game?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Hidden", "how-long-to-beat": "How Long to Beat", "info": "Info", + "join-session": "Join session", + "join-session-of": "Join {user}'s session", "languages": "Languages", "last-played": "Last played", "launchbox-cloud": "Cloud", @@ -426,6 +431,8 @@ "status-never-playing": "Never Playing", "status-now-playing": "Now Playing", "status-retired": "Retired", + "stream": "Stream", + "stream-on": "Stream on {container}", "summary": "Summary", "switch-version": "Switch version", "tab-achievements": "Achievements", diff --git a/frontend/src/locales/en_US/settings.json b/frontend/src/locales/en_US/settings.json index fcf25c4e83..fd81cc90fe 100644 --- a/frontend/src/locales/en_US/settings.json +++ b/frontend/src/locales/en_US/settings.json @@ -437,6 +437,25 @@ "sort-size": "Size", "states": "States", "stopped": "Stopped", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Summary", "task-failed": "Task failed", "task-history": "Task History", diff --git a/frontend/src/locales/es_ES/activity.json b/frontend/src/locales/es_ES/activity.json index 40bb0676ca..59f2a7f090 100644 --- a/frontend/src/locales/es_ES/activity.json +++ b/frontend/src/locales/es_ES/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/es_ES/platform.json index d6a53bf4f9..d23a5473e6 100644 --- a/frontend/src/locales/es_ES/platform.json +++ b/frontend/src/locales/es_ES/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Cajas cuadradas antiguas", "on-disk": "En disco", "only-with-games": "Solo plataformas con juegos", + "playable-both": "Jugable en el navegador y transmisible desde {label}", + "playable-browser-dosbox": "Jugable en el navegador mediante DOSBox", + "playable-browser-emulatorjs": "Jugable en el navegador mediante EmulatorJS", + "playable-browser-ruffle": "Jugable en el navegador mediante Ruffle", + "playable-none": "No jugable en el navegador ni por transmisión", + "playable-stream": "Transmisible desde {label}", "player-count": "Número de jugadores", "properties": "Propiedades", "random-rom": "ROM aleatorio", diff --git a/frontend/src/locales/es_ES/play.json b/frontend/src/locales/es_ES/play.json index 2aa55a2896..fbae1a8b1a 100644 --- a/frontend/src/locales/es_ES/play.json +++ b/frontend/src/locales/es_ES/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Descargar tarjeta", + "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", + "join-closed": "Esa sesión ya no está abierta a otros jugadores.", + "join-ended": "Esa sesión ha terminado.", + "keep-playing": "Seguir jugando", + "leave-dialog-text": "El anfitrión sigue jugando. Tú saldrás de la sesión.", + "leave-dialog-title": "¿Salir de la sesión?", + "leave-session": "Salir de la sesión", + "load-autosave": "Cargar guardado automático", + "load-state": "Cargar estado", + "manage-memory-cards": "Gestionar tarjetas de memoria", + "manual-disc-swap-hint": "Este emulador cambia de disco desde su propio menú, no desde aquí.", + "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-download-failed": "No se pudo descargar la tarjeta de memoria", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "La tarjeta más reciente se carga de forma predeterminada. El progreso se sincroniza al salir.", + "memory-card-import-adopt": "Importar esta tarjeta", + "memory-card-import-body": "Este contenedor ya tiene una tarjeta de memoria con {count} archivo(s) de guardado. ¿Quieres importarla a tu biblioteca o empezar con una tarjeta nueva?", + "memory-card-import-discard": "Empezar de cero", + "memory-card-import-discard-body": "La tarjeta de memoria de este contenedor se borrará. Nada de lo guardado en ella se podrá recuperar.", + "memory-card-import-discard-confirm": "Borrar y empezar de cero", + "memory-card-import-discard-title": "¿Borrar la tarjeta existente?", + "memory-card-import-games": "Juegos en esta tarjeta: {games}", + "memory-card-import-size": "{size} en total", + "memory-card-import-title": "Tarjeta de memoria encontrada", + "memory-card-no-data": "Esta tarjeta aún no tiene datos guardados", + "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-unreadable-body": "RomM no ha podido leer la tarjeta de memoria de este contenedor, así que no puede saber si contiene partidas guardadas. Inténtalo más tarde o empieza de cero y borra lo que haya en ella.", + "memory-card-unreadable-override": "Empezar de cero de todos modos", + "memory-card-unreadable-reason": "Motivo: {reason}", + "memory-card-unreadable-title": "No se ha podido leer la tarjeta de memoria", + "memory-card-unreadable-warning": "La tarjeta existente se borrará y no se podrá recuperar.", + "memory-card-updated": "Actualizada {when}", + "memory-card-upload-failed": "No se pudo subir la tarjeta de memoria", + "memory-card-uploaded": "Tarjeta de memoria subida", + "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.", + "multiplayer": "Modo multijugador", + "multiplayer-hint": "Muestra un botón Unirse en la página de este juego y mantiene visibles el chat y la cámara web.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "No hay estados disponibles", "page-title": "Jugar a {name}", "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-data": "Datos de guardado", + "save-data-detail": "Actualizado {time} · {size}", + "save-data-none": "Aún no hay datos de guardado", + "save-data-none-hint": "{platform} guarda el progreso en la partida guardada del propio juego", + "save-data-none-note": "Juega y guarda dentro del juego. Se sincronizará al terminar la sesión.", + "save-data-note": "Restaurado en la consola antes de iniciar. Cárgalo desde el menú del propio juego.", + "save-data-synced": "Sincronizado", + "save-slot": "Ranura de guardado", + "save-state": "Guardar estado", "select-background-color": "Seleccionar color de fondo", "select-save": "Seleccionar guardado", "select-state": "Seleccionar estado", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Mostrar marco", "slot": "Ranura", "start-fresh-hint": "Empezar desde cero — sin partidas guardadas.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Se produjo un error inesperado.", "stream-error-load-rom": "No se pudieron cargar los detalles de la ROM.", "stream-error-not-configured": "No hay ningún contenedor de streaming configurado para {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Salir de pantalla completa", "stream-frame-title": "Transmisión del juego", "stream-fullscreen": "Pantalla completa", - "stream-load-autosave": "Cargar guardado automático", "stream-load-state": "Cargar estado", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Silenciar", "stream-occupied-body": "{rom} se está jugando desde las {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Otra persona está jugando en este momento. Inténtalo de nuevo más tarde.", "stream-occupied-title": "Sesión en uso", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Guardar y salir", - "stream-save-slot": "Ranura de guardado", "stream-save-state": "Guardar estado", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "El emulador no confirmó el guardado. El progreso reciente podría perderse.", - "stream-slot-n": "Ranura {n}", "stream-stop": "Detener", "stream-subtitle": "Transmisión", + "stream-swap-disc": "Cambiar disco", "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.", + "swap-disc-confirm": "Cambiar", + "swap-disc-failed": "No se pudo cambiar el disco. Puede que la consola siga a mitad del cambio, inténtalo de nuevo.", + "swap-disc-text": "Elige el disco que quieres cargar. El juego sigue en marcha, así que guarda tu progreso dentro del juego primero.", + "swap-disc-title": "Cambiar disco", + "upload-memory-card": "Subir tarjeta" } diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index 9f33303849..c2bcf49505 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -59,6 +59,9 @@ "completion": "Completado", "completionist": "Completista", "confirm-delete-note": "¿Estás seguro de que quieres eliminar la nota \"{title}\"?", + "confirm-join-body": "Te unirás a «{name}» como jugador adicional. El anfitrión mantiene el control de la sesión y de sus partidas guardadas.", + "confirm-join-title": "¿Unirse a esta sesión?", + "confirm-join-title-of": "¿Unirse a la sesión de {user}?", "confirm-launch-protected-body": "Marcaste «{name}» como {status}. ¿Quieres jugarlo de todos modos?", "confirm-launch-protected-title": "¿Iniciar este juego?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Oculto", "how-long-to-beat": "Tiempo de Juego", "info": "Info", + "join-session": "Unirse a la sesión", + "join-session-of": "Unirse a la sesión de {user}", "languages": "Idiomas", "last-played": "Última vez jugado", "launchbox-cloud": "Nube", @@ -426,6 +431,8 @@ "status-never-playing": "Nunca jugar", "status-now-playing": "Jugando", "status-retired": "Abandonado", + "stream": "Transmitir", + "stream-on": "Transmitir en {container}", "summary": "Resumen", "switch-version": "Cambiar versión", "tab-achievements": "Logros", diff --git a/frontend/src/locales/es_ES/settings.json b/frontend/src/locales/es_ES/settings.json index ebb59dd5f0..b725e5a5ef 100644 --- a/frontend/src/locales/es_ES/settings.json +++ b/frontend/src/locales/es_ES/settings.json @@ -437,6 +437,25 @@ "sort-size": "Tamaño", "states": "Estados", "stopped": "Detenido", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Resumen", "task-failed": "La tarea falló", "task-history": "Historial de tareas", diff --git a/frontend/src/locales/fr_FR/activity.json b/frontend/src/locales/fr_FR/activity.json index 7beffc28f4..f754c6aff5 100644 --- a/frontend/src/locales/fr_FR/activity.json +++ b/frontend/src/locales/fr_FR/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/fr_FR/platform.json index a3ef632f7e..23a0a653aa 100644 --- a/frontend/src/locales/fr_FR/platform.json +++ b/frontend/src/locales/fr_FR/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Anciens boîtiers carrés", "on-disk": "Sur le disque", "only-with-games": "Uniquement les plateformes avec des jeux", + "playable-both": "Jouable dans le navigateur et diffusable depuis {label}", + "playable-browser-dosbox": "Jouable dans le navigateur via DOSBox", + "playable-browser-emulatorjs": "Jouable dans le navigateur via EmulatorJS", + "playable-browser-ruffle": "Jouable dans le navigateur via Ruffle", + "playable-none": "Ni jouable dans le navigateur ni diffusable", + "playable-stream": "Diffusable depuis {label}", "player-count": "Nombre de joueurs", "properties": "Propriétés", "random-rom": "ROM aléatoire", diff --git a/frontend/src/locales/fr_FR/play.json b/frontend/src/locales/fr_FR/play.json index 56ad187cee..4987cd88d0 100644 --- a/frontend/src/locales/fr_FR/play.json +++ b/frontend/src/locales/fr_FR/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Télécharger la carte", + "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", + "join-closed": "Cette session n'est plus ouverte aux autres joueurs.", + "join-ended": "Cette session est terminée.", + "keep-playing": "Continuer à jouer", + "leave-dialog-text": "L'hôte continue de jouer. Vous quitterez la session.", + "leave-dialog-title": "Quitter la session ?", + "leave-session": "Quitter la session", + "load-autosave": "Charger la sauvegarde automatique", + "load-state": "Charger l'état", + "manage-memory-cards": "Gérer les cartes mémoire", + "manual-disc-swap-hint": "Cet émulateur change de disque depuis son propre menu, pas d'ici.", + "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-download-failed": "Impossible de télécharger la carte mémoire", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "La carte la plus récente est chargée par défaut. La progression est synchronisée à la sortie.", + "memory-card-import-adopt": "Importer cette carte", + "memory-card-import-body": "Ce conteneur possède déjà une carte mémoire contenant {count} fichier(s) de sauvegarde. Voulez-vous l'importer dans votre bibliothèque ou démarrer avec une carte vierge ?", + "memory-card-import-discard": "Repartir de zéro", + "memory-card-import-discard-body": "La carte mémoire de ce conteneur sera effacée. Rien de ce qui y est enregistré ne pourra être récupéré.", + "memory-card-import-discard-confirm": "Effacer et repartir de zéro", + "memory-card-import-discard-title": "Effacer la carte existante ?", + "memory-card-import-games": "Jeux sur cette carte : {games}", + "memory-card-import-size": "{size} au total", + "memory-card-import-title": "Carte mémoire détectée", + "memory-card-no-data": "Cette carte ne contient pas encore de données enregistrées", + "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-unreadable-body": "RomM n'a pas pu lire la carte mémoire de ce conteneur et ne peut donc pas savoir si elle contient des sauvegardes. Réessayez plus tard, ou repartez de zéro et effacez ce qu'elle contient.", + "memory-card-unreadable-override": "Repartir de zéro quand même", + "memory-card-unreadable-reason": "Raison : {reason}", + "memory-card-unreadable-title": "Impossible de lire la carte mémoire", + "memory-card-unreadable-warning": "La carte existante sera effacée et ne pourra pas être récupérée.", + "memory-card-updated": "Mise à jour {when}", + "memory-card-upload-failed": "Impossible d'envoyer la carte mémoire", + "memory-card-uploaded": "Carte mémoire envoyée", + "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.", + "multiplayer": "Mode multijoueur", + "multiplayer-hint": "Affiche un bouton Rejoindre sur la page de ce jeu et garde le chat et la webcam visibles.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "Aucun état disponible", "page-title": "Jouer à {name}", "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-data": "Données de sauvegarde", + "save-data-detail": "Mis à jour {time} · {size}", + "save-data-none": "Aucune donnée de sauvegarde", + "save-data-none-hint": "{platform} conserve la progression dans la sauvegarde du jeu", + "save-data-none-note": "Jouez, puis sauvegardez dans le jeu. La synchronisation se fait à la fin de la session.", + "save-data-note": "Restauré sur la console avant le lancement. Chargez-le depuis le menu du jeu.", + "save-data-synced": "Synchronisé", + "save-slot": "Emplacement 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", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Afficher le cadre", "slot": "Emplacement", "start-fresh-hint": "Choisissez-en un ci-dessous pour reprendre, ou appuyez sur Jouer pour démarrer une nouvelle partie.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Une erreur inattendue s'est produite.", "stream-error-load-rom": "Impossible de charger les détails de la ROM.", "stream-error-not-configured": "Aucun conteneur de streaming n'est configuré pour {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Quitter le plein écran", "stream-frame-title": "Flux du jeu", "stream-fullscreen": "Plein écran", - "stream-load-autosave": "Charger la sauvegarde automatique", "stream-load-state": "Charger l'état", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Couper le son", "stream-occupied-body": "{rom} est en cours de partie depuis {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Quelqu'un d'autre joue actuellement. Réessayez plus tard.", "stream-occupied-title": "Session en cours d'utilisation", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Enregistrer et quitter", - "stream-save-slot": "Emplacement de sauvegarde", "stream-save-state": "Enregistrer l'état", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "L'émulateur n'a pas confirmé la sauvegarde. La progression récente pourrait être perdue.", - "stream-slot-n": "Emplacement {n}", "stream-stop": "Arrêter", "stream-subtitle": "Streaming", + "stream-swap-disc": "Changer de disque", "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.", + "swap-disc-confirm": "Changer", + "swap-disc-failed": "Le changement de disque a échoué. La console est peut-être encore en train de changer, réessayez.", + "swap-disc-text": "Choisissez le disque à charger. Le jeu continue de tourner, sauvegardez donc votre progression dans le jeu d'abord.", + "swap-disc-title": "Changer de disque", + "upload-memory-card": "Envoyer une carte" } diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index 27c3a0dad6..c941d586a6 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -59,6 +59,9 @@ "completion": "Complétion", "completionist": "Complétionniste", "confirm-delete-note": "Êtes-vous sûr de vouloir supprimer la note \"{title}\" ?", + "confirm-join-body": "Vous rejoindrez « {name} » en tant que joueur supplémentaire. L'hôte garde le contrôle de la session et de ses sauvegardes.", + "confirm-join-title": "Rejoindre cette session ?", + "confirm-join-title-of": "Rejoindre la session de {user} ?", "confirm-launch-protected-body": "Vous avez marqué « {name} » comme {status}. Voulez-vous quand même y jouer ?", "confirm-launch-protected-title": "Lancer ce jeu ?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Caché", "how-long-to-beat": "Durée de Jeu", "info": "Info", + "join-session": "Rejoindre la session", + "join-session-of": "Rejoindre la session de {user}", "languages": "Langues", "last-played": "Dernière partie", "launchbox-cloud": "Cloud", @@ -426,6 +431,8 @@ "status-never-playing": "Jamais joué", "status-now-playing": "En train de jouer", "status-retired": "Abandonné", + "stream": "Diffuser", + "stream-on": "Diffuser sur {container}", "summary": "Résumé", "switch-version": "Changer de version", "tab-achievements": "Succès", diff --git a/frontend/src/locales/fr_FR/settings.json b/frontend/src/locales/fr_FR/settings.json index edf6b2c7cb..fd8dcbc3ab 100644 --- a/frontend/src/locales/fr_FR/settings.json +++ b/frontend/src/locales/fr_FR/settings.json @@ -437,6 +437,25 @@ "sort-size": "Taille", "states": "États", "stopped": "Arrêté", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Résumé", "task-failed": "Échec de la tâche", "task-history": "Historique des tâches", diff --git a/frontend/src/locales/hu_HU/activity.json b/frontend/src/locales/hu_HU/activity.json index 640e2bb0be..6c3e4370a3 100644 --- a/frontend/src/locales/hu_HU/activity.json +++ b/frontend/src/locales/hu_HU/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/hu_HU/platform.json index c6d308eb81..3e0ea9beb3 100644 --- a/frontend/src/locales/hu_HU/platform.json +++ b/frontend/src/locales/hu_HU/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Régi négyzetes tokok", "on-disk": "Lemezen", "only-with-games": "Csak játékokkal rendelkező platformok", + "playable-both": "Böngészőben játszható és streamelhető innen: {label}", + "playable-browser-dosbox": "Böngészőben játszható DOSBox segítségével", + "playable-browser-emulatorjs": "Böngészőben játszható EmulatorJS segítségével", + "playable-browser-ruffle": "Böngészőben játszható Ruffle segítségével", + "playable-none": "Böngészőben nem játszható és nem streamelhető", + "playable-stream": "Streamelhető innen: {label}", "player-count": "Játékosok száma", "properties": "Tulajdonságok", "random-rom": "Véletlenszerű ROM", diff --git a/frontend/src/locales/hu_HU/play.json b/frontend/src/locales/hu_HU/play.json index 222d3d45cd..986fc50f41 100644 --- a/frontend/src/locales/hu_HU/play.json +++ b/frontend/src/locales/hu_HU/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Kártya letölté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ő", + "join-closed": "Ez a munkamenet már nem áll nyitva más játékosok előtt.", + "join-ended": "Ez a munkamenet véget ért.", + "keep-playing": "Játék folytatása", + "leave-dialog-text": "A házigazda tovább játszik. Te kilépsz a munkamenetből.", + "leave-dialog-title": "Kilépsz a munkamenetből?", + "leave-session": "Kilépés a munkamenetből", + "load-autosave": "Automatikus mentés betöltése", + "load-state": "Állapot betöltése", + "manage-memory-cards": "Memóriakártyák kezelése", + "manual-disc-swap-hint": "Ez az emulátor a saját menüjéből cserél lemezt, nem innen.", + "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-download-failed": "A memóriakártyát nem sikerült letölteni", + "memory-card-erase-keyword": "ERASE", + "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-import-adopt": "Kártya importálása", + "memory-card-import-body": "Ebben a konténerben már van egy memóriakártya {count} mentésfájllal. Importálod a könyvtáradba, vagy inkább új kártyával kezdesz?", + "memory-card-import-discard": "Kezdés elölről", + "memory-card-import-discard-body": "A konténeren lévő memóriakártya törlődik. Semmi, ami rajta van mentve, nem állítható vissza.", + "memory-card-import-discard-confirm": "Törlés és kezdés elölről", + "memory-card-import-discard-title": "Törlöd a meglévő kártyát?", + "memory-card-import-games": "Játékok ezen a kártyán: {games}", + "memory-card-import-size": "Összesen {size}", + "memory-card-import-title": "Memóriakártya található", + "memory-card-no-data": "Ezen a kártyán még nincsenek mentett adatok", + "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-unreadable-body": "A RomM nem tudta beolvasni a konténeren lévő memóriakártyát, így nem derül ki, vannak-e rajta mentések. Próbáld meg később, vagy kezdd elölről, és töröld, ami rajta van.", + "memory-card-unreadable-override": "Kezdés elölről mindenképp", + "memory-card-unreadable-reason": "Ok: {reason}", + "memory-card-unreadable-title": "A memóriakártyát nem sikerült beolvasni", + "memory-card-unreadable-warning": "A meglévő kártya törlődik, és nem állítható vissza.", + "memory-card-updated": "Frissítve: {when}", + "memory-card-upload-failed": "A memóriakártyát nem sikerült feltölteni", + "memory-card-uploaded": "Memóriakártya feltöltve", + "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.", + "multiplayer": "Többjátékos mód", + "multiplayer-hint": "Csatlakozás gombot jelenít meg a játék oldalán, és láthatóan tartja a csevegést és a webkamerát.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "Nincs elérhető állás", "page-title": "{name} indítása", "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-data": "Mentési adatok", + "save-data-detail": "Frissítve {time} · {size}", + "save-data-none": "Még nincs mentési adat", + "save-data-none-hint": "A(z) {platform} a játék saját mentésében tárolja a haladást", + "save-data-none-note": "Játssz, majd ments a játékon belül. A munkamenet végén szinkronizálódik.", + "save-data-note": "Indítás előtt visszaállítva a konzolra. Töltsd be a játék saját menüjéből.", + "save-data-synced": "Szinkronizálva", + "save-slot": "Mentési hely", + "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", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Keret megjelenítése", "slot": "Hely", "start-fresh-hint": "Válassz egyet az alábbiak közül a folytatáshoz, vagy nyomd meg az Indítást egy új játékhoz.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Váratlan hiba történt.", "stream-error-load-rom": "A ROM adatait nem sikerült betölteni.", "stream-error-not-configured": "Nincs streaming konténer konfigurálva a következőhöz: {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Kilépés a teljes képernyőből", "stream-frame-title": "Játék streamje", "stream-fullscreen": "Teljes képernyő", - "stream-load-autosave": "Automatikus mentés betöltése", "stream-load-state": "Állás betöltése", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Némítás", "stream-occupied-body": "A(z) {rom} játékban van {time} óta.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Jelenleg valaki más játszik. Próbálja meg később.", "stream-occupied-title": "A munkamenet használatban van", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Mentés és kilépés", - "stream-save-slot": "Mentési hely", "stream-save-state": "Állás mentése", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "Az emulátor nem erősítette meg a mentést. A legutóbbi előrehaladás elveszhet.", - "stream-slot-n": "{n}. hely", "stream-stop": "Leállítás", "stream-subtitle": "Streamelés", + "stream-swap-disc": "Lemezcsere", "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.", + "swap-disc-confirm": "Csere", + "swap-disc-failed": "A lemezcsere nem sikerült. Lehet, hogy a konzol még a csere közepén tart, próbáld újra.", + "swap-disc-text": "Válaszd ki a betöltendő lemezt. A játék tovább fut, ezért előbb mentsd el a haladásodat a játékon belül.", + "swap-disc-title": "Lemezcsere", + "upload-memory-card": "Kártya feltöltése" } diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index 84b218230c..2533f3e15b 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -59,6 +59,9 @@ "completion": "Befejezve", "completionist": "Completionista", "confirm-delete-note": "Biztos hogy törölni akarod ezt a jegyzetet \"{title}\"?", + "confirm-join-body": "További játékosként csatlakozol ehhez: „{name}”. A munkamenet és a mentések felett a gazda tartja meg az irányítást.", + "confirm-join-title": "Csatlakozol ehhez a munkamenethez?", + "confirm-join-title-of": "Csatlakozol {user} munkamenetéhez?", "confirm-launch-protected-body": "A(z) „{name}“ játékot {status} állapotúnak jelölted. Mindenképp játszani szeretnél vele?", "confirm-launch-protected-title": "Elindítod ezt a játékot?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Rejtett", "how-long-to-beat": "How Long to Beat", "info": "Info", + "join-session": "Csatlakozás a munkamenethez", + "join-session-of": "Csatlakozás {user} munkamenetéhez", "languages": "Nyelvek", "last-played": "Utoljára játszva", "launchbox-cloud": "Felhő", @@ -426,6 +431,8 @@ "status-never-playing": "Soha nem játszom", "status-now-playing": "Most játszom", "status-retired": "Félretett", + "stream": "Streamelés", + "stream-on": "Streamelés a(z) {container} gépen", "summary": "Összefoglalás", "switch-version": "Verzió váltása", "tab-achievements": "Teljesítmények", diff --git a/frontend/src/locales/hu_HU/settings.json b/frontend/src/locales/hu_HU/settings.json index de79852501..e602a43a56 100644 --- a/frontend/src/locales/hu_HU/settings.json +++ b/frontend/src/locales/hu_HU/settings.json @@ -437,6 +437,25 @@ "sort-size": "Méret", "states": "Állások", "stopped": "Megállítva", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Összefoglaló", "task-failed": "A feladat sikertelen", "task-history": "Feladat előzmények", diff --git a/frontend/src/locales/it_IT/activity.json b/frontend/src/locales/it_IT/activity.json index 424601a2b3..1373a6288a 100644 --- a/frontend/src/locales/it_IT/activity.json +++ b/frontend/src/locales/it_IT/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/it_IT/platform.json index 926dd7d1c3..b99013a10b 100644 --- a/frontend/src/locales/it_IT/platform.json +++ b/frontend/src/locales/it_IT/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Vecchie custodie quadrate", "on-disk": "Su disco", "only-with-games": "Solo piattaforme con giochi", + "playable-both": "Giocabile nel browser e trasmissibile da {label}", + "playable-browser-dosbox": "Giocabile nel browser tramite DOSBox", + "playable-browser-emulatorjs": "Giocabile nel browser tramite EmulatorJS", + "playable-browser-ruffle": "Giocabile nel browser tramite Ruffle", + "playable-none": "Non giocabile nel browser né in streaming", + "playable-stream": "Trasmissibile da {label}", "player-count": "Numero di giocatori", "properties": "Proprietà", "random-rom": "ROM casuale", diff --git a/frontend/src/locales/it_IT/play.json b/frontend/src/locales/it_IT/play.json index 45db62c5ff..ef11a1848a 100644 --- a/frontend/src/locales/it_IT/play.json +++ b/frontend/src/locales/it_IT/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Scarica card", + "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", + "join-closed": "Quella sessione non è più aperta ad altri giocatori.", + "join-ended": "Quella sessione è terminata.", + "keep-playing": "Continua a giocare", + "leave-dialog-text": "L'host continua a giocare. Tu uscirai dalla sessione.", + "leave-dialog-title": "Uscire dalla sessione?", + "leave-session": "Esci dalla sessione", + "load-autosave": "Carica salvataggio automatico", + "load-state": "Carica stato", + "manage-memory-cards": "Gestisci memory card", + "manual-disc-swap-hint": "Questo emulatore cambia disco dal proprio menu, non da qui.", + "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-download-failed": "Impossibile scaricare la memory card", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "La memory card più recente viene caricata per impostazione predefinita. I progressi vengono sincronizzati all'uscita.", + "memory-card-import-adopt": "Importa questa memory card", + "memory-card-import-body": "Questo container ha già una memory card con {count} file di salvataggio. Vuoi importarla nella tua libreria o iniziare con una memory card nuova?", + "memory-card-import-discard": "Riparti da zero", + "memory-card-import-discard-body": "La memory card presente su questo container verrà cancellata. Nulla di ciò che vi è salvato potrà essere recuperato.", + "memory-card-import-discard-confirm": "Cancella e riparti da zero", + "memory-card-import-discard-title": "Cancellare la memory card esistente?", + "memory-card-import-games": "Giochi su questa memory card: {games}", + "memory-card-import-size": "{size} in totale", + "memory-card-import-title": "Memory card trovata", + "memory-card-no-data": "Questa card non ha ancora dati salvati", + "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-unreadable-body": "RomM non è riuscito a leggere la memory card di questo container, quindi non può sapere se contiene salvataggi. Riprova più tardi oppure riparti da zero e cancella ciò che contiene.", + "memory-card-unreadable-override": "Riparti da zero comunque", + "memory-card-unreadable-reason": "Motivo: {reason}", + "memory-card-unreadable-title": "Impossibile leggere la memory card", + "memory-card-unreadable-warning": "La memory card esistente verrà cancellata e non potrà essere recuperata.", + "memory-card-updated": "Aggiornata {when}", + "memory-card-upload-failed": "Impossibile caricare la memory card", + "memory-card-uploaded": "Memory card caricata", + "memory-card-versions": "Cronologia versioni", + "memory-cards": "Memory card", + "memory-cards-empty": "Non hai ancora nessuna memory card per questo emulatore.", + "multiplayer": "Modalità multigiocatore", + "multiplayer-hint": "Mostra un pulsante Partecipa nella pagina di questo gioco e mantiene visibili chat e webcam.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "Nessuno stato disponibile", "page-title": "Gioca a {name}", "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-data": "Dati di salvataggio", + "save-data-detail": "Aggiornato {time} · {size}", + "save-data-none": "Nessun dato di salvataggio", + "save-data-none-hint": "{platform} conserva i progressi nel salvataggio del gioco stesso", + "save-data-none-note": "Gioca e salva dentro il gioco. Verrà sincronizzato al termine della sessione.", + "save-data-note": "Ripristinato sulla console prima dell'avvio. Caricalo dal menu del gioco.", + "save-data-synced": "Sincronizzato", + "save-slot": "Slot di salvataggio", + "save-state": "Salva stato", "select-background-color": "Seleziona colore di sfondo", "select-save": "Seleziona Salvataggio", "select-state": "Seleziona Stato", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Mostra cornice", "slot": "Slot", "start-fresh-hint": "Scegline uno qui sotto per riprendere, oppure premi Gioca per iniziare da capo.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Si è verificato un errore imprevisto.", "stream-error-load-rom": "Impossibile caricare i dettagli della ROM.", "stream-error-not-configured": "Nessun contenitore di streaming configurato per {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Esci da schermo intero", "stream-frame-title": "Stream del gioco", "stream-fullscreen": "Schermo intero", - "stream-load-autosave": "Carica salvataggio automatico", "stream-load-state": "Carica stato", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Disattiva audio", "stream-occupied-body": "{rom} è in gioco da {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Qualcun altro sta giocando in questo momento. Riprova più tardi.", "stream-occupied-title": "Sessione in uso", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Salva ed esci", - "stream-save-slot": "Slot di salvataggio", "stream-save-state": "Salva stato", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "L'emulatore non ha confermato il salvataggio. I progressi recenti potrebbero andare persi.", - "stream-slot-n": "Slot {n}", "stream-stop": "Ferma", "stream-subtitle": "Streaming", + "stream-swap-disc": "Cambia disco", "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.", + "swap-disc-confirm": "Cambia", + "swap-disc-failed": "Cambio disco non riuscito. La console potrebbe essere ancora a metà del cambio, riprova.", + "swap-disc-text": "Scegli il disco da caricare. Il gioco resta in esecuzione, quindi salva prima i tuoi progressi nel gioco.", + "swap-disc-title": "Cambia disco", + "upload-memory-card": "Carica card" } diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index 1a295b79ce..0938d5219e 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -59,6 +59,9 @@ "completion": "Completamento", "completionist": "Completista", "confirm-delete-note": "Sei sicuro di voler eliminare la nota \"{title}\"?", + "confirm-join-body": "Verrai aggiunto a «{name}» come giocatore aggiuntivo. L'host mantiene il controllo della sessione e dei suoi salvataggi.", + "confirm-join-title": "Partecipare a questa sessione?", + "confirm-join-title-of": "Partecipare alla sessione di {user}?", "confirm-launch-protected-body": "Hai contrassegnato «{name}» come {status}. Vuoi comunque giocarci?", "confirm-launch-protected-title": "Avviare questo gioco?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Nascosto", "how-long-to-beat": "Durata di Gioco", "info": "Info", + "join-session": "Partecipa alla sessione", + "join-session-of": "Partecipa alla sessione di {user}", "languages": "Lingue", "last-played": "Ultima sessione", "launchbox-cloud": "Cloud", @@ -426,6 +431,8 @@ "status-never-playing": "Mai giocare", "status-now-playing": "In gioco", "status-retired": "Abbandonato", + "stream": "Trasmetti", + "stream-on": "Trasmetti su {container}", "summary": "Riassunto", "switch-version": "Cambia versione", "tab-achievements": "Obiettivi", diff --git a/frontend/src/locales/it_IT/settings.json b/frontend/src/locales/it_IT/settings.json index ca2941b552..9661c0f248 100644 --- a/frontend/src/locales/it_IT/settings.json +++ b/frontend/src/locales/it_IT/settings.json @@ -437,6 +437,25 @@ "sort-size": "Dimensione", "states": "Stati", "stopped": "Fermato", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Riepilogo", "task-failed": "Attività non riuscita", "task-history": "Cronologia attività", diff --git a/frontend/src/locales/ja_JP/activity.json b/frontend/src/locales/ja_JP/activity.json index 583f651f33..b9c4d34bd1 100644 --- a/frontend/src/locales/ja_JP/activity.json +++ b/frontend/src/locales/ja_JP/activity.json @@ -10,5 +10,14 @@ "now-playing": "プレイ中", "playing-on": "{device}でプレイ中", "playing-since": "{time}からプレイ中", - "total-sessions": "セッション合計" + "release-failed": "セッションを解放できませんでした", + "release-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/ja_JP/platform.json index 9eaaeeadac..84792ad52f 100644 --- a/frontend/src/locales/ja_JP/platform.json +++ b/frontend/src/locales/ja_JP/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "旧角型ケース", "on-disk": "ディスク上", "only-with-games": "ゲームがあるプラットフォームのみ", + "playable-both": "ブラウザでプレイ可能、{label} からストリーミング可能", + "playable-browser-dosbox": "DOSBox によりブラウザでプレイ可能", + "playable-browser-emulatorjs": "EmulatorJS によりブラウザでプレイ可能", + "playable-browser-ruffle": "Ruffle によりブラウザでプレイ可能", + "playable-none": "ブラウザでもストリーミングでもプレイできません", + "playable-stream": "{label} からストリーミング可能", "player-count": "プレイヤー数", "properties": "属性", "random-rom": "ランダムROM", diff --git a/frontend/src/locales/ja_JP/play.json b/frontend/src/locales/ja_JP/play.json index 8c1f29e9cd..60705c4c6d 100644 --- a/frontend/src/locales/ja_JP/play.json +++ b/frontend/src/locales/ja_JP/play.json @@ -4,15 +4,96 @@ "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": "ステートを解除", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "カードをダウンロード", + "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": "全画面", + "join-closed": "このセッションは他のプレイヤーに開放されていません。", + "join-ended": "このセッションは終了しました。", + "keep-playing": "プレイを続ける", + "leave-dialog-text": "ホストはプレイを続けます。あなたはセッションから抜けます。", + "leave-dialog-title": "セッションから抜けますか?", + "leave-session": "セッションから抜ける", + "load-autosave": "オートセーブを読み込む", + "load-state": "ステートをロード", + "manage-memory-cards": "メモリーカードを管理", + "manual-disc-swap-hint": "このエミュレーターはディスクの交換をエミュレーター側のメニューで行います。", + "memory-card": "メモリーカード", + "memory-card-count": "{count} 枚", + "memory-card-create-failed": "メモリーカードを作成できませんでした", + "memory-card-created": "メモリーカードを作成しました", + "memory-card-delete-failed": "メモリーカードを削除できませんでした", + "memory-card-deleted": "メモリーカードを削除しました", + "memory-card-download-failed": "メモリーカードをダウンロードできませんでした", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "デフォルトで最新のカードが読み込まれます。終了時に進行状況が同期されます。", + "memory-card-import-adopt": "このカードをインポート", + "memory-card-import-body": "このコンテナには、セーブファイルが {count} 件入ったメモリーカードがすでにあります。ライブラリにインポートしますか、それとも新しいカードで始めますか?", + "memory-card-import-discard": "新しく始める", + "memory-card-import-discard-body": "このコンテナのメモリーカードは消去されます。保存されている内容は復元できません。", + "memory-card-import-discard-confirm": "消去して新しく始める", + "memory-card-import-discard-title": "既存のカードを消去しますか?", + "memory-card-import-games": "このカードのゲーム: {games}", + "memory-card-import-size": "合計 {size}", + "memory-card-import-title": "メモリーカードが見つかりました", + "memory-card-no-data": "このカードにはまだ保存されたデータがありません", + "memory-card-no-versions": "保存されたバージョンはまだありません", + "memory-card-rename-failed": "メモリーカードの名前を変更できませんでした", + "memory-card-renamed": "メモリーカードの名前を変更しました", + "memory-card-share-failed": "メモリーカードの共有を変更できませんでした", + "memory-card-share-label": "他のユーザーと共有", + "memory-card-shared": "共有中", + "memory-card-unreadable-body": "RomM はこのコンテナのメモリーカードを読み取れなかったため、セーブデータがあるかどうか判断できません。しばらくしてからやり直すか、中身を消去して新しく始めてください。", + "memory-card-unreadable-override": "それでも新しく始める", + "memory-card-unreadable-reason": "理由: {reason}", + "memory-card-unreadable-title": "メモリーカードを読み取れませんでした", + "memory-card-unreadable-warning": "既存のカードは消去され、復元できません。", + "memory-card-updated": "{when} に更新", + "memory-card-upload-failed": "メモリーカードをアップロードできませんでした", + "memory-card-uploaded": "メモリーカードをアップロードしました", + "memory-card-versions": "バージョン履歴", + "memory-cards": "メモリーカード", + "memory-cards-empty": "このエミュレーターのメモリーカードはまだありません。", + "multiplayer": "マルチプレイヤーモード", + "multiplayer-hint": "このゲームのページに参加ボタンを表示し、チャットとウェブカメラを表示したままにします。", + "mute": "ミュート", + "new-memory-card": "新しいカード", + "no-memory-cards": "メモリーカードはまだありません", "no-save-selected": "セーブデータが選択されていません", "no-saves-available": "利用可能なセーブデータがありません", "no-screenshot-available": "スクリーンショットはありません", @@ -20,17 +101,37 @@ "no-states-available": "利用可能なステートがありません", "page-title": "{name} をプレイ", "play": "プレイ", + "play-on": "{label} でプレイ", "powered-by": "提供:", "quit": "終了", + "rename-memory-card": "メモリーカードの名前を変更", + "resume-failed": "選択したステートを読み込めませんでした。ゲームは最初から開始されました。", "resume-from-save": "セーブから再開", "resume-from-state": "ステートセーブから再開", "save-and-quit": "保存して終了", + "save-data": "セーブデータ", + "save-data-detail": "更新: {time} · {size}", + "save-data-none": "セーブデータはまだありません", + "save-data-none-hint": "{platform} は進行状況をゲーム自身のセーブに保存します", + "save-data-none-note": "プレイしてゲーム内でセーブしてください。セッション終了時に同期されます。", + "save-data-note": "起動前に本体へ復元されます。ゲーム内のメニューから読み込んでください。", + "save-data-synced": "同期済み", + "save-slot": "セーブスロット", + "save-state": "ステートをセーブ", "select-background-color": "背景色を選択", "select-save": "セーブデータを選択", "select-state": "ステートを選択", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "ベゼルを表示", "slot": "スロット", "start-fresh-hint": "下から選んで再開するか、Playを押して最初から始めてください。", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "予期しないエラーが発生しました。", "stream-error-load-rom": "ROMの詳細を読み込めませんでした。", "stream-error-not-configured": "{platform} 用のストリーミングコンテナが構成されていません。", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "全画面表示を終了", "stream-frame-title": "ゲームストリーム", "stream-fullscreen": "全画面表示", - "stream-load-autosave": "オートセーブを読み込む", "stream-load-state": "ステートをロード", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "ミュート", "stream-occupied-body": "{rom} は {time} からプレイされています。", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "現在、他の人がプレイ中です。後でもう一度お試しください。", "stream-occupied-title": "セッション使用中", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "保存して終了", - "stream-save-slot": "セーブスロット", "stream-save-state": "ステートをセーブ", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "エミュレーターが保存を確認できませんでした。最近の進行状況が失われる可能性があります。", - "stream-slot-n": "スロット {n}", "stream-stop": "停止", "stream-subtitle": "ストリーミング", + "stream-swap-disc": "ディスクを交換", "stream-try-again": "再試行", "stream-unknown-game": "不明なゲーム", "stream-unmute": "ミュート解除", - "stream-volume": "音量" + "stream-volume": "音量", + "streaming-description": "ゲームは専用の {label} コンテナで実行され、ブラウザに直接ストリーミングされます。", + "swap-disc-confirm": "交換", + "swap-disc-failed": "ディスクの交換に失敗しました。本体がまだ交換中の可能性があります。もう一度お試しください。", + "swap-disc-text": "読み込むディスクを選んでください。ゲームは動作したままなので、先にゲーム内で進行状況を保存してください。", + "swap-disc-title": "ディスクの交換", + "upload-memory-card": "カードをアップロード" } diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index 0f598ccc29..d9d4713bb4 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -59,6 +59,9 @@ "completion": "完了度", "completionist": "コンプリート", "confirm-delete-note": "ノート\"{title}\"を削除してもよろしいですか?", + "confirm-join-body": "追加のプレイヤーとして「{name}」に参加します。セッションとセーブデータの管理はホストが引き続き行います。", + "confirm-join-title": "このセッションに参加しますか?", + "confirm-join-title-of": "{user} のセッションに参加しますか?", "confirm-launch-protected-body": "「{name}」を{status}に設定しています。それでもプレイしますか?", "confirm-launch-protected-title": "このゲームを起動しますか?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "非表示", "how-long-to-beat": "プレイ時間", "info": "詳細", + "join-session": "セッションに参加", + "join-session-of": "{user} のセッションに参加", "languages": "言語", "last-played": "最終プレイ", "launchbox-cloud": "クラウド", @@ -426,6 +431,8 @@ "status-never-playing": "プレイしない", "status-now-playing": "プレイ中", "status-retired": "中断", + "stream": "ストリーミング", + "stream-on": "{container} でストリーミング", "summary": "結果", "switch-version": "バージョン切り替え", "tab-achievements": "実績", diff --git a/frontend/src/locales/ja_JP/settings.json b/frontend/src/locales/ja_JP/settings.json index 289588da9d..ded5e25d48 100644 --- a/frontend/src/locales/ja_JP/settings.json +++ b/frontend/src/locales/ja_JP/settings.json @@ -437,6 +437,25 @@ "sort-size": "サイズ", "states": "ステートセーブ", "stopped": "停止済み", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "概要", "task-failed": "タスクが失敗しました", "task-history": "タスク履歴", diff --git a/frontend/src/locales/ko_KR/activity.json b/frontend/src/locales/ko_KR/activity.json index 03a27c81f8..7e0035a301 100644 --- a/frontend/src/locales/ko_KR/activity.json +++ b/frontend/src/locales/ko_KR/activity.json @@ -10,5 +10,14 @@ "now-playing": "플레이 중", "playing-on": "{device}에서 플레이 중", "playing-since": "{time}부터 플레이 중", - "total-sessions": "전체 세션" + "release-failed": "세션을 해제할 수 없습니다", + "release-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/ko_KR/platform.json index 9bd6389cdc..892f643ee2 100644 --- a/frontend/src/locales/ko_KR/platform.json +++ b/frontend/src/locales/ko_KR/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "옛 정사각형 케이스", "on-disk": "디스크에 있음", "only-with-games": "게임이 있는 플랫폼만", + "playable-both": "브라우저에서 플레이 가능, {label}에서 스트리밍 가능", + "playable-browser-dosbox": "DOSBox를 통해 브라우저에서 플레이 가능", + "playable-browser-emulatorjs": "EmulatorJS를 통해 브라우저에서 플레이 가능", + "playable-browser-ruffle": "Ruffle을 통해 브라우저에서 플레이 가능", + "playable-none": "브라우저에서도 스트리밍으로도 플레이할 수 없음", + "playable-stream": "{label}에서 스트리밍 가능", "player-count": "플레이어 수", "properties": "속성", "random-rom": "무작위 ROM", diff --git a/frontend/src/locales/ko_KR/play.json b/frontend/src/locales/ko_KR/play.json index 740dc25907..2fb77ec16d 100644 --- a/frontend/src/locales/ko_KR/play.json +++ b/frontend/src/locales/ko_KR/play.json @@ -4,15 +4,96 @@ "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": "상태 선택 해제", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "카드 다운로드", + "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": "전체 화면", + "join-closed": "이 세션은 더 이상 다른 플레이어에게 열려 있지 않습니다.", + "join-ended": "이 세션은 종료되었습니다.", + "keep-playing": "계속 플레이", + "leave-dialog-text": "호스트는 계속 플레이합니다. 당신만 세션에서 나갑니다.", + "leave-dialog-title": "세션에서 나갈까요?", + "leave-session": "세션에서 나가기", + "load-autosave": "자동 저장 불러오기", + "load-state": "상태 불러오기", + "manage-memory-cards": "메모리 카드 관리", + "manual-disc-swap-hint": "이 에뮬레이터는 자체 메뉴에서 디스크를 교체합니다.", + "memory-card": "메모리 카드", + "memory-card-count": "{count}개", + "memory-card-create-failed": "메모리 카드를 만들 수 없습니다", + "memory-card-created": "메모리 카드가 생성되었습니다", + "memory-card-delete-failed": "메모리 카드를 삭제할 수 없습니다", + "memory-card-deleted": "메모리 카드가 삭제되었습니다", + "memory-card-download-failed": "메모리 카드를 다운로드할 수 없습니다", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "기본적으로 최신 카드가 로드됩니다. 종료하면 진행 상황이 동기화됩니다.", + "memory-card-import-adopt": "이 카드 가져오기", + "memory-card-import-body": "이 컨테이너에는 이미 저장 파일 {count}개가 들어 있는 메모리 카드가 있습니다. 라이브러리로 가져올까요, 아니면 새 카드로 시작할까요?", + "memory-card-import-discard": "새로 시작", + "memory-card-import-discard-body": "이 컨테이너의 메모리 카드가 지워집니다. 저장된 내용은 복구할 수 없습니다.", + "memory-card-import-discard-confirm": "지우고 새로 시작", + "memory-card-import-discard-title": "기존 카드를 지울까요?", + "memory-card-import-games": "이 카드의 게임: {games}", + "memory-card-import-size": "총 {size}", + "memory-card-import-title": "메모리 카드를 찾았습니다", + "memory-card-no-data": "이 카드에는 아직 저장된 데이터가 없습니다", + "memory-card-no-versions": "저장된 버전이 아직 없습니다", + "memory-card-rename-failed": "메모리 카드의 이름을 변경할 수 없습니다", + "memory-card-renamed": "메모리 카드 이름이 변경되었습니다", + "memory-card-share-failed": "메모리 카드의 공유를 변경할 수 없습니다", + "memory-card-share-label": "다른 사용자와 공유됨", + "memory-card-shared": "공유됨", + "memory-card-unreadable-body": "RomM이 이 컨테이너의 메모리 카드를 읽지 못해 저장 데이터가 있는지 확인할 수 없습니다. 나중에 다시 시도하거나, 카드에 있는 내용을 지우고 새로 시작하세요.", + "memory-card-unreadable-override": "그래도 새로 시작", + "memory-card-unreadable-reason": "이유: {reason}", + "memory-card-unreadable-title": "메모리 카드를 읽을 수 없습니다", + "memory-card-unreadable-warning": "기존 카드는 지워지며 복구할 수 없습니다.", + "memory-card-updated": "{when}에 업데이트됨", + "memory-card-upload-failed": "메모리 카드를 업로드할 수 없습니다", + "memory-card-uploaded": "메모리 카드가 업로드되었습니다", + "memory-card-versions": "버전 기록", + "memory-cards": "메모리 카드", + "memory-cards-empty": "이 에뮬레이터에 대한 메모리 카드가 아직 없습니다.", + "multiplayer": "멀티플레이어 모드", + "multiplayer-hint": "이 게임 페이지에 참가 버튼을 표시하고 채팅과 웹캠을 계속 보이게 합니다.", + "mute": "음소거", + "new-memory-card": "새 카드", + "no-memory-cards": "메모리 카드가 아직 없습니다", "no-save-selected": "선택된 세이브 없음", "no-saves-available": "사용 가능한 세이브 없음", "no-screenshot-available": "사용 가능한 스크린샷이 없습니다", @@ -20,17 +101,37 @@ "no-states-available": "사용 가능한 상태 없음", "page-title": "{name} 실행", "play": "실행", + "play-on": "{label}에서 플레이", "powered-by": "제공", "quit": "종료", + "rename-memory-card": "메모리 카드 이름 변경", + "resume-failed": "선택한 상태를 불러오지 못했습니다. 게임이 처음부터 시작되었습니다.", "resume-from-save": "세이브에서 이어하기", "resume-from-state": "상태에서 이어하기", "save-and-quit": "저장하고 종료", + "save-data": "저장 데이터", + "save-data-detail": "업데이트 {time} · {size}", + "save-data-none": "아직 저장 데이터가 없습니다", + "save-data-none-hint": "{platform}은(는) 게임 자체 저장에 진행 상황을 보관합니다", + "save-data-none-note": "플레이한 뒤 게임에서 저장하세요. 세션이 끝나면 동기화됩니다.", + "save-data-note": "실행 전에 콘솔로 복원됩니다. 게임 자체 메뉴에서 불러오세요.", + "save-data-synced": "동기화됨", + "save-slot": "저장 슬롯", + "save-state": "상태 저장", "select-background-color": "배경색 선택", "select-save": "세이브 선택", "select-state": "상태 선택", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "베젤 표시", "slot": "슬롯", "start-fresh-hint": "아래에서 하나를 선택하여 이어 하거나, 실행을 눌러 새로 시작하세요.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "예기치 않은 오류가 발생했습니다.", "stream-error-load-rom": "ROM 세부 정보를 불러올 수 없습니다.", "stream-error-not-configured": "{platform}에 대해 구성된 스트리밍 컨테이너가 없습니다.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "전체 화면 종료", "stream-frame-title": "게임 스트림", "stream-fullscreen": "전체 화면", - "stream-load-autosave": "자동 저장 불러오기", "stream-load-state": "상태 불러오기", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "음소거", "stream-occupied-body": "{rom}을(를) {time}부터 플레이하고 있습니다.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "현재 다른 사람이 플레이 중입니다. 나중에 다시 시도하세요.", "stream-occupied-title": "세션 사용 중", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "저장 후 종료", - "stream-save-slot": "저장 슬롯", "stream-save-state": "상태 저장", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "에뮬레이터가 저장을 확인하지 못했습니다. 최근 진행 상황이 손실될 수 있습니다.", - "stream-slot-n": "슬롯 {n}", "stream-stop": "중지", "stream-subtitle": "스트리밍", + "stream-swap-disc": "디스크 교체", "stream-try-again": "다시 시도", "stream-unknown-game": "알 수 없는 게임", "stream-unmute": "음소거 해제", - "stream-volume": "볼륨" + "stream-volume": "볼륨", + "streaming-description": "게임은 전용 {label} 컨테이너에서 실행되며 브라우저로 바로 스트리밍됩니다.", + "swap-disc-confirm": "교체", + "swap-disc-failed": "디스크 교체에 실패했습니다. 본체가 아직 교체 중일 수 있으니 다시 시도하세요.", + "swap-disc-text": "불러올 디스크를 선택하세요. 게임은 계속 실행되므로 먼저 게임 안에서 진행 상황을 저장하세요.", + "swap-disc-title": "디스크 교체", + "upload-memory-card": "카드 업로드" } diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index 04b409f447..777b2d6918 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -59,6 +59,9 @@ "completion": "완료율", "completionist": "완주주의자", "confirm-delete-note": "노트 \"{title}\"을(를) 삭제하시겠습니까?", + "confirm-join-body": "추가 플레이어로 \"{name}\"에 참가합니다. 세션과 저장 데이터는 호스트가 계속 관리합니다.", + "confirm-join-title": "이 세션에 참가할까요?", + "confirm-join-title-of": "{user}의 세션에 참가할까요?", "confirm-launch-protected-body": "\"{name}\"을(를) {status}(으)로 표시했습니다. 그래도 플레이하시겠습니까?", "confirm-launch-protected-title": "이 게임을 실행할까요?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "숨김", "how-long-to-beat": "플레이 시간", "info": "정보", + "join-session": "세션 참가", + "join-session-of": "{user}의 세션에 참가", "languages": "언어", "last-played": "마지막 플레이", "launchbox-cloud": "클라우드", @@ -426,6 +431,8 @@ "status-never-playing": "플레이 안 함", "status-now-playing": "현재 플레이 중", "status-retired": "중단", + "stream": "스트리밍", + "stream-on": "{container}에서 스트리밍", "summary": "요약", "switch-version": "버전 전환", "tab-achievements": "도전 과제", diff --git a/frontend/src/locales/ko_KR/settings.json b/frontend/src/locales/ko_KR/settings.json index e956611f80..4f16d5cc41 100644 --- a/frontend/src/locales/ko_KR/settings.json +++ b/frontend/src/locales/ko_KR/settings.json @@ -437,6 +437,25 @@ "sort-size": "크기", "states": "상태", "stopped": "중지됨", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "요약", "task-failed": "작업 실패", "task-history": "작업 히스토리", diff --git a/frontend/src/locales/pl_PL/activity.json b/frontend/src/locales/pl_PL/activity.json index f488389164..06e4fa5c01 100644 --- a/frontend/src/locales/pl_PL/activity.json +++ b/frontend/src/locales/pl_PL/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/pl_PL/platform.json index bb15a2d9ae..95e7f6762d 100644 --- a/frontend/src/locales/pl_PL/platform.json +++ b/frontend/src/locales/pl_PL/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Stare pudełka kwadratowe", "on-disk": "Na dysku", "only-with-games": "Tylko platformy z grami", + "playable-both": "Grywalne w przeglądarce i dostępne w streamingu z {label}", + "playable-browser-dosbox": "Grywalne w przeglądarce przez DOSBox", + "playable-browser-emulatorjs": "Grywalne w przeglądarce przez EmulatorJS", + "playable-browser-ruffle": "Grywalne w przeglądarce przez Ruffle", + "playable-none": "Niegrywalne w przeglądarce ani przez streaming", + "playable-stream": "Dostępne w streamingu z {label}", "player-count": "Liczba graczy", "properties": "Właściwości", "random-rom": "Losowy ROM", diff --git a/frontend/src/locales/pl_PL/play.json b/frontend/src/locales/pl_PL/play.json index 2ce82a0305..fa6cc772a1 100644 --- a/frontend/src/locales/pl_PL/play.json +++ b/frontend/src/locales/pl_PL/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Pobierz kartę", + "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", + "join-closed": "Ta sesja nie jest już otwarta dla innych graczy.", + "join-ended": "Ta sesja się zakończyła.", + "keep-playing": "Graj dalej", + "leave-dialog-text": "Gospodarz gra dalej. Ty opuścisz sesję.", + "leave-dialog-title": "Opuścić sesję?", + "leave-session": "Opuść sesję", + "load-autosave": "Wczytaj automatyczny zapis", + "load-state": "Wczytaj stan", + "manage-memory-cards": "Zarządzaj kartami pamięci", + "manual-disc-swap-hint": "Ten emulator zmienia płyty z własnego menu, nie stąd.", + "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-download-failed": "Nie udało się pobrać karty pamięci", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "Domyślnie ładowana jest najnowsza karta. Postęp jest synchronizowany przy wyjściu.", + "memory-card-import-adopt": "Importuj tę kartę", + "memory-card-import-body": "Ten kontener ma już kartę pamięci z {count} plikiem(ami) zapisu. Zaimportować ją do biblioteki czy zacząć od nowej karty?", + "memory-card-import-discard": "Zacznij od nowa", + "memory-card-import-discard-body": "Karta pamięci w tym kontenerze zostanie wymazana. Niczego, co na niej zapisano, nie da się odzyskać.", + "memory-card-import-discard-confirm": "Wymaż i zacznij od nowa", + "memory-card-import-discard-title": "Wymazać istniejącą kartę?", + "memory-card-import-games": "Gry na tej karcie: {games}", + "memory-card-import-size": "Łącznie {size}", + "memory-card-import-title": "Znaleziono kartę pamięci", + "memory-card-no-data": "Ta karta nie ma jeszcze zapisanych danych", + "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-unreadable-body": "RomM nie mógł odczytać karty pamięci w tym kontenerze, więc nie wie, czy są na niej zapisy. Spróbuj później albo zacznij od nowa i wymaż to, co się na niej znajduje.", + "memory-card-unreadable-override": "Mimo to zacznij od nowa", + "memory-card-unreadable-reason": "Powód: {reason}", + "memory-card-unreadable-title": "Nie udało się odczytać karty pamięci", + "memory-card-unreadable-warning": "Istniejąca karta zostanie wymazana i nie będzie można jej odzyskać.", + "memory-card-updated": "Zaktualizowano {when}", + "memory-card-upload-failed": "Nie udało się przesłać karty pamięci", + "memory-card-uploaded": "Przesłano kartę pamięci", + "memory-card-versions": "Historia wersji", + "memory-cards": "Karty pamięci", + "memory-cards-empty": "Nie masz jeszcze żadnych kart pamięci dla tego emulatora.", + "multiplayer": "Tryb wieloosobowy", + "multiplayer-hint": "Wyświetla przycisk Dołącz na stronie tej gry i utrzymuje widoczny czat oraz kamerę internetową.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "Brak dostępnych stanów", "page-title": "Graj w {name}", "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-data": "Dane zapisu", + "save-data-detail": "Zaktualizowano {time} · {size}", + "save-data-none": "Brak danych zapisu", + "save-data-none-hint": "{platform} przechowuje postęp we własnym zapisie gry", + "save-data-none-note": "Zagraj i zapisz w grze. Synchronizacja nastąpi po zakończeniu sesji.", + "save-data-note": "Przywrócone na konsolę przed uruchomieniem. Wczytaj je z menu samej gry.", + "save-data-synced": "Zsynchronizowano", + "save-slot": "Miejsce zapisu", + "save-state": "Zapisz stan", "select-background-color": "Wybierz kolor tła", "select-save": "Wybierz zapis", "select-state": "Wybierz stan", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Pokaż ramkę", "slot": "Slot", "start-fresh-hint": "Wybierz jeden poniżej, aby wznowić, lub naciśnij Graj, aby zacząć od nowa.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Wystąpił nieoczekiwany błąd.", "stream-error-load-rom": "Nie można załadować szczegółów ROM.", "stream-error-not-configured": "Nie skonfigurowano kontenera strumieniowego dla {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Wyjdź z trybu pełnoekranowego", "stream-frame-title": "Transmisja gry", "stream-fullscreen": "Pełny ekran", - "stream-load-autosave": "Wczytaj autozapis", "stream-load-state": "Wczytaj stan", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Wycisz", "stream-occupied-body": "{rom} jest w grze od {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Ktoś inny obecnie gra. Spróbuj ponownie później.", "stream-occupied-title": "Sesja w użyciu", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Zapisz i wyjdź", - "stream-save-slot": "Slot zapisu", "stream-save-state": "Zapisz stan", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "Emulator nie potwierdził zapisu. Ostatnie postępy mogą zostać utracone.", - "stream-slot-n": "Slot {n}", "stream-stop": "Zatrzymaj", "stream-subtitle": "Transmisja", + "stream-swap-disc": "Zmień płytę", "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.", + "swap-disc-confirm": "Zmień", + "swap-disc-failed": "Nie udało się zmienić płyty. Konsola może być wciąż w trakcie zmiany, spróbuj ponownie.", + "swap-disc-text": "Wybierz płytę do wczytania. Gra działa dalej, więc najpierw zapisz postęp w samej grze.", + "swap-disc-title": "Zmiana płyty", + "upload-memory-card": "Prześlij kartę" } diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index 8f5247ba09..1ce0bb456f 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -59,6 +59,9 @@ "completion": "Ukończenie", "completionist": "Kompletista", "confirm-delete-note": "Czy na pewno chcesz usunąć notatkę \"{title}\"?", + "confirm-join-body": "Dołączysz do gry „{name}” jako dodatkowy gracz. Host zachowuje kontrolę nad sesją i jej zapisami.", + "confirm-join-title": "Dołączyć do tej sesji?", + "confirm-join-title-of": "Dołączyć do sesji użytkownika {user}?", "confirm-launch-protected-body": "Oznaczono „{name}“ jako {status}. Czy mimo to chcesz w nią zagrać?", "confirm-launch-protected-title": "Uruchomić tę grę?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Ukryte", "how-long-to-beat": "Czas Gry", "info": "Informacje", + "join-session": "Dołącz do sesji", + "join-session-of": "Dołącz do sesji użytkownika {user}", "languages": "Języki", "last-played": "Ostatnio grane", "launchbox-cloud": "Chmura", @@ -426,6 +431,8 @@ "status-never-playing": "Nigdy nie gram", "status-now-playing": "Aktualnie grane", "status-retired": "Porzucone", + "stream": "Strumieniuj", + "stream-on": "Strumieniuj na {container}", "summary": "Podsumowanie", "switch-version": "Zmień wersję", "tab-achievements": "Osiągnięcia", diff --git a/frontend/src/locales/pl_PL/settings.json b/frontend/src/locales/pl_PL/settings.json index ae4f42803d..798359acb6 100644 --- a/frontend/src/locales/pl_PL/settings.json +++ b/frontend/src/locales/pl_PL/settings.json @@ -437,6 +437,25 @@ "sort-size": "Rozmiar", "states": "Stany", "stopped": "Zatrzymane", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Podsumowanie", "task-failed": "Zadanie nie powiodło się", "task-history": "Historia zadań", diff --git a/frontend/src/locales/pt_BR/activity.json b/frontend/src/locales/pt_BR/activity.json index 00cf114cb4..b40a761119 100644 --- a/frontend/src/locales/pt_BR/activity.json +++ b/frontend/src/locales/pt_BR/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/pt_BR/platform.json index 4cc1be9c35..4909947032 100644 --- a/frontend/src/locales/pt_BR/platform.json +++ b/frontend/src/locales/pt_BR/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Caixas quadradas antigas", "on-disk": "No disco", "only-with-games": "Apenas plataformas com jogos", + "playable-both": "Jogável no navegador e transmissível de {label}", + "playable-browser-dosbox": "Jogável no navegador via DOSBox", + "playable-browser-emulatorjs": "Jogável no navegador via EmulatorJS", + "playable-browser-ruffle": "Jogável no navegador via Ruffle", + "playable-none": "Não jogável no navegador nem por transmissão", + "playable-stream": "Transmissível de {label}", "player-count": "Número de jogadores", "properties": "Propriedades", "random-rom": "ROM aleatório", diff --git a/frontend/src/locales/pt_BR/play.json b/frontend/src/locales/pt_BR/play.json index 7d4bc4dfd7..41b48d1b19 100644 --- a/frontend/src/locales/pt_BR/play.json +++ b/frontend/src/locales/pt_BR/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Baixar cartão", + "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", + "join-closed": "Essa sessão não está mais aberta a outros jogadores.", + "join-ended": "Essa sessão foi encerrada.", + "keep-playing": "Continuar jogando", + "leave-dialog-text": "O anfitrião continua jogando. Você vai sair da sessão.", + "leave-dialog-title": "Sair da sessão?", + "leave-session": "Sair da sessão", + "load-autosave": "Carregar salvamento automático", + "load-state": "Carregar estado", + "manage-memory-cards": "Gerenciar cartões de memória", + "manual-disc-swap-hint": "Este emulador troca de disco pelo próprio menu, não por aqui.", + "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-download-failed": "Não foi possível baixar o cartão de memória", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "O cartão mais recente é carregado por padrão. O progresso é sincronizado ao sair.", + "memory-card-import-adopt": "Importar este cartão", + "memory-card-import-body": "Este contêiner já tem um cartão de memória com {count} arquivo(s) de salvamento. Deseja importá-lo para a sua biblioteca ou começar com um cartão novo?", + "memory-card-import-discard": "Começar do zero", + "memory-card-import-discard-body": "O cartão de memória deste contêiner será apagado. Nada do que estiver salvo nele poderá ser recuperado.", + "memory-card-import-discard-confirm": "Apagar e começar do zero", + "memory-card-import-discard-title": "Apagar o cartão existente?", + "memory-card-import-games": "Jogos neste cartão: {games}", + "memory-card-import-size": "{size} no total", + "memory-card-import-title": "Cartão de memória encontrado", + "memory-card-no-data": "Este cartão ainda não tem dados salvos", + "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-unreadable-body": "O RomM não conseguiu ler o cartão de memória deste contêiner, então não é possível saber se ele contém saves. Tente novamente mais tarde ou comece do zero e apague o que estiver nele.", + "memory-card-unreadable-override": "Começar do zero mesmo assim", + "memory-card-unreadable-reason": "Motivo: {reason}", + "memory-card-unreadable-title": "Não foi possível ler o cartão de memória", + "memory-card-unreadable-warning": "O cartão existente será apagado e não poderá ser recuperado.", + "memory-card-updated": "Atualizado {when}", + "memory-card-upload-failed": "Não foi possível enviar o cartão de memória", + "memory-card-uploaded": "Cartão de memória enviado", + "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.", + "multiplayer": "Modo multijogador", + "multiplayer-hint": "Mostra um botão Entrar na página deste jogo e mantém o chat e a webcam visíveis.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "Nenhum estado disponível", "page-title": "Jogar {name}", "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-data": "Dados de save", + "save-data-detail": "Atualizado {time} · {size}", + "save-data-none": "Ainda não há dados de save", + "save-data-none-hint": "{platform} guarda o progresso no save do próprio jogo", + "save-data-none-note": "Jogue e salve dentro do jogo. A sincronização acontece ao encerrar a sessão.", + "save-data-note": "Restaurado no console antes de iniciar. Carregue pelo menu do próprio jogo.", + "save-data-synced": "Sincronizado", + "save-slot": "Slot de salvamento", + "save-state": "Salvar estado", "select-background-color": "Selecionar cor de fundo", "select-save": "Selecionar save", "select-state": "Selecionar estado", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Mostrar moldura", "slot": "Slot", "start-fresh-hint": "Escolha um abaixo para retomar, ou clique em Jogar para começar do zero.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Ocorreu um erro inesperado.", "stream-error-load-rom": "Não foi possível carregar os detalhes da ROM.", "stream-error-not-configured": "Nenhum contêiner de streaming está configurado para {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Sair da tela cheia", "stream-frame-title": "Transmissão do jogo", "stream-fullscreen": "Tela cheia", - "stream-load-autosave": "Carregar salvamento automático", "stream-load-state": "Carregar estado", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Silenciar", "stream-occupied-body": "{rom} está sendo jogado desde {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Outra pessoa está jogando no momento. Tente novamente mais tarde.", "stream-occupied-title": "Sessão em uso", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Salvar e sair", - "stream-save-slot": "Slot de salvamento", "stream-save-state": "Salvar estado", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "O emulador não confirmou o salvamento. O progresso recente pode ser perdido.", - "stream-slot-n": "Slot {n}", "stream-stop": "Parar", "stream-subtitle": "Transmissão", + "stream-swap-disc": "Trocar disco", "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.", + "swap-disc-confirm": "Trocar", + "swap-disc-failed": "A troca de disco falhou. O console pode ainda estar no meio da troca, tente novamente.", + "swap-disc-text": "Escolha o disco que deseja carregar. O jogo continua rodando, então salve seu progresso dentro do jogo primeiro.", + "swap-disc-title": "Trocar disco", + "upload-memory-card": "Enviar cartão" } diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index 62d49b5ef6..473f2b6937 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -59,6 +59,9 @@ "completion": "Conclusão", "completionist": "Completista", "confirm-delete-note": "Tem certeza de que deseja excluir a nota \"{title}\"?", + "confirm-join-body": "Você entrará em «{name}» como jogador adicional. O anfitrião mantém o controle da sessão e dos seus jogos salvos.", + "confirm-join-title": "Entrar nesta sessão?", + "confirm-join-title-of": "Entrar na sessão de {user}?", "confirm-launch-protected-body": "Você marcou \"{name}\" como {status}. Deseja jogá-lo mesmo assim?", "confirm-launch-protected-title": "Iniciar este jogo?", "convert-to-folder-body": "Esta ação converterá a ROM em uma ROM de múltiplos arquivos. Esta ação não é reversível.", @@ -150,6 +153,8 @@ "hidden": "Oculto", "how-long-to-beat": "Tempo de Jogo", "info": "Informações", + "join-session": "Entrar na sessão", + "join-session-of": "Entrar na sessão de {user}", "languages": "Idiomas", "last-played": "Jogado por último", "launchbox-cloud": "Nuvem", @@ -426,6 +431,8 @@ "status-never-playing": "Nunca jogar", "status-now-playing": "Jogando agora", "status-retired": "Abandonado", + "stream": "Transmitir", + "stream-on": "Transmitir em {container}", "summary": "Resumo", "switch-version": "Alternar versão", "tab-achievements": "Conquistas", diff --git a/frontend/src/locales/pt_BR/settings.json b/frontend/src/locales/pt_BR/settings.json index 0fc8a75d6d..90539fe6be 100644 --- a/frontend/src/locales/pt_BR/settings.json +++ b/frontend/src/locales/pt_BR/settings.json @@ -437,6 +437,25 @@ "sort-size": "Tamanho", "states": "Save states", "stopped": "Parado", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Resumo", "task-failed": "Falha na tarefa", "task-history": "Histórico de tarefas", diff --git a/frontend/src/locales/ro_RO/activity.json b/frontend/src/locales/ro_RO/activity.json index 80cecf05ad..99623acb77 100644 --- a/frontend/src/locales/ro_RO/activity.json +++ b/frontend/src/locales/ro_RO/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/ro_RO/platform.json index f974f8baef..71c6b13099 100644 --- a/frontend/src/locales/ro_RO/platform.json +++ b/frontend/src/locales/ro_RO/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Cutii pătrate vechi", "on-disk": "Pe disc", "only-with-games": "Doar platformele cu jocuri", + "playable-both": "Se poate juca în browser și se poate transmite de pe {label}", + "playable-browser-dosbox": "Se poate juca în browser prin DOSBox", + "playable-browser-emulatorjs": "Se poate juca în browser prin EmulatorJS", + "playable-browser-ruffle": "Se poate juca în browser prin Ruffle", + "playable-none": "Nu se poate juca în browser și nici prin streaming", + "playable-stream": "Se poate transmite de pe {label}", "player-count": "Numărul de jucători", "properties": "Proprietăți", "random-rom": "ROM aleatoriu", diff --git a/frontend/src/locales/ro_RO/play.json b/frontend/src/locales/ro_RO/play.json index f0aa884f06..4c6c77e6b3 100644 --- a/frontend/src/locales/ro_RO/play.json +++ b/frontend/src/locales/ro_RO/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Descarcă cardul", + "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", + "join-closed": "Acea sesiune nu mai este deschisă altor jucători.", + "join-ended": "Acea sesiune s-a încheiat.", + "keep-playing": "Continuă să joci", + "leave-dialog-text": "Gazda continuă să joace. Tu vei ieși din sesiune.", + "leave-dialog-title": "Ieși din sesiune?", + "leave-session": "Ieși din sesiune", + "load-autosave": "Încarcă salvarea automată", + "load-state": "Încarcă starea", + "manage-memory-cards": "Gestionează cardurile de memorie", + "manual-disc-swap-hint": "Acest emulator schimbă discurile din propriul meniu, nu de aici.", + "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-download-failed": "Cardul de memorie nu a putut fi descărcat", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "În mod implicit se încarcă cel mai recent card. Progresul se sincronizează la ieșire.", + "memory-card-import-adopt": "Importă acest card", + "memory-card-import-body": "Acest container are deja un card de memorie cu {count} fișier(e) de salvare. Îl imporți în biblioteca ta sau începi cu un card nou?", + "memory-card-import-discard": "Începe de la zero", + "memory-card-import-discard-body": "Cardul de memorie de pe acest container va fi șters. Nimic din ce este salvat pe el nu va putea fi recuperat.", + "memory-card-import-discard-confirm": "Șterge și începe de la zero", + "memory-card-import-discard-title": "Ștergi cardul existent?", + "memory-card-import-games": "Jocuri pe acest card: {games}", + "memory-card-import-size": "{size} în total", + "memory-card-import-title": "Card de memorie găsit", + "memory-card-no-data": "Acest card nu are încă date salvate", + "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-unreadable-body": "RomM nu a putut citi cardul de memorie de pe acest container, așa că nu poate spune dacă are salvări pe el. Încearcă mai târziu sau începe de la zero, ștergând tot ce se află pe el.", + "memory-card-unreadable-override": "Începe oricum de la zero", + "memory-card-unreadable-reason": "Motiv: {reason}", + "memory-card-unreadable-title": "Cardul de memorie nu a putut fi citit", + "memory-card-unreadable-warning": "Cardul existent va fi șters și nu va putea fi recuperat.", + "memory-card-updated": "Actualizat {when}", + "memory-card-upload-failed": "Cardul de memorie nu a putut fi încărcat", + "memory-card-uploaded": "Card de memorie încărcat", + "memory-card-versions": "Istoricul versiunilor", + "memory-cards": "Carduri de memorie", + "memory-cards-empty": "Nu ai încă niciun card de memorie pentru acest emulator.", + "multiplayer": "Mod multiplayer", + "multiplayer-hint": "Afișează un buton Alătură-te pe pagina acestui joc și păstrează vizibile chatul și camera web.", + "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ă", @@ -20,17 +101,37 @@ "no-states-available": "Nicio stare disponibilă", "page-title": "Joacă {name}", "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-data": "Date de salvare", + "save-data-detail": "Actualizat {time} · {size}", + "save-data-none": "Încă nu există date de salvare", + "save-data-none-hint": "{platform} păstrează progresul în salvarea proprie a jocului", + "save-data-none-note": "Joacă, apoi salvează în joc. Se sincronizează la încheierea sesiunii.", + "save-data-note": "Restaurat pe consolă înainte de lansare. Încarcă-l din meniul jocului.", + "save-data-synced": "Sincronizat", + "save-slot": "Slot de salvare", + "save-state": "Salvează starea", "select-background-color": "Selectează culoarea de fundal", "select-save": "Selectează salvare", "select-state": "Selectează stare", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Afișează rama", "slot": "Slot", "start-fresh-hint": "Alege una mai jos pentru a relua sau apasă Joacă pentru a începe de la zero.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "A apărut o eroare neașteptată.", "stream-error-load-rom": "Detaliile ROM-ului nu au putut fi încărcate.", "stream-error-not-configured": "Niciun container de streaming nu este configurat pentru {platform}.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Ieșire din ecran complet", "stream-frame-title": "Streamul jocului", "stream-fullscreen": "Ecran complet", - "stream-load-autosave": "Încarcă salvarea automată", "stream-load-state": "Încarcă starea", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Dezactivează sunetul", "stream-occupied-body": "{rom} este jucat din {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Altcineva joacă în acest moment. Încercați din nou mai târziu.", "stream-occupied-title": "Sesiune în uz", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Salvează și ieși", - "stream-save-slot": "Slot de salvare", "stream-save-state": "Salvează starea", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "Emulatorul nu a confirmat salvarea. Progresul recent ar putea fi pierdut.", - "stream-slot-n": "Slot {n}", "stream-stop": "Oprește", "stream-subtitle": "Streaming", + "stream-swap-disc": "Schimbă discul", "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.", + "swap-disc-confirm": "Schimbă", + "swap-disc-failed": "Schimbarea discului a eșuat. Consola poate fi încă în mijlocul schimbării, încearcă din nou.", + "swap-disc-text": "Alege discul de încărcat. Jocul rămâne pornit, așa că salvează-ți mai întâi progresul în joc.", + "swap-disc-title": "Schimbarea discului", + "upload-memory-card": "Încarcă un card" } diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index 988fbb1bd4..095284a022 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -59,6 +59,9 @@ "completion": "Finalizare", "completionist": "Completist", "confirm-delete-note": "Sunteți sigur că vreți să ștergeți notația \"{title}\"?", + "confirm-join-body": "Vei fi adăugat la „{name}” ca jucător suplimentar. Gazda păstrează controlul asupra sesiunii și al salvărilor acesteia.", + "confirm-join-title": "Te alături acestei sesiuni?", + "confirm-join-title-of": "Te alături sesiunii lui {user}?", "confirm-launch-protected-body": "Ai marcat „{name}“ ca {status}. Vrei să îl joci oricum?", "confirm-launch-protected-title": "Pornești acest joc?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Ascuns", "how-long-to-beat": "Timp de Joc", "info": "Informații", + "join-session": "Alătură-te sesiunii", + "join-session-of": "Alătură-te sesiunii lui {user}", "languages": "Limbi", "last-played": "Jucat ultima dată", "launchbox-cloud": "Cloud", @@ -426,6 +431,8 @@ "status-never-playing": "Nu voi juca niciodată", "status-now-playing": "Joc acum", "status-retired": "Abandonat", + "stream": "Redare în flux", + "stream-on": "Redare în flux pe {container}", "summary": "Rezumat", "switch-version": "Schimbă versiunea", "tab-achievements": "Realizări", diff --git a/frontend/src/locales/ro_RO/settings.json b/frontend/src/locales/ro_RO/settings.json index ccf6a02c6f..fad4e0c3d0 100644 --- a/frontend/src/locales/ro_RO/settings.json +++ b/frontend/src/locales/ro_RO/settings.json @@ -437,6 +437,25 @@ "sort-size": "Dimensiune", "states": "Stări", "stopped": "Oprire", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Rezumat", "task-failed": "Sarcina a eșuat", "task-history": "Istoric sarcini", diff --git a/frontend/src/locales/ru_RU/activity.json b/frontend/src/locales/ru_RU/activity.json index 02c1acd5cf..a59682e8e1 100644 --- a/frontend/src/locales/ru_RU/activity.json +++ b/frontend/src/locales/ru_RU/activity.json @@ -10,5 +10,14 @@ "now-playing": "Сейчас играют", "playing-on": "Играет на {device}", "playing-since": "Играет с {time}", - "total-sessions": "Всего сессий" + "release-failed": "Не удалось освободить сессию", + "release-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/ru_RU/platform.json index d74a276a28..b69232f9b2 100644 --- a/frontend/src/locales/ru_RU/platform.json +++ b/frontend/src/locales/ru_RU/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Старые квадратные кейсы", "on-disk": "На диске", "only-with-games": "Только платформы с играми", + "playable-both": "Запускается в браузере и транслируется с {label}", + "playable-browser-dosbox": "Запускается в браузере через DOSBox", + "playable-browser-emulatorjs": "Запускается в браузере через EmulatorJS", + "playable-browser-ruffle": "Запускается в браузере через Ruffle", + "playable-none": "Не запускается ни в браузере, ни через трансляцию", + "playable-stream": "Транслируется с {label}", "player-count": "Количество игроков", "properties": "Свойства", "random-rom": "Случайный ROM", diff --git a/frontend/src/locales/ru_RU/play.json b/frontend/src/locales/ru_RU/play.json index 1acbc84920..d4a1eb2bb1 100644 --- a/frontend/src/locales/ru_RU/play.json +++ b/frontend/src/locales/ru_RU/play.json @@ -4,15 +4,96 @@ "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": "Снять выбор состояния", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Скачать карту", + "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": "Полный экран", + "join-closed": "Эта сессия больше не открыта для других игроков.", + "join-ended": "Эта сессия завершена.", + "keep-playing": "Продолжить игру", + "leave-dialog-text": "Хост продолжает играть. Вы выйдете из сессии.", + "leave-dialog-title": "Выйти из сессии?", + "leave-session": "Выйти из сессии", + "load-autosave": "Загрузить автосохранение", + "load-state": "Загрузить состояние", + "manage-memory-cards": "Управление картами памяти", + "manual-disc-swap-hint": "Этот эмулятор меняет диски через собственное меню, а не отсюда.", + "memory-card": "Карта памяти", + "memory-card-count": "{count} карт", + "memory-card-create-failed": "Не удалось создать карту памяти", + "memory-card-created": "Карта памяти создана", + "memory-card-delete-failed": "Не удалось удалить карту памяти", + "memory-card-deleted": "Карта памяти удалена", + "memory-card-download-failed": "Не удалось скачать карту памяти", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "По умолчанию загружается новейшая карта. Прогресс синхронизируется при выходе.", + "memory-card-import-adopt": "Импортировать эту карту", + "memory-card-import-body": "На этом контейнере уже есть карта памяти с {count} файл(ами) сохранений. Импортировать её в вашу библиотеку или начать с чистой карты?", + "memory-card-import-discard": "Начать заново", + "memory-card-import-discard-body": "Карта памяти на этом контейнере будет стёрта. Ничего из сохранённого на ней восстановить не удастся.", + "memory-card-import-discard-confirm": "Стереть и начать заново", + "memory-card-import-discard-title": "Стереть существующую карту?", + "memory-card-import-games": "Игры на этой карте: {games}", + "memory-card-import-size": "Всего {size}", + "memory-card-import-title": "Найдена карта памяти", + "memory-card-no-data": "На этой карте пока нет сохранённых данных", + "memory-card-no-versions": "Пока нет сохранённых версий", + "memory-card-rename-failed": "Не удалось переименовать карту памяти", + "memory-card-renamed": "Карта памяти переименована", + "memory-card-share-failed": "Не удалось изменить общий доступ к карте памяти", + "memory-card-share-label": "Доступна другим пользователям", + "memory-card-shared": "Общий доступ", + "memory-card-unreadable-body": "RomM не смог прочитать карту памяти на этом контейнере, поэтому нельзя определить, есть ли на ней сохранения. Попробуйте позже или начните заново, стерев всё, что на ней есть.", + "memory-card-unreadable-override": "Всё равно начать заново", + "memory-card-unreadable-reason": "Причина: {reason}", + "memory-card-unreadable-title": "Не удалось прочитать карту памяти", + "memory-card-unreadable-warning": "Существующая карта будет стёрта, и восстановить её не получится.", + "memory-card-updated": "Обновлено {when}", + "memory-card-upload-failed": "Не удалось загрузить карту памяти", + "memory-card-uploaded": "Карта памяти загружена", + "memory-card-versions": "История версий", + "memory-cards": "Карты памяти", + "memory-cards-empty": "У вас пока нет карт памяти для этого эмулятора.", + "multiplayer": "Режим мультиплеера", + "multiplayer-hint": "Показывает кнопку «Присоединиться» на странице этой игры и оставляет чат и веб-камеру видимыми.", + "mute": "Отключить звук", + "new-memory-card": "Новая карта", + "no-memory-cards": "Пока нет карт памяти", "no-save-selected": "Сохранение не выбрано", "no-saves-available": "Нет доступных сохранений", "no-screenshot-available": "Скриншот недоступен", @@ -20,17 +101,37 @@ "no-states-available": "Нет доступных состояний", "page-title": "Играть в {name}", "play": "Играть", + "play-on": "Играть на {label}", "powered-by": "На базе", "quit": "Выйти", + "rename-memory-card": "Переименовать карту памяти", + "resume-failed": "Не удалось загрузить выбранное состояние. Игра началась заново.", "resume-from-save": "Продолжить с сохранения", "resume-from-state": "Продолжить с состояния", "save-and-quit": "Сохранить и выйти", + "save-data": "Данные сохранения", + "save-data-detail": "Обновлено {time} · {size}", + "save-data-none": "Данных сохранения пока нет", + "save-data-none-hint": "{platform} хранит прогресс в собственном сохранении игры", + "save-data-none-note": "Играйте и сохранитесь в игре. Синхронизация произойдёт в конце сеанса.", + "save-data-note": "Восстановлено на консоль перед запуском. Загрузите его из меню самой игры.", + "save-data-synced": "Синхронизировано", + "save-slot": "Слот сохранения", + "save-state": "Сохранить состояние", "select-background-color": "Выбрать цвет фона", "select-save": "Выбрать сохранение", "select-state": "Выбрать состояние", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Показать рамку", "slot": "Слот", "start-fresh-hint": "Выберите одно из ниже, чтобы продолжить, или нажмите «Играть», чтобы начать заново.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Произошла непредвиденная ошибка.", "stream-error-load-rom": "Не удалось загрузить сведения о ROM.", "stream-error-not-configured": "Для {platform} не настроен контейнер потоковой передачи.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Выйти из полноэкранного режима", "stream-frame-title": "Трансляция игры", "stream-fullscreen": "Полный экран", - "stream-load-autosave": "Загрузить автосохранение", "stream-load-state": "Загрузить состояние", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Отключить звук", "stream-occupied-body": "В {rom} играют с {time}.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Сейчас играет кто-то другой. Повторите попытку позже.", "stream-occupied-title": "Сеанс занят", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Сохранить и выйти", - "stream-save-slot": "Слот сохранения", "stream-save-state": "Сохранить состояние", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "Эмулятор не подтвердил сохранение. Недавний прогресс может быть потерян.", - "stream-slot-n": "Слот {n}", "stream-stop": "Остановить", "stream-subtitle": "Трансляция", + "stream-swap-disc": "Сменить диск", "stream-try-again": "Повторить попытку", "stream-unknown-game": "Неизвестная игра", "stream-unmute": "Включить звук", - "stream-volume": "Громкость" + "stream-volume": "Громкость", + "streaming-description": "Игра запускается в выделенном контейнере {label} и транслируется прямо в ваш браузер.", + "swap-disc-confirm": "Сменить", + "swap-disc-failed": "Не удалось сменить диск. Консоль может быть ещё в процессе смены, попробуйте снова.", + "swap-disc-text": "Выберите диск для загрузки. Игра продолжает работать, поэтому сначала сохраните прогресс внутри игры.", + "swap-disc-title": "Смена диска", + "upload-memory-card": "Загрузить карту" } diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index 3f29cab418..b077b6b95f 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -59,6 +59,9 @@ "completion": "Завершение", "completionist": "Перфекционист", "confirm-delete-note": "Вы уверены, что хотите удалить заметку \"{title}\"?", + "confirm-join-body": "Вы присоединитесь к «{name}» как дополнительный игрок. Управление сессией и её сохранениями остаётся у ведущего.", + "confirm-join-title": "Присоединиться к этой сессии?", + "confirm-join-title-of": "Присоединиться к сессии {user}?", "confirm-launch-protected-body": "Вы отметили «{name}» как {status}. Всё равно хотите поиграть?", "confirm-launch-protected-title": "Запустить эту игру?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Скрыто", "how-long-to-beat": "Время Прохождения", "info": "Информация", + "join-session": "Присоединиться к сессии", + "join-session-of": "Присоединиться к сессии {user}", "languages": "Языки", "last-played": "Последний раз играли", "launchbox-cloud": "Облако", @@ -426,6 +431,8 @@ "status-never-playing": "Никогда не играть", "status-now-playing": "Сейчас играю", "status-retired": "Заброшено", + "stream": "Стриминг", + "stream-on": "Стриминг на {container}", "summary": "Резюме", "switch-version": "Переключить версию", "tab-achievements": "Достижения", diff --git a/frontend/src/locales/ru_RU/settings.json b/frontend/src/locales/ru_RU/settings.json index 36e141e26a..c488b0111a 100644 --- a/frontend/src/locales/ru_RU/settings.json +++ b/frontend/src/locales/ru_RU/settings.json @@ -437,6 +437,25 @@ "sort-size": "Размер", "states": "Состояния", "stopped": "Остановлено", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Сводка", "task-failed": "Задача не удалась", "task-history": "История задач", diff --git a/frontend/src/locales/tr_TR/activity.json b/frontend/src/locales/tr_TR/activity.json index b665183abc..6c52ecc90f 100644 --- a/frontend/src/locales/tr_TR/activity.json +++ b/frontend/src/locales/tr_TR/activity.json @@ -10,5 +10,14 @@ "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-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/tr_TR/platform.json index e5ca2a17e5..d2fa0ac63a 100644 --- a/frontend/src/locales/tr_TR/platform.json +++ b/frontend/src/locales/tr_TR/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "Eski kare kutular", "on-disk": "Diskte", "only-with-games": "Yalnızca oyunu olan platformlar", + "playable-both": "Tarayıcıda oynanabilir ve {label} üzerinden yayınlanabilir", + "playable-browser-dosbox": "DOSBox ile tarayıcıda oynanabilir", + "playable-browser-emulatorjs": "EmulatorJS ile tarayıcıda oynanabilir", + "playable-browser-ruffle": "Ruffle ile tarayıcıda oynanabilir", + "playable-none": "Tarayıcıda oynanamaz ve yayınlanamaz", + "playable-stream": "{label} üzerinden yayınlanabilir", "player-count": "Oyuncu sayısı", "properties": "Özellikler", "random-rom": "Rastgele ROM", diff --git a/frontend/src/locales/tr_TR/play.json b/frontend/src/locales/tr_TR/play.json index d77ece5aff..436162960b 100644 --- a/frontend/src/locales/tr_TR/play.json +++ b/frontend/src/locales/tr_TR/play.json @@ -4,15 +4,96 @@ "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", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "Kartı indir", + "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", + "join-closed": "Bu oturum artık diğer oyunculara açık değil.", + "join-ended": "Bu oturum sona erdi.", + "keep-playing": "Oynamaya devam et", + "leave-dialog-text": "Ana oyuncu oynamaya devam ediyor. Siz oturumdan ayrılacaksınız.", + "leave-dialog-title": "Oturumdan ayrılınsın mı?", + "leave-session": "Oturumdan ayrıl", + "load-autosave": "Otomatik kaydı yükle", + "load-state": "Durumu yükle", + "manage-memory-cards": "Hafıza kartlarını yönet", + "manual-disc-swap-hint": "Bu emülatör diskleri kendi menüsünden değiştirir, buradan değil.", + "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-download-failed": "Hafıza kartı indirilemedi", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "Varsayılan olarak en yeni kart yüklenir. İlerleme, çıkışta senkronize edilir.", + "memory-card-import-adopt": "Bu kartı içe aktar", + "memory-card-import-body": "Bu kapsayıcıda zaten {count} kayıt dosyası içeren bir hafıza kartı var. Kütüphanenize aktarmak mı, yoksa yeni bir kartla başlamak mı istersiniz?", + "memory-card-import-discard": "Sıfırdan başla", + "memory-card-import-discard-body": "Bu kapsayıcıdaki hafıza kartı silinecek. Üzerinde kayıtlı hiçbir şey geri getirilemez.", + "memory-card-import-discard-confirm": "Sil ve sıfırdan başla", + "memory-card-import-discard-title": "Mevcut kart silinsin mi?", + "memory-card-import-games": "Bu karttaki oyunlar: {games}", + "memory-card-import-size": "Toplam {size}", + "memory-card-import-title": "Hafıza kartı bulundu", + "memory-card-no-data": "Bu kartta henüz kayıtlı veri yok", + "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-unreadable-body": "RomM bu kapsayıcıdaki hafıza kartını okuyamadı, bu yüzden kayıt içerip içermediğini bilemiyor. Daha sonra tekrar deneyin ya da sıfırdan başlayıp üzerindekileri silin.", + "memory-card-unreadable-override": "Yine de sıfırdan başla", + "memory-card-unreadable-reason": "Neden: {reason}", + "memory-card-unreadable-title": "Hafıza kartı okunamadı", + "memory-card-unreadable-warning": "Mevcut kart silinecek ve geri getirilemeyecek.", + "memory-card-updated": "{when} güncellendi", + "memory-card-upload-failed": "Hafıza kartı yüklenemedi", + "memory-card-uploaded": "Hafıza kartı yüklendi", + "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.", + "multiplayer": "Çok oyunculu mod", + "multiplayer-hint": "Bu oyunun sayfasında bir Katıl düğmesi gösterir, sohbeti ve web kamerasını görünür tutar.", + "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", @@ -20,17 +101,37 @@ "no-states-available": "Mevcut durum kaydı yok", "page-title": "{name} oyna", "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-data": "Kayıt verisi", + "save-data-detail": "Güncellendi {time} · {size}", + "save-data-none": "Henüz kayıt verisi yok", + "save-data-none-hint": "{platform}, ilerlemeyi oyunun kendi kaydında tutar", + "save-data-none-note": "Oyna, sonra oyun içinde kaydet. Oturum bitince eşitlenir.", + "save-data-note": "Başlatmadan önce konsola geri yüklendi. Oyunun kendi menüsünden yükle.", + "save-data-synced": "Eşitlendi", + "save-slot": "Kayıt yuvası", + "save-state": "Durumu kaydet", "select-background-color": "Arka plan rengi seç", "select-save": "Kayıt seç", "select-state": "Durum kaydı seç", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "Çerçeveyi göster", "slot": "Slot", "start-fresh-hint": "Devam etmek için aşağıdan birini seçin veya baştan başlamak için Oyna'ya basın.", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "Beklenmeyen bir hata oluştu.", "stream-error-load-rom": "ROM ayrıntıları yüklenemedi.", "stream-error-not-configured": "{platform} için yapılandırılmış bir yayın konteyneri yok.", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "Tam ekrandan çık", "stream-frame-title": "Oyun yayını", "stream-fullscreen": "Tam ekran", - "stream-load-autosave": "Otomatik kaydı yükle", "stream-load-state": "Durumu yükle", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "Sessize al", "stream-occupied-body": "{rom} {time} saatinden beri oynanıyor.", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "Şu anda başka biri oynuyor. Daha sonra tekrar deneyin.", "stream-occupied-title": "Oturum kullanımda", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "Kaydet ve çık", - "stream-save-slot": "Kayıt yuvası", "stream-save-state": "Durumu kaydet", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "Emülatör kaydı onaylamadı. Son ilerleme kaybolabilir.", - "stream-slot-n": "Yuva {n}", "stream-stop": "Durdur", "stream-subtitle": "Yayın", + "stream-swap-disc": "Disk değiştir", "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.", + "swap-disc-confirm": "Değiştir", + "swap-disc-failed": "Disk değiştirilemedi. Konsol hâlâ değişimin ortasında olabilir, tekrar deneyin.", + "swap-disc-text": "Yüklenecek diski seçin. Oyun çalışmaya devam ediyor, bu yüzden önce ilerlemenizi oyun içinde kaydedin.", + "swap-disc-title": "Disk değiştirme", + "upload-memory-card": "Kart yükle" } diff --git a/frontend/src/locales/tr_TR/rom.json b/frontend/src/locales/tr_TR/rom.json index a713f8e7df..36f320c4ab 100644 --- a/frontend/src/locales/tr_TR/rom.json +++ b/frontend/src/locales/tr_TR/rom.json @@ -59,6 +59,9 @@ "completion": "Tamamlama", "completionist": "Yüzde yüz tamamlayıcı", "confirm-delete-note": "\"{title}\" notunu silmek istediğinizden emin misiniz?", + "confirm-join-body": "\"{name}\" oyununa ek oyuncu olarak katılacaksın. Oturumun ve kayıtlarının denetimi ev sahibinde kalır.", + "confirm-join-title": "Bu oturuma katılmak istiyor musun?", + "confirm-join-title-of": "{user} adlı kullanıcının oturumuna katılmak istiyor musun?", "confirm-launch-protected-body": "\"{name}\" oyununu {status} olarak işaretlediniz. Yine de oynamak istiyor musunuz?", "confirm-launch-protected-title": "Bu oyun başlatılsın mı?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "Gizli", "how-long-to-beat": "Tamamlama Süresi", "info": "Bilgi", + "join-session": "Oturuma katıl", + "join-session-of": "{user} adlı kullanıcının oturumuna katıl", "languages": "Diller", "last-played": "Son oynama", "launchbox-cloud": "Bulut", @@ -426,6 +431,8 @@ "status-never-playing": "Hiç Oynanmayacak", "status-now-playing": "Şu An Oynanıyor", "status-retired": "Bırakıldı", + "stream": "Yayınla", + "stream-on": "{container} üzerinde yayınla", "summary": "Özet", "switch-version": "Sürümü değiştir", "tab-achievements": "Başarımlar", diff --git a/frontend/src/locales/tr_TR/settings.json b/frontend/src/locales/tr_TR/settings.json index 2b25b25826..b7d0be9373 100644 --- a/frontend/src/locales/tr_TR/settings.json +++ b/frontend/src/locales/tr_TR/settings.json @@ -437,6 +437,25 @@ "sort-size": "Boyut", "states": "Durum Kayıtları", "stopped": "Durduruldu", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "Özet", "task-failed": "Görev başarısız", "task-history": "Görev Geçmişi", diff --git a/frontend/src/locales/zh_CN/activity.json b/frontend/src/locales/zh_CN/activity.json index 49dc48ccdc..2959608046 100644 --- a/frontend/src/locales/zh_CN/activity.json +++ b/frontend/src/locales/zh_CN/activity.json @@ -10,5 +10,14 @@ "now-playing": "正在游玩", "playing-on": "正在 {device} 上游玩", "playing-since": "自 {time} 起游玩", - "total-sessions": "总会话数" + "release-failed": "无法释放会话", + "release-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/zh_CN/platform.json index 3def82022f..f9e1c62718 100644 --- a/frontend/src/locales/zh_CN/platform.json +++ b/frontend/src/locales/zh_CN/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "旧式方形盒装", "on-disk": "磁盘上", "only-with-games": "仅显示有游戏的平台", + "playable-both": "可在浏览器中游玩,也可从 {label} 串流", + "playable-browser-dosbox": "可通过 DOSBox 在浏览器中游玩", + "playable-browser-emulatorjs": "可通过 EmulatorJS 在浏览器中游玩", + "playable-browser-ruffle": "可通过 Ruffle 在浏览器中游玩", + "playable-none": "无法在浏览器中游玩,也无法串流", + "playable-stream": "可从 {label} 串流", "player-count": "玩家人数", "properties": "属性", "random-rom": "随机 ROM", diff --git a/frontend/src/locales/zh_CN/play.json b/frontend/src/locales/zh_CN/play.json index cd09a6a542..1d8d485b40 100644 --- a/frontend/src/locales/zh_CN/play.json +++ b/frontend/src/locales/zh_CN/play.json @@ -4,15 +4,96 @@ "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": "取消选择状态", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "下载记忆卡", + "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": "全屏", + "join-closed": "该会话不再对其他玩家开放。", + "join-ended": "该会话已结束。", + "keep-playing": "继续游戏", + "leave-dialog-text": "主机会继续游戏,你将退出该会话。", + "leave-dialog-title": "退出会话?", + "leave-session": "退出会话", + "load-autosave": "加载自动存档", + "load-state": "加载即时存档", + "manage-memory-cards": "管理记忆卡", + "manual-disc-swap-hint": "此模拟器在自己的菜单中更换光盘,而不是在这里。", + "memory-card": "记忆卡", + "memory-card-count": "{count} 张", + "memory-card-create-failed": "无法创建记忆卡", + "memory-card-created": "已创建记忆卡", + "memory-card-delete-failed": "无法删除记忆卡", + "memory-card-deleted": "已删除记忆卡", + "memory-card-download-failed": "无法下载记忆卡", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "默认加载最新的记忆卡。退出时会同步进度。", + "memory-card-import-adopt": "导入此记忆卡", + "memory-card-import-body": "此容器上已有一张记忆卡,其中包含 {count} 个存档文件。要将它导入你的库,还是使用一张全新的记忆卡开始?", + "memory-card-import-discard": "重新开始", + "memory-card-import-discard-body": "此容器上的记忆卡将被清除。其中保存的内容都无法恢复。", + "memory-card-import-discard-confirm": "清除并重新开始", + "memory-card-import-discard-title": "要清除已有的记忆卡吗?", + "memory-card-import-games": "此记忆卡上的游戏:{games}", + "memory-card-import-size": "共 {size}", + "memory-card-import-title": "发现记忆卡", + "memory-card-no-data": "此卡尚无已保存的数据", + "memory-card-no-versions": "尚无已保存的版本", + "memory-card-rename-failed": "无法重命名记忆卡", + "memory-card-renamed": "已重命名记忆卡", + "memory-card-share-failed": "无法更改记忆卡的共享设置", + "memory-card-share-label": "与其他用户共享", + "memory-card-shared": "已共享", + "memory-card-unreadable-body": "RomM 无法读取此容器上的记忆卡,因此无法判断其中是否有存档。请稍后重试,或者清除卡上的内容后重新开始。", + "memory-card-unreadable-override": "仍然重新开始", + "memory-card-unreadable-reason": "原因:{reason}", + "memory-card-unreadable-title": "无法读取记忆卡", + "memory-card-unreadable-warning": "已有的记忆卡将被清除且无法恢复。", + "memory-card-updated": "更新于 {when}", + "memory-card-upload-failed": "无法上传记忆卡", + "memory-card-uploaded": "已上传记忆卡", + "memory-card-versions": "版本历史", + "memory-cards": "记忆卡", + "memory-cards-empty": "您还没有此模拟器的记忆卡。", + "multiplayer": "多人模式", + "multiplayer-hint": "在该游戏页面显示“加入”按钮,并保持聊天和摄像头可见。", + "mute": "静音", + "new-memory-card": "新建卡", + "no-memory-cards": "尚无记忆卡", "no-save-selected": "未选择存档", "no-saves-available": "无可用存档", "no-screenshot-available": "无可用截图", @@ -20,17 +101,37 @@ "no-states-available": "无可用状态", "page-title": "游玩 {name}", "play": "游玩", + "play-on": "在 {label} 上游玩", "powered-by": "技术支持", "quit": "退出", + "rename-memory-card": "重命名记忆卡", + "resume-failed": "无法加载所选的存档状态。游戏已重新开始。", "resume-from-save": "从存档继续", "resume-from-state": "从状态继续", "save-and-quit": "保存并退出", + "save-data": "存档数据", + "save-data-detail": "更新于 {time} · {size}", + "save-data-none": "暂无存档数据", + "save-data-none-hint": "{platform} 将进度保存在游戏自身的存档中", + "save-data-none-note": "先游玩,然后在游戏内存档。会话结束时会同步回来。", + "save-data-note": "启动前已还原到主机。请从游戏自身的菜单中读取。", + "save-data-synced": "已同步", + "save-slot": "存档位", + "save-state": "保存即时存档", "select-background-color": "选择背景颜色", "select-save": "选择存档", "select-state": "选择状态", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "显示边框", "slot": "存档位", "start-fresh-hint": "选择下方任一项继续,或点击开始从头开始。", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "发生意外错误。", "stream-error-load-rom": "无法加载 ROM 详细信息。", "stream-error-not-configured": "未为 {platform} 配置流式容器。", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "退出全屏", "stream-frame-title": "游戏串流", "stream-fullscreen": "全屏", - "stream-load-autosave": "加载自动存档", "stream-load-state": "加载状态", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "静音", "stream-occupied-body": "{rom} 自 {time} 起一直在游玩。", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "当前有其他人正在游玩。请稍后再试。", "stream-occupied-title": "会话使用中", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "保存并退出", - "stream-save-slot": "存档槽", "stream-save-state": "保存状态", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "模拟器未确认保存。最近的进度可能会丢失。", - "stream-slot-n": "槽位 {n}", "stream-stop": "停止", "stream-subtitle": "串流", + "stream-swap-disc": "更换光盘", "stream-try-again": "重试", "stream-unknown-game": "未知游戏", "stream-unmute": "取消静音", - "stream-volume": "音量" + "stream-volume": "音量", + "streaming-description": "游戏在专用的 {label} 容器中运行,并直接串流到您的浏览器。", + "swap-disc-confirm": "更换", + "swap-disc-failed": "更换光盘失败。主机可能仍在更换过程中,请重试。", + "swap-disc-text": "选择要载入的光盘。游戏会继续运行,请先在游戏内保存进度。", + "swap-disc-title": "更换光盘", + "upload-memory-card": "上传记忆卡" } diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index ba2d086cec..b18b5efbf5 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -59,6 +59,9 @@ "completion": "完成度", "completionist": "完美主义者", "confirm-delete-note": "确定要删除笔记\"{title}\"吗?", + "confirm-join-body": "你将作为额外玩家加入《{name}》。会话及其存档仍由主机控制。", + "confirm-join-title": "加入此会话?", + "confirm-join-title-of": "加入 {user} 的会话?", "confirm-launch-protected-body": "您已将“{name}”标记为{status}。仍要玩吗?", "confirm-launch-protected-title": "启动这个游戏吗?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "隐藏", "how-long-to-beat": "游戏时长", "info": "信息", + "join-session": "加入会话", + "join-session-of": "加入 {user} 的会话", "languages": "语言", "last-played": "最后游玩", "launchbox-cloud": "云端", @@ -426,6 +431,8 @@ "status-never-playing": "不玩", "status-now-playing": "正在游玩", "status-retired": "已搁置", + "stream": "串流", + "stream-on": "在 {container} 上串流", "summary": "概要", "switch-version": "切换版本", "tab-achievements": "成就", diff --git a/frontend/src/locales/zh_CN/settings.json b/frontend/src/locales/zh_CN/settings.json index fe064c806b..bc0959203b 100644 --- a/frontend/src/locales/zh_CN/settings.json +++ b/frontend/src/locales/zh_CN/settings.json @@ -437,6 +437,25 @@ "sort-size": "容量", "states": "状态", "stopped": "已停止", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "概要", "task-failed": "任务失败", "task-history": "任务历史", diff --git a/frontend/src/locales/zh_TW/activity.json b/frontend/src/locales/zh_TW/activity.json index a7b951074c..36ed019948 100644 --- a/frontend/src/locales/zh_TW/activity.json +++ b/frontend/src/locales/zh_TW/activity.json @@ -10,5 +10,14 @@ "now-playing": "正在遊玩", "playing-on": "正在 {device} 上遊玩", "playing-since": "自 {time} 起遊玩", - "total-sessions": "工作階段總數" + "release-failed": "無法釋放工作階段", + "release-reason": "Reason (optional)", + "release-reason-placeholder": "Shown to the player whose session ends", + "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/platform.json b/frontend/src/locales/zh_TW/platform.json index c5a33091a9..eae942f58f 100644 --- a/frontend/src/locales/zh_TW/platform.json +++ b/frontend/src/locales/zh_TW/platform.json @@ -51,6 +51,12 @@ "old-squared-cases": "舊式方形卡帶盒", "on-disk": "在磁碟上", "only-with-games": "僅顯示有遊戲的平台", + "playable-both": "可在瀏覽器中遊玩,也可從 {label} 串流", + "playable-browser-dosbox": "可透過 DOSBox 在瀏覽器中遊玩", + "playable-browser-emulatorjs": "可透過 EmulatorJS 在瀏覽器中遊玩", + "playable-browser-ruffle": "可透過 Ruffle 在瀏覽器中遊玩", + "playable-none": "無法在瀏覽器中遊玩,也無法串流", + "playable-stream": "可從 {label} 串流", "player-count": "玩家人數", "properties": "屬性", "random-rom": "隨機 ROM", diff --git a/frontend/src/locales/zh_TW/play.json b/frontend/src/locales/zh_TW/play.json index 1ec1a8ae3f..3d75bb44ec 100644 --- a/frontend/src/locales/zh_TW/play.json +++ b/frontend/src/locales/zh_TW/play.json @@ -4,15 +4,96 @@ "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": "取消選擇即時存檔", + "desktop-back": "Back to administration", + "desktop-error-no-container": "That streaming container is not configured.", + "desktop-error-occupied": "This container is already in use. End the session holding it first.", + "desktop-error-release": "The desktop session could not be released. The container may still be in use.", + "desktop-error-server": "The desktop session could not be opened.", + "desktop-exit": "End desktop session", + "desktop-exit-body": "The container will be released and anything still open on the desktop will be closed. Emulator settings you saved are kept.", + "desktop-exit-confirm": "End session", + "desktop-exit-title": "End this desktop session?", + "desktop-frame-title": "Container desktop", + "desktop-subtitle": "Desktop session", + "desktop-title": "Container desktop", + "download-memory-card": "下載記憶卡", + "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": "全螢幕", + "join-closed": "該工作階段不再對其他玩家開放。", + "join-ended": "該工作階段已結束。", + "keep-playing": "繼續遊戲", + "leave-dialog-text": "主機會繼續遊玩,你將退出該工作階段。", + "leave-dialog-title": "退出工作階段?", + "leave-session": "退出工作階段", + "load-autosave": "載入自動存檔", + "load-state": "載入即時存檔", + "manage-memory-cards": "管理記憶卡", + "manual-disc-swap-hint": "此模擬器在自己的選單中更換光碟,而不是在這裡。", + "memory-card": "記憶卡", + "memory-card-count": "{count} 張", + "memory-card-create-failed": "無法建立記憶卡", + "memory-card-created": "已建立記憶卡", + "memory-card-delete-failed": "無法刪除記憶卡", + "memory-card-deleted": "已刪除記憶卡", + "memory-card-download-failed": "無法下載記憶卡", + "memory-card-erase-keyword": "ERASE", + "memory-card-hint": "預設載入最新的記憶卡。離開時會同步進度。", + "memory-card-import-adopt": "匯入此記憶卡", + "memory-card-import-body": "此容器上已有一張記憶卡,其中包含 {count} 個存檔檔案。要將它匯入你的媒體庫,還是使用一張全新的記憶卡開始?", + "memory-card-import-discard": "重新開始", + "memory-card-import-discard-body": "此容器上的記憶卡將被清除。其中儲存的內容都無法復原。", + "memory-card-import-discard-confirm": "清除並重新開始", + "memory-card-import-discard-title": "要清除既有的記憶卡嗎?", + "memory-card-import-games": "此記憶卡上的遊戲:{games}", + "memory-card-import-size": "共 {size}", + "memory-card-import-title": "發現記憶卡", + "memory-card-no-data": "此卡尚無已儲存的資料", + "memory-card-no-versions": "尚無已儲存的版本", + "memory-card-rename-failed": "無法重新命名記憶卡", + "memory-card-renamed": "已重新命名記憶卡", + "memory-card-share-failed": "無法變更記憶卡的分享設定", + "memory-card-share-label": "與其他使用者分享", + "memory-card-shared": "已分享", + "memory-card-unreadable-body": "RomM 無法讀取此容器上的記憶卡,因此無法判斷其中是否有存檔。請稍後再試,或清除卡上的內容後重新開始。", + "memory-card-unreadable-override": "仍然重新開始", + "memory-card-unreadable-reason": "原因:{reason}", + "memory-card-unreadable-title": "無法讀取記憶卡", + "memory-card-unreadable-warning": "既有的記憶卡將被清除且無法復原。", + "memory-card-updated": "更新於 {when}", + "memory-card-upload-failed": "無法上傳記憶卡", + "memory-card-uploaded": "已上傳記憶卡", + "memory-card-versions": "版本歷史", + "memory-cards": "記憶卡", + "memory-cards-empty": "您還沒有此模擬器的記憶卡。", + "multiplayer": "多人模式", + "multiplayer-hint": "在此遊戲頁面顯示「加入」按鈕,並保持聊天與網路攝影機可見。", + "mute": "靜音", + "new-memory-card": "新增卡", + "no-memory-cards": "尚無記憶卡", "no-save-selected": "未選擇存檔", "no-saves-available": "無可用存檔", "no-screenshot-available": "沒有可用的截圖", @@ -20,17 +101,37 @@ "no-states-available": "無可用即時存檔", "page-title": "遊玩 {name}", "play": "遊玩", + "play-on": "在 {label} 上遊玩", "powered-by": "技術提供", "quit": "退出", + "rename-memory-card": "重新命名記憶卡", + "resume-failed": "無法載入所選的存檔狀態。遊戲已重新開始。", "resume-from-save": "從存檔繼續", "resume-from-state": "從即時存檔繼續", "save-and-quit": "保存並退出", + "save-data": "存檔資料", + "save-data-detail": "更新於 {time} · {size}", + "save-data-none": "尚無存檔資料", + "save-data-none-hint": "{platform} 將進度保存在遊戲本身的存檔中", + "save-data-none-note": "先遊玩,然後在遊戲內存檔。工作階段結束時會同步回來。", + "save-data-note": "啟動前已還原到主機。請從遊戲本身的選單中讀取。", + "save-data-synced": "已同步", + "save-slot": "存檔格", + "save-state": "儲存即時存檔", "select-background-color": "選擇背景顏色", "select-save": "選擇存檔", "select-state": "選擇即時存檔", + "session-ended": "Your streaming session has ended.", + "session-ended-by": "An administrator ({user}) ended your streaming session.", + "session-ended-reason-label": "Reason given", + "session-ended-title": "Session ended", "show-bezel": "顯示邊框", "slot": "存檔槽", "start-fresh-hint": "從下方選擇一項繼續遊玩,或按下 Play 重新開始。", + "states-view": "State view", + "states-view-grid": "Grid", + "states-view-list": "List", + "states-view-strip": "Row", "stream-error-generic": "發生意外錯誤。", "stream-error-load-rom": "無法載入 ROM 詳細資訊。", "stream-error-not-configured": "未為 {platform} 設定串流容器。", @@ -38,21 +139,29 @@ "stream-exit-fullscreen": "退出全螢幕", "stream-frame-title": "遊戲串流", "stream-fullscreen": "全螢幕", - "stream-load-autosave": "載入自動存檔", "stream-load-state": "載入狀態", + "stream-load-state-failed": "The state could not be loaded.", "stream-mute": "靜音", "stream-occupied-body": "{rom} 自 {time} 起一直在遊玩。", + "stream-occupied-draining": "The previous session is still saving. Try again in a moment.", "stream-occupied-fallback": "目前有其他人正在遊玩。請稍後再試。", "stream-occupied-title": "工作階段使用中", + "stream-release-failed": "The session could not be released. The container may still be in use.", "stream-save-and-exit": "儲存並退出", - "stream-save-slot": "存檔槽", "stream-save-state": "儲存狀態", + "stream-save-state-failed": "The state could not be saved.", "stream-save-unconfirmed": "模擬器未確認儲存。最近的進度可能會遺失。", - "stream-slot-n": "插槽 {n}", "stream-stop": "停止", "stream-subtitle": "串流", + "stream-swap-disc": "更換光碟", "stream-try-again": "重試", "stream-unknown-game": "未知遊戲", "stream-unmute": "取消靜音", - "stream-volume": "音量" + "stream-volume": "音量", + "streaming-description": "遊戲在專用的 {label} 容器中執行,並直接串流到您的瀏覽器。", + "swap-disc-confirm": "更換", + "swap-disc-failed": "更換光碟失敗。主機可能仍在更換過程中,請再試一次。", + "swap-disc-text": "選擇要載入的光碟。遊戲會繼續執行,請先在遊戲內儲存進度。", + "swap-disc-title": "更換光碟", + "upload-memory-card": "上傳記憶卡" } diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index 507578c21d..e028388470 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -59,6 +59,9 @@ "completion": "完成度", "completionist": "完美主義者", "confirm-delete-note": "確定要刪除筆記\"{title}\"嗎?", + "confirm-join-body": "你將以額外玩家身分加入《{name}》。工作階段與其存檔仍由主機控制。", + "confirm-join-title": "加入此工作階段?", + "confirm-join-title-of": "加入 {user} 的工作階段?", "confirm-launch-protected-body": "您已將「{name}」標記為{status}。仍要玩嗎?", "confirm-launch-protected-title": "啟動這個遊戲嗎?", "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", @@ -150,6 +153,8 @@ "hidden": "隱藏", "how-long-to-beat": "遊戲時長", "info": "資訊", + "join-session": "加入工作階段", + "join-session-of": "加入 {user} 的工作階段", "languages": "語言", "last-played": "最後遊玩", "launchbox-cloud": "雲端", @@ -426,6 +431,8 @@ "status-never-playing": "不玩", "status-now-playing": "正在遊玩", "status-retired": "已擱置", + "stream": "串流", + "stream-on": "在 {container} 上串流", "summary": "概要", "switch-version": "切换版本", "tab-achievements": "成就", diff --git a/frontend/src/locales/zh_TW/settings.json b/frontend/src/locales/zh_TW/settings.json index 028f453775..40d8957c0b 100644 --- a/frontend/src/locales/zh_TW/settings.json +++ b/frontend/src/locales/zh_TW/settings.json @@ -437,6 +437,25 @@ "sort-size": "大小", "states": "即時存檔", "stopped": "已停止", + "streaming": "Streaming", + "streaming-by": "({user})", + "streaming-desktop-session": "Desktop session", + "streaming-disabled": "Emulator streaming is disabled on this server.", + "streaming-idle": "Idle", + "streaming-load-failed": "Could not load the streaming containers.", + "streaming-none": "No streaming containers are configured.", + "streaming-open-desktop": "Open desktop", + "streaming-refresh": "Refresh", + "streaming-release": "End session", + "streaming-release-body": "This stops whatever is running on {container} and disconnects the player holding it.", + "streaming-release-confirm": "End session", + "streaming-release-failed": "Could not end the session.", + "streaming-release-reason": "Ended by an administrator", + "streaming-release-title": "End this session?", + "streaming-released": "Session ended.", + "streaming-since": "since {time}", + "streaming-unknown-game": "Unknown game", + "streaming-unusable": "This container's host has no scheme, so it can never be claimed.", "summary": "摘要", "task-failed": "任務失敗", "task-history": "任務歷史", diff --git a/frontend/src/plugins/router.ts b/frontend/src/plugins/router.ts index 1fe6facee8..54ddad460c 100644 --- a/frontend/src/plugins/router.ts +++ b/frontend/src/plugins/router.ts @@ -33,6 +33,7 @@ export const ROUTES = { EMULATORJS: "emulatorjs", RUFFLE: "ruffle", STREAM: "stream", + STREAM_DESKTOP: "stream-desktop", SCAN: "scan", UPLOAD: "upload", ACTIVITY: "activity", @@ -278,6 +279,16 @@ const routes = [ v2: v2For(ROUTES.STREAM), }, }, + { + // No :rom, unlike the player route: a desktop session runs no game. + // The container is a query param because its key is a URL. + path: "stream/desktop", + name: ROUTES.STREAM_DESKTOP, + components: { + default: () => import("@/views/Home.vue"), + v2: v2For(ROUTES.STREAM_DESKTOP), + }, + }, // Settings group — every settings route shares the same v2 // sub-layout (sidebar + content panel). Library Tools (Scan / // Upload / Patcher) live here too so they share the settings diff --git a/frontend/src/services/api/memory-card.ts b/frontend/src/services/api/memory-card.ts new file mode 100644 index 0000000000..28814fce62 --- /dev/null +++ b/frontend/src/services/api/memory-card.ts @@ -0,0 +1,92 @@ +import type { + MemoryCardCreatePayload, + MemoryCardSchema, + MemoryCardVersionSchema, + UserMemoryCardSchema, +} from "@/__generated__"; +import api from "@/services/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`); +} + +// The card as it stands now, which is its newest version and also what the +// next claim hydrates onto a container. 404s when it has never been synced. +async function downloadMemoryCard({ id }: { id: number }) { + return api.get(`/memory-cards/${id}/content`, { + responseType: "blob", + }); +} + +// Store a card image the user supplied as the card's newest version. Only the +// zip layout the broker exchanges is accepted. +async function uploadMemoryCardVersion({ + id, + file, +}: { + id: number; + file: File; +}) { + const formData = new FormData(); + formData.append("cardFile", file); + + return api.post( + `/memory-cards/${id}/versions`, + formData, + { headers: { "Content-Type": "multipart/form-data" } }, + ); +} + +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, + downloadMemoryCard, + uploadMemoryCardVersion, + createMemoryCard, + renameMemoryCard, + setMemoryCardVisibility, + deleteMemoryCards, +}; diff --git a/frontend/src/services/api/streaming.ts b/frontend/src/services/api/streaming.ts index 147a53a44e..438d552545 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 ───────────────────────────────────────────────────────────────────── @@ -5,14 +6,20 @@ import api from "@/services/api"; export interface PlatformCapabilities { max_slots: number; // manual save slots, selectable as 1..max_slots has_autosave: boolean; // whether a dedicated autosave slot can be loaded - autosave_slot: number; // that slot's index (loadable, not savable), 0 if none + autosave_slot: number; // that slot's index, where exit saves land, 0 if none + supports_disc_swap?: boolean; // a live swap route exists for this platform + has_manual_disc_swap?: boolean; // no route, but the emulator's own UI can do it } 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 +33,117 @@ 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; + // A desktop session runs no game, so rom_name is null and the row has to + // say what it is rather than showing an empty cell. + desktop: boolean; + claimed_at: string | null; + user_id: number | null; + username: string | null; +} + +// Row of the admin-only GET /streaming/containers list, one per container +// rather than per platform: a container serves many platforms but hosts one +// session, so the fleet view counts containers. +export interface AdminStreamingContainer { + container: string; // the key release and desktop calls name + label: string | null; + host: string | null; + platforms: string[]; + supports_desktop: boolean; + // False when the configured host carries no scheme, so no broker URL can be + // derived and the container can never be claimed. + configured: boolean; + session: Omit | null; +} + +export interface DesktopSession { + container: string; + platform: string; + host: string; + label: string; + claimed_at: string; +} + +/** Why a session the caller used to hold is gone. Present only when an admin + * ended it; an expired or self-released session carries no notice. */ +export interface SessionTermination { + ended_by: string | null; + reason: string | null; + ended_at: string | null; + platform: string | null; + rom_id: number | null; + rom_name: string | null; +} + +export interface SessionStatus { + status: "active" | "ended"; + platform: string; + termination?: SessionTermination | null; +} + +/** Body of the 428 a claim returns when the container still holds a memory + * card nobody has decided about. Hand-written: FastAPI serves it as a bare + * `detail` dict, so it never reaches the OpenAPI schema. */ +export interface MemoryCardImportDetail { + code: "memory_card_import_required"; + outcome: "found" | "unreadable"; + /** Why the card could not be read. Present on "unreadable" only. */ + reason?: string; + /** What the card holds. Present on "found" only. */ + summary?: { + file_count: number; + total_bytes: number; + game_codes: string[]; + }; +} + +/** The answer to that prompt, replayed on the retried claim. "discard" erases + * the card currently on the container. */ +export type MemoryCardImport = "adopt" | "discard"; + +/** Entry of GET /streaming/sessions/joinable. Nullable fields cover a session + * claimed before a config change removed its container. */ +export interface JoinableSession { + container: string; + label: string | null; + platform: string | null; + rom_id: number | null; + rom_name: string | null; + host_username: string | null; +} + +/** Answer to POST /streaming/sessions/{platform}/join. `host` is the room URL + * the joiner's iframe loads; no control route accepts them. */ +export interface JoinedSession { + platform: string; + host: string; + label: string; + rom_id: number | null; + rom_name: string | null; +} + +export function isMemoryCardImportDetail( + value: unknown, +): value is MemoryCardImportDetail { + return ( + typeof value === "object" && + value !== null && + (value as { code?: unknown }).code === "memory_card_import_required" + ); } // ── Requests ────────────────────────────────────────────────────────────────── @@ -36,12 +154,55 @@ 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, + cardImport?: MemoryCardImport, + multiplayer?: boolean, +) { + return api.post("/streaming/sessions", { + rom_id: romId, + ...(stateId !== undefined ? { state_id: stateId } : {}), + ...(memoryCardId !== undefined ? { memory_card_id: memoryCardId } : {}), + ...(cardImport !== undefined ? { card_import: cardImport } : {}), + ...(multiplayer !== undefined ? { multiplayer } : {}), + }); +} + +async function releaseSession( + platform: string, + reason?: string, + container?: string, + save?: boolean, +) { + return api.delete(`/streaming/sessions/${platform}`, { + params: { + // Sent whenever the caller supplied one, empty string included: the + // backend treats the param's presence as "this is an admin force-release". + ...(reason !== undefined ? { reason } : {}), + // Names which container to release, needed when a pool serves the + // platform and the admin is ending a session they do not own. + ...(container !== undefined ? { container } : {}), + // Only the player who deliberately stopped without saving sends this. + // Everything else leaves it off so the backend still autosaves. + ...(save === false ? { save: false } : {}), + }, + }); +} + +async function listJoinableSessions() { + return api.get<{ sessions: JoinableSession[] }>( + "/streaming/sessions/joinable", + ); } -async function releaseSession(platform: string) { - return api.delete(`/streaming/sessions/${platform}`); +async function joinSession(platform: string, container?: string) { + return api.post( + `/streaming/sessions/${platform}/join`, + {}, + { params: container !== undefined ? { container } : {} }, + ); } async function saveAndExit(platform: string, slot = 0, wait = true) { @@ -51,6 +212,14 @@ async function saveAndExit(platform: string, slot = 0, wait = true) { }); } +async function heartbeatSession(platform: string) { + return api.post(`/streaming/sessions/${platform}/heartbeat`); +} + +async function sessionStatus(platform: string) { + return api.get(`/streaming/sessions/${platform}/status`); +} + async function setVolume(platform: string, level: number) { return api.post(`/streaming/sessions/${platform}/volume`, { level: Math.round(level), @@ -68,17 +237,94 @@ async function saveState(platform: string, slot = 1) { return api.post(`/streaming/sessions/${platform}/save-state`, { slot }); } +// The frame the browser grabbed off the stream canvas, held server-side until +// the state save that follows claims it as its thumbnail. +async function putStateFrame(platform: string, frame: Blob) { + return api.post(`/streaming/sessions/${platform}/state-frame`, frame, { + headers: { "Content-Type": "image/png" }, + }); +} + async function loadState(platform: string, slot = 1) { return api.post(`/streaming/sessions/${platform}/load-state`, { slot }); } +async function swapDisc(platform: string, fileId: number) { + return api.post(`/streaming/sessions/${platform}/swap-disc`, { + file_id: fileId, + }); +} + +async function adminListSessions() { + return api.get<{ sessions: AdminStreamingSession[] }>("/streaming/sessions"); +} + +async function adminListContainers() { + return api.get<{ enabled: boolean; containers: AdminStreamingContainer[] }>( + "/streaming/containers", + { headers: { "Cache-Control": "no-cache" } }, + ); +} + +async function claimDesktop(container: string) { + return api.post("/streaming/desktop", { container }); +} + +// ── 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): Promise { + return 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, + container?: string, +): Promise { + // Names which container to release, for the platforms a pool serves. + const query = container ? `?container=${encodeURIComponent(container)}` : ""; + return fetch(`/api/streaming/sessions/${platform}${query}`, { + method: "DELETE", + keepalive: true, + credentials: "same-origin", + headers: keepaliveHeaders(), + }); +} + export default { fetchConfig, claimSession, + listJoinableSessions, + joinSession, releaseSession, saveAndExit, + heartbeatSession, + sessionStatus, setVolume, setMute, saveState, + putStateFrame, loadState, + swapDisc, + adminListSessions, + adminListContainers, + claimDesktop, + saveAndExitKeepalive, + releaseSessionKeepalive, }; diff --git a/frontend/src/stores/streaming.test.ts b/frontend/src/stores/streaming.test.ts new file mode 100644 index 0000000000..adbc9e59e1 --- /dev/null +++ b/frontend/src/stores/streaming.test.ts @@ -0,0 +1,109 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; +import streamingApi, { type JoinableSession } from "@/services/api/streaming"; +import { useStreamingStore } from "@/stores/streaming"; + +vi.mock("@/services/api/streaming", () => ({ + default: { listJoinableSessions: vi.fn() }, +})); + +describe("platformCapabilities disc flags", () => { + beforeEach(() => setActivePinia(createPinia())); + + it("maps the backend disc flags to camelCase", () => { + const store = useStreamingStore(); + store.config = { + enabled: true, + containers: [ + { + platform: "dc", + host: "http://x", + label: "RetroArch", + emulator: "retroarch", + capabilities: { + max_slots: 0, + has_autosave: true, + autosave_slot: 10, + supports_disc_swap: true, + has_manual_disc_swap: false, + }, + }, + ], + }; + expect(store.platformCapabilities("dc").supportsDiscSwap).toBe(true); + expect(store.platformCapabilities("dc").hasManualDiscSwap).toBe(false); + }); + + it("reports no disc swap for an unconfigured platform", () => { + const store = useStreamingStore(); + expect(store.platformCapabilities("dc").supportsDiscSwap).toBe(false); + }); +}); + +describe("joinable sessions", () => { + const listJoinableSessions = + streamingApi.listJoinableSessions as unknown as Mock; + + function sessions(romId: number) { + const session: JoinableSession = { + container: "ps2-1", + label: "PCSX2", + platform: "ps2", + rom_id: romId, + rom_name: "Game", + host_username: "ana", + }; + return { data: { sessions: [session] } }; + } + + beforeEach(() => { + setActivePinia(createPinia()); + listJoinableSessions.mockReset(); + listJoinableSessions.mockResolvedValue(sessions(7)); + }); + + it("collapses concurrent callers into one request", async () => { + // A virtualised gallery mounts many action surfaces at once; one request + // each would be a storm, and the last response to land would win. + const store = useStreamingStore(); + + await Promise.all([ + store.fetchJoinableSessions(), + store.fetchJoinableSessions(), + store.fetchJoinableSessions(), + ]); + + expect(listJoinableSessions).toHaveBeenCalledTimes(1); + expect(store.joinableForRom(7)?.host_username).toBe("ana"); + }); + + it("serves a second caller from the freshness window, and refetches when forced", async () => { + const store = useStreamingStore(); + + await store.fetchJoinableSessions(); + await store.fetchJoinableSessions(); + expect(listJoinableSessions).toHaveBeenCalledTimes(1); + + await store.fetchJoinableSessions(true); + expect(listJoinableSessions).toHaveBeenCalledTimes(2); + }); + + it("drops a session the caller found to be gone", async () => { + const store = useStreamingStore(); + await store.fetchJoinableSessions(); + + store.forgetJoinableSession(7); + + expect(store.joinableForRom(7)).toBeNull(); + }); + + it("keeps the last known list when a refresh fails", async () => { + const store = useStreamingStore(); + await store.fetchJoinableSessions(); + + listJoinableSessions.mockRejectedValueOnce(new Error("offline")); + await store.fetchJoinableSessions(true); + + expect(store.joinableForRom(7)?.host_username).toBe("ana"); + }); +}); diff --git a/frontend/src/stores/streaming.ts b/frontend/src/stores/streaming.ts index bb408b79e7..79f6c9948a 100644 --- a/frontend/src/stores/streaming.ts +++ b/frontend/src/stores/streaming.ts @@ -3,13 +3,24 @@ import { ref, computed } from "vue"; import streamingApi from "@/services/api/streaming"; import type { ActiveSession, + JoinableSession, + JoinedSession, + MemoryCardImport, + SessionStatus, StreamingConfig, StreamingContainer, } from "@/services/api/streaming"; export type { ActiveSession, + AdminStreamingSession, + JoinableSession, + JoinedSession, + MemoryCardImport, + MemoryCardImportDetail, PlatformCapabilities, + SessionStatus, + SessionTermination, StreamingConfig, StreamingContainer, } from "@/services/api/streaming"; @@ -18,6 +29,8 @@ const NO_CAPABILITIES = { maxSlots: 0, hasAutosave: false, autosaveSlot: 0, + supportsDiscSwap: false, + hasManualDiscSwap: false, } as const; // ── Store ───────────────────────────────────────────────────────────────────── @@ -26,6 +39,9 @@ export const useStreamingStore = defineStore("streaming", () => { const config = ref({ enabled: false, containers: [] }); const activeSession = ref(null); const loading = ref(false); + // `loading` is false both before and after the fetch, so consumers that must + // not act on an unresolved config need this instead. + const configLoaded = ref(false); const error = ref(null); const isEnabled = computed(() => config.value.enabled); @@ -56,11 +72,15 @@ export const useStreamingStore = defineStore("streaming", () => { * maxSlots - number of user-accessible save slots (slot selector range) * hasAutosave - whether a dedicated "load autosave" action is available * autosaveSlot - the slot index used for autosave (0 when none) + * supportsDiscSwap - whether the disc can be changed mid-session + * hasManualDiscSwap - whether the emulator's own UI can change it instead */ function platformCapabilities(slug: string | null | undefined): { maxSlots: number; hasAutosave: boolean; autosaveSlot: number; + supportsDiscSwap: boolean; + hasManualDiscSwap: boolean; } { const caps = containerForPlatform(slug)?.capabilities; if (!caps) return { ...NO_CAPABILITIES }; @@ -68,6 +88,8 @@ export const useStreamingStore = defineStore("streaming", () => { maxSlots: caps.max_slots, hasAutosave: caps.has_autosave, autosaveSlot: caps.autosave_slot, + supportsDiscSwap: caps.supports_disc_swap ?? false, + hasManualDiscSwap: caps.has_manual_disc_swap ?? false, }; } @@ -89,6 +111,7 @@ export const useStreamingStore = defineStore("streaming", () => { console.warn("[streaming] Could not fetch config:", err); } finally { loading.value = false; + configLoaded.value = true; } } @@ -96,29 +119,123 @@ 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 + * 428 - the container's pre-existing memory card needs a decision; + * retry with cardImport set to the user's answer * 503 - broker/unreachable */ - async function claimSession(romId: number): Promise { - const { data } = await streamingApi.claimSession(romId); + async function claimSession( + romId: number, + stateId?: number, + memoryCardId?: number, + cardImport?: MemoryCardImport, + multiplayer?: boolean, + ): Promise { + const { data } = await streamingApi.claimSession( + romId, + stateId, + memoryCardId, + cardImport, + multiplayer, + ); activeSession.value = data; return data; } + // Populated on demand by surfaces that offer a Join affordance. Kept in the + // store rather than fetched per component: a virtualised gallery hosts many + // GameActions instances, and one request each would be a request storm. + const joinableSessions = ref([]); + let joinableRequest: Promise | null = null; + let joinableFetchedAt = 0; + // A host can end a session at any time, so the list is only trusted for as + // long as a user takes to scan a page before acting on it. + const JOINABLE_MAX_AGE_MS = 30_000; + + /** + * Refresh the whole-library list of joinable sessions. + * + * Whole-library rather than per-ROM: a gallery card offers Join for its own + * ROM and cannot fetch for itself. Concurrent callers share the one in-flight + * request, which is also what keeps an older response from landing last and + * overwriting a newer one. `force` skips the freshness window, for a surface + * the user is about to act on. + */ + async function fetchJoinableSessions(force = false): Promise { + if (joinableRequest) return joinableRequest; + if (!force && Date.now() - joinableFetchedAt < JOINABLE_MAX_AGE_MS) return; + + joinableRequest = (async () => { + try { + const { data } = await streamingApi.listJoinableSessions(); + joinableSessions.value = data.sessions; + } catch { + // Best effort, and the last known list stays: a failed refresh says + // nothing about which sessions are still up, and wiping it would pull + // the Join affordance off every card the user is looking at. + } finally { + joinableFetchedAt = Date.now(); + joinableRequest = null; + } + })(); + return joinableRequest; + } + + /** + * Drop a session the caller has just found to be gone, so the Join + * affordance disappears instead of waiting out the freshness window. + */ + function forgetJoinableSession(romId: number): void { + joinableSessions.value = joinableSessions.value.filter( + (s) => s.rom_id !== romId, + ); + } + + function joinableForRom( + romId: number | null | undefined, + ): JoinableSession | null { + if (romId == null) return null; + return joinableSessions.value.find((s) => s.rom_id === romId) ?? null; + } + + /** + * Ask to join a session someone else opened to other players. Returns the + * room URL for the joiner's iframe. Unlike claimSession this grants no + * control of the container: every control route stays with the host. + */ + async function joinSession( + platform: string, + container?: string, + ): Promise { + const { data } = await streamingApi.joinSession(platform, container); + return data; + } + /** * Release the active session when the user leaves the player page. * Returns true when the backend acknowledged the release * (the local session record is dropped); false when the call * failed (the session is still held server-side, so the record is kept so * the user can retry instead of being wedged behind their own session). + * save=false is a player leaving deliberately without saving; it stays on + * by default so the tab-close path keeps autosaving. */ - async function releaseSession(platform: string): Promise { + async function releaseSession( + platform: string, + save = true, + ): Promise { if (!platform) return false; try { - await streamingApi.releaseSession(platform); + await streamingApi.releaseSession(platform, undefined, undefined, save); activeSession.value = null; return true; } catch (err) { @@ -131,36 +248,118 @@ 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 backend gave the container back (it does so even when the + * save itself failed), and reports when it could not. + * saved: the broker confirmed the state save. + * released=false means 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; + const released = data.released ?? true; + if (released) activeSession.value = null; + return { released, saved: data.saved ?? false }; } catch (err) { console.warn("[streaming] Could not save-and-exit:", err); - return false; + return { released: false, saved: false }; + } + } + + /** + * Refresh the session's liveness stamp so the backend does not treat it as + * abandoned, and report back whether the session still exists. Called + * periodically while playing. + * + * Returns null when the answer is unknown (network error): the caller must + * not tear the player down on a transient failure, only on a definite + * `ended`. Best-effort, never throws. + */ + async function heartbeatSession( + platform: string, + ): Promise { + if (!platform) return null; + try { + const { data } = await streamingApi.heartbeatSession(platform); + return data; + } catch (err) { + console.warn("[streaming] Could not heartbeat session:", err); + return null; + } + } + + /** + * Ask whether this platform's session is still ours, without refreshing it. + * Used on mount and on tab refocus, where a heartbeat would wrongly extend a + * claim we may no longer hold. Null means unknown, as above. + */ + async function fetchSessionStatus( + platform: string, + ): Promise { + if (!platform) return null; + try { + const { data } = await streamingApi.sessionStatus(platform); + return data; + } catch (err) { + console.warn("[streaming] Could not fetch session status:", err); + return null; } } + /** + * 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; + // The caller is unloading and cannot await, so the rejection is caught on + // the promise itself; try/catch here would only see a synchronous throw. + 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; + streamingApi.releaseSessionKeepalive(platform).catch((err) => { + console.warn("[streaming] Could not release session (keepalive):", err); + }); + } + return { config, activeSession, loading, + configLoaded, error, isEnabled, containerForPlatform, platformCapabilities, fetchConfig, claimSession, + joinableSessions, + fetchJoinableSessions, + forgetJoinableSession, + joinableForRom, + joinSession, releaseSession, saveAndExit, + heartbeatSession, + fetchSessionStatus, + saveAndExitKeepalive, + releaseSessionKeepalive, }; }); diff --git a/frontend/src/v2/components/GameActions/GameActionBtn.vue b/frontend/src/v2/components/GameActions/GameActionBtn.vue index 9560a08be4..f0920a6869 100644 --- a/frontend/src/v2/components/GameActions/GameActionBtn.vue +++ b/frontend/src/v2/components/GameActions/GameActionBtn.vue @@ -28,6 +28,8 @@ // glass → default translucent frosted-glass pill // surface → translucent grey, page-background friendly (Details) // emphasized → white-on-dark (used by Play in card + details) +// brand → solid brand fill, the coloured peer to emphasized +// (used by Stream so it reads as its own destination) // bare → no background or border, just the icon (list rows / // inline strips where the row's own surface frames the // control) @@ -65,6 +67,8 @@ const { t } = useI18n(); export type GameAction = | "play" + | "stream" + | "join" | "download" | "copy-link" | "qr" @@ -80,16 +84,18 @@ interface Props { /** Size ladder shared with RBtn / RChip / RTag. */ size?: "x-small" | "small" | "default" | "large" | "x-large"; /** - * `glass` — dark scrim, designed to read on top of cover art - * (GameCard hover overlay). - * `surface` — translucent grey surface, matches RTag tokens - * (GameDetails header where the buttons sit on the - * page background, not over a cover). - * `emphasized` — primary white-on-dark CTA (Play). - * `bare` — no chrome; just the icon. For list rows where the row's - * own surface already frames the control. + * `glass`: dark scrim, designed to read on top of cover art + * (GameCard hover overlay). + * `surface`: translucent grey surface, matches RTag tokens + * (GameDetails header where the buttons sit on the + * page background, not over a cover). + * `emphasized`: primary white-on-dark CTA (Play). + * `brand`: solid brand fill. Sits beside `emphasized` as an equal + * CTA that goes somewhere else (Stream). + * `bare`: no chrome; just the icon. For list rows where the row's + * own surface already frames the control. */ - variant?: "glass" | "surface" | "emphasized" | "bare"; + variant?: "glass" | "surface" | "emphasized" | "brand" | "bare"; withLabel?: boolean; /** * Status-only: when several status states are active, the button @@ -162,7 +168,29 @@ const preset = computed(() => { icon: "mdi-play", label: t("rom.play"), activeIcon: null, - onClick: actions.play, + onClick: () => actions.play("local"), + active: false, + }; + } + if (props.action === "stream") { + return { + icon: "mdi-play-network", + label: actions.streamLabel.value + ? t("rom.stream-on", { container: actions.streamLabel.value }) + : t("rom.stream"), + activeIcon: null, + onClick: () => actions.play("stream"), + active: false, + }; + } + if (props.action === "join") { + return { + icon: "mdi-account-multiple-plus", + label: actions.joinHostLabel.value + ? t("rom.join-session-of", { user: actions.joinHostLabel.value }) + : t("rom.join-session"), + activeIcon: null, + onClick: () => void actions.joinStream(), active: false, }; } @@ -684,6 +712,24 @@ function onClick(e: MouseEvent) { transform: scale(0.96); } +/* Brand, a solid fill in the product colour. Play and Stream are peers + that lead somewhere different, so the second CTA takes colour rather + than a second white pill. */ +.r-v2-game-btn--brand { + background: var(--r-color-brand-primary) !important; + border-color: var(--r-color-brand-primary) !important; + color: white !important; +} +.r-v2-game-btn--brand:hover { + background: var(--r-color-brand-primary-hover) !important; + border-color: var(--r-color-brand-primary-hover) !important; + transform: translateY(-1px); +} +.r-v2-game-btn--brand:active { + background: var(--r-color-brand-primary-pressed) !important; + transform: scale(0.96); +} + /* Active-state colour swaps per action. */ .r-v2-game-btn--active-favorite { color: var(--r-color-brand-primary) !important; diff --git a/frontend/src/v2/components/GameActions/GameActions.vue b/frontend/src/v2/components/GameActions/GameActions.vue index bdfefaf8a6..2f8c194edd 100644 --- a/frontend/src/v2/components/GameActions/GameActions.vue +++ b/frontend/src/v2/components/GameActions/GameActions.vue @@ -58,13 +58,31 @@ useGridNav(rootEl, {