Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
a277ab2
feat: streaming library sync, memory cards, and session hardening
LoneAngelFayt Jul 20, 2026
815a242
feat: notify and redirect players when an admin force-releases their …
LoneAngelFayt Jul 20, 2026
c7bd8f6
feat: keep every streamed save state as browsable history
LoneAngelFayt Jul 20, 2026
989714c
feat: source state thumbnails from the broker for Dolphin
LoneAngelFayt Jul 20, 2026
c7c2908
fix: reject non-PNG bytes before storing a state thumbnail
LoneAngelFayt Jul 20, 2026
2ff8518
refactor: collapse binary broker I/O into shared helpers
LoneAngelFayt Jul 21, 2026
942d136
Merge branch 'master' into feat/emulator-streaming-additions
LoneAngelFayt Jul 21, 2026
5ec45bc
feat: ask before wiping a container's pre-existing memory card
LoneAngelFayt Jul 21, 2026
b39c1aa
feat: recognize xemu save state slots
LoneAngelFayt Jul 21, 2026
e831623
fix: ignore memory_card_sync on platforms without a memory card
LoneAngelFayt Jul 21, 2026
770f09e
fix: merge forked alembic heads from platform_description and contain…
LoneAngelFayt Jul 21, 2026
54a29cd
fix: correct unparseable-card summary and tighten adoption types
LoneAngelFayt Jul 24, 2026
a831d6c
feat: run streaming session teardown after the release response
LoneAngelFayt Jul 24, 2026
9fbbb4c
fix(migrations): drop merge head, chain memory_cards onto platform_de…
LoneAngelFayt Jul 24, 2026
549edf1
Merge remote-tracking branch 'upstream/master' into feat/emulator-str…
LoneAngelFayt Jul 24, 2026
a2b7091
feat: size streaming state transfers per emulator, raise xemu to 2 GiB
LoneAngelFayt Jul 26, 2026
d8264fc
test: assert an unparsable memory card reports no size
LoneAngelFayt Jul 26, 2026
65b9907
feat(streaming): carry broker stream token in claim host URL
LoneAngelFayt Jul 27, 2026
b957995
Merge remote-tracking branch 'upstream/master' into feat/emulator-str…
LoneAngelFayt Aug 2, 2026
440a367
fix(streaming): release the claim on failed teardown, recover interru…
LoneAngelFayt Aug 2, 2026
aac075e
test: mock canPlayStream in useGameActions tests
LoneAngelFayt Aug 2, 2026
ccc56e6
feat(streaming): support the webstation broker protocol
LoneAngelFayt Aug 4, 2026
49b2646
feat(streaming): let one container serve many platforms
LoneAngelFayt Aug 4, 2026
6669a9f
feat(streaming): claim the first free container from a platform pool
LoneAngelFayt Aug 4, 2026
0415148
feat(streaming): add admin desktop sessions and a streaming fleet tab
LoneAngelFayt Aug 4, 2026
56d6714
fix(streaming): reveal the player bar from the stream's bottom edge, …
LoneAngelFayt Aug 5, 2026
0b8e59c
feat(streaming): accept a proxied path as a container host
LoneAngelFayt Aug 5, 2026
4111223
Merge branch 'rommapp:master' into feat/emulator-streaming-v2
LoneAngelFayt Aug 5, 2026
7d85b12
feat: pull states into the RomM library from webstation containers
LoneAngelFayt Aug 7, 2026
41161f4
fix: widen the taskbar reveal band so it clears the desktop taskbar
LoneAngelFayt Aug 7, 2026
dce6818
fix(streaming): pull the exit state into the library when a session i…
LoneAngelFayt Aug 7, 2026
a7a3c1f
feat: sync whole memory cards on webstation containers
LoneAngelFayt Aug 7, 2026
7eb2eaf
fix(streaming): reveal the control bar across the whole bottom edge w…
LoneAngelFayt Aug 7, 2026
d379eab
feat(memory-cards): download a card as a zip and upload your own
LoneAngelFayt Aug 7, 2026
af755bb
feat: capture state thumbnails from the stream canvas in the browser
LoneAngelFayt Aug 8, 2026
e41901f
feat: move the stream control bar to the top edge
LoneAngelFayt Aug 8, 2026
bdcee1c
feat: drive stream volume and mute in the browser on same-origin cont…
LoneAngelFayt Aug 8, 2026
dad1c43
fix(stream): shrink the control bar reveal band so the emulator windo…
LoneAngelFayt Aug 8, 2026
2510214
feat(stream): report save data instead of an empty state picker where…
LoneAngelFayt Aug 8, 2026
40b6b3a
fix(stream): fall back to emulator capabilities so retroarch platform…
LoneAngelFayt Aug 8, 2026
12b6447
feat(stream): offer play and stream as separate buttons where both ca…
LoneAngelFayt Aug 8, 2026
b973f13
fix(stream): use an mdi glyph that exists for the stream action icon
LoneAngelFayt Aug 8, 2026
ced6a24
feat(stream): give the stream button a solid brand fill so it reads a…
LoneAngelFayt Aug 8, 2026
e3e109e
feat(stream): tab between save states and the save archive when the e…
LoneAngelFayt Aug 8, 2026
4e29aba
fix(tools): import the memory-card helper from its current home
LoneAngelFayt Aug 8, 2026
df2e1ec
feat(streaming): open a session to other players with a multiplayer flag
LoneAngelFayt Aug 9, 2026
4654891
feat(streaming): add the multiplayer toggle and the join flow to the …
LoneAngelFayt Aug 9, 2026
15c7453
fix(streaming): stop without saving instead of always writing a state
LoneAngelFayt Aug 9, 2026
a610374
feat(i18n): add play mode tooltip strings
LoneAngelFayt Aug 10, 2026
721a29b
feat(streaming): resolve a play mode covering browser and streaming
LoneAngelFayt Aug 10, 2026
b264f9f
feat(platforms): add a play mode badge with a split streaming state
LoneAngelFayt Aug 10, 2026
b48e9ee
feat(platforms): show the play mode badge on the tile and list row
LoneAngelFayt Aug 10, 2026
45ff468
feat(platforms): count streamable platforms as playable in sort and g…
LoneAngelFayt Aug 10, 2026
6ff8ef6
feat(streaming): accept per-platform option blocks on a container
LoneAngelFayt Aug 10, 2026
d84d36c
Merge branch 'rommapp:master' into feat/emulator-streaming-v2
LoneAngelFayt Aug 10, 2026
2b35ca3
fix: updating stream config for v2
LoneAngelFayt Aug 10, 2026
80bf8b5
feat(streaming): derive the webstation broker host from the stream host
LoneAngelFayt Aug 14, 2026
4896466
docs(streaming): document one container serving many platforms
LoneAngelFayt Aug 14, 2026
2ed7705
fix(streaming): close PlatformsIndex test file and cover webstation+p…
LoneAngelFayt Aug 14, 2026
41dd9d3
fix: address independent review findings in streaming badge/config plan
LoneAngelFayt Aug 14, 2026
f96e06f
test: always pin DB_NAME to a dedicated test database
LoneAngelFayt Aug 14, 2026
64bbf2c
feat: record the disc a save state was captured on
LoneAngelFayt Aug 15, 2026
910090c
feat: ship disc-swap capability flags to the streaming client
LoneAngelFayt Aug 15, 2026
2374ba1
feat: add a swap-disc route to the streaming api
LoneAngelFayt Aug 15, 2026
f9465d2
test: assert the session persists the swapped disc id
LoneAngelFayt Aug 15, 2026
6fb1a60
feat: stamp the mounted disc onto captured save states
LoneAngelFayt Aug 15, 2026
a95a753
feat: restore the recorded disc when resuming a streamed session
LoneAngelFayt Aug 15, 2026
cb87373
feat: expose disc-swap capability and api to the streaming client
LoneAngelFayt Aug 15, 2026
bb6bc06
feat: add a disc-swap control to the stream control bar
LoneAngelFayt Aug 15, 2026
29f0772
feat: localize the disc-swap strings
LoneAngelFayt Aug 15, 2026
67cfb48
fix: address disc-swap review findings across streaming and player
LoneAngelFayt Aug 15, 2026
546b379
fix: gate the disc control on more than one swappable disc
LoneAngelFayt Aug 15, 2026
90e8d73
fix: address review findings across streaming, memory cards and player
LoneAngelFayt Aug 16, 2026
e3bff9e
fix: address the second review pass across streaming and memory cards
LoneAngelFayt Aug 16, 2026
79fdd08
fix: address the third review pass across streaming and memory cards
LoneAngelFayt Aug 16, 2026
1f5025b
fix: address the fourth review pass across streaming and memory cards
LoneAngelFayt Aug 16, 2026
be826b1
fix: address the fifth review pass across streaming and memory cards
LoneAngelFayt Aug 16, 2026
9571b88
chore: align the streaming work with the repo conventions
LoneAngelFayt Aug 16, 2026
29193a1
fix: address the sixth review pass across streaming and memory cards
LoneAngelFayt Aug 16, 2026
8d465c9
fix: cap how long backend work may hold a container
LoneAngelFayt Aug 16, 2026
9b2ced0
Merge remote-tracking branch 'upstream/master' into feat/emulator-str…
LoneAngelFayt Aug 16, 2026
07a75f9
fix: add save-state capabilities for the native PPSSPP broker
LoneAngelFayt Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions backend/alembic/versions/0108_memory_cards.py
Original file line number Diff line number Diff line change
@@ -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")
53 changes: 53 additions & 0 deletions backend/alembic/versions/0109_container_adoptions.py
Original file line number Diff line number Diff line change
@@ -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")
38 changes: 38 additions & 0 deletions backend/alembic/versions/0110_state_disc_file.py
Original file line number Diff line number Diff line change
@@ -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")
6 changes: 6 additions & 0 deletions backend/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
32 changes: 30 additions & 2 deletions backend/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 10 additions & 9 deletions backend/conftest.py
Original file line number Diff line number Diff line change
@@ -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"
Loading