Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 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
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/0104_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: 0104_memory_cards
Revises: 0103_roms_facets_provider_ids
Create Date: 2026-07-12 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "0104_memory_cards"
down_revision = "0103_roms_facets_provider_ids"
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/0105_container_adoptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Add streaming_container_adoptions table

Revision ID: 0105_container_adoptions
Revises: 0104_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 = "0105_container_adoptions"
down_revision = "0104_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")
6 changes: 6 additions & 0 deletions backend/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,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
7 changes: 7 additions & 0 deletions backend/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ class StreamingContainer(TypedDict):
host: str
broker_host: str
label: str
library_path: NotRequired[str]
# Namespace for stored states/cards; defaults to label (or platform)
# lowercased when omitted.
emulator: NotRequired[str]
# Opt in to whole memory-card sync (broker /memory-card). When true, the
# legacy per-file /save-file in-game-save path is skipped for this container.
memory_card_sync: NotRequired[bool]


class Config:
Expand Down
Loading