Skip to content

Commit 77970f6

Browse files
committed
Merge branch 'master' into feature/device-flow-authorization
2 parents 812491f + dbc6139 commit 77970f6

265 files changed

Lines changed: 13203 additions & 4829 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pytest.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ jobs:
7171
- name: Initiate MariaDB database
7272
if: matrix.db == 'mariadb'
7373
run: |
74-
mysql --host 127.0.0.1 --port ${{ job.services.mariadb.ports['3306'] }} -uroot -ppasswd -e "GRANT ALL PRIVILEGES ON romm_test.* TO 'romm_test'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;"
74+
# Grant on the `romm_test%` namespace so the test user can create the
75+
# per-worker databases (romm_test_gw0, ...) used under pytest-xdist,
76+
# without granting it global privileges. The backticks are escaped so
77+
# the shell doesn't treat them as command substitution.
78+
mysql --host 127.0.0.1 --port ${{ job.services.mariadb.ports['3306'] }} -uroot -ppasswd -e "GRANT ALL PRIVILEGES ON \`romm\_test%\`.* TO 'romm_test'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;"
7579
7680
- name: Run python tests
7781
env:
@@ -80,9 +84,11 @@ jobs:
8084
ROMM_DB_DRIVER: ${{ matrix.db }}
8185
REDIS_HOST: 127.0.0.1
8286
REDIS_PORT: ${{ job.services.valkey.ports['6379'] }}
87+
HYPOTHESIS_PROFILE: ci
8388
run: |
8489
cd backend
85-
uv run pytest -vv --maxfail=10 --junitxml=pytest-report.xml --cov --cov-report xml:coverage.xml --cov-config=.coveragerc .
90+
# GitHub-hosted Linux runners have 4 vCPUs; run one worker per core.
91+
uv run pytest -n 4 -vv --maxfail=10 --junitxml=pytest-report.xml --cov --cov-report xml:coverage.xml --cov-config=.coveragerc .
8692
8793
- name: Publish test results
8894
uses: EnricoMi/publish-unit-test-result-action/linux@v2.20.0

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ romm_mock
5454
backend/romm_test/resources
5555
backend/romm_test/logs
5656
.pytest_cache
57+
.hypothesis
5758
/romm_test
5859
.coverage
5960
pytest-report.xml

backend/adapters/services/igdb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ class SlugToIGDB(TypedDict):
495495
"category": "Computer",
496496
"family_name": "Atari",
497497
"family_slug": "atari",
498-
"generation": -1,
498+
"generation": 4,
499499
"id": 63,
500500
"name": "Atari ST/STE",
501501
"slug": "atari-st",

backend/alembic/env.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ def include_object(object, name, type_, reflected, compare_to):
4141
]: # Virtual table
4242
return False
4343

44+
# Skip DB-specific search indexes in autogenerate
45+
# to avoid false drop/create operations
46+
if type_ == "index" and name in (
47+
"idx_roms_name_fs_name_fulltext",
48+
"idx_roms_name_trgm",
49+
"idx_roms_fs_name_trgm",
50+
):
51+
return False
52+
4453
return True
4554

4655

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Speed up gallery search and name ordering on the roms table
2+
3+
This migration adds the database structures the gallery relies on to search
4+
and sort large libraries without full table scans:
5+
6+
- Search indexes on roms.name and roms.fs_name, tailored per backend so the
7+
search query stays index-backed: a FULLTEXT index on MySQL/MariaDB, and
8+
pg_trgm GIN indexes on PostgreSQL (installing the pg_trgm extension first).
9+
- A plain idx_roms_name index on roms.name to accelerate name lookups and
10+
range scans.
11+
- A precomputed, indexed name_sort_key column for natural-sort ordering.
12+
Sorting by name previously ran a per-row regexp (strip leading articles,
13+
zero-pad numbers) that no index could cover, forcing a full sort on every
14+
page. The key is now stored on write and backfilled here, so ordering by
15+
name — including deep-offset pages — uses idx_roms_name_sort_key.
16+
17+
downgrade() drops every object created here in reverse order, leaving the
18+
pg_trgm extension in place since other objects may depend on it.
19+
20+
Revision ID: 0084_add_roms_search_index
21+
Revises: 0083_rom_category_soundtrack
22+
Create Date: 2026-06-16 00:00:00.000000
23+
24+
"""
25+
26+
import sqlalchemy as sa
27+
from alembic import op
28+
29+
from models.rom import NAME_SORT_KEY_MAX_LENGTH, compute_name_sort_key
30+
from utils.database import is_mariadb, is_mysql, is_postgresql
31+
32+
# revision identifiers, used by Alembic.
33+
revision = "0084_add_roms_search_index"
34+
down_revision = "0083_rom_category_soundtrack"
35+
branch_labels = None
36+
depends_on = None
37+
38+
FULLTEXT_INDEX_NAME = "idx_roms_name_fs_name_fulltext"
39+
PG_NAME_INDEX = "idx_roms_name_trgm"
40+
PG_FS_NAME_INDEX = "idx_roms_fs_name_trgm"
41+
42+
_BACKFILL_BATCH = 1000
43+
44+
45+
def upgrade() -> None:
46+
bind = op.get_bind()
47+
48+
# 1. DB-specific search indexes on roms.name and roms.fs_name.
49+
if is_mysql(bind) or is_mariadb(bind):
50+
op.execute(
51+
sa.text(
52+
f"CREATE FULLTEXT INDEX {FULLTEXT_INDEX_NAME} "
53+
"ON roms (name, fs_name)"
54+
)
55+
)
56+
elif is_postgresql(bind):
57+
# pg_trgm is a trusted extension since PostgreSQL 13, so a non-superuser
58+
# with CREATE on the database can install it.
59+
op.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
60+
op.execute(
61+
sa.text(
62+
f"CREATE INDEX IF NOT EXISTS {PG_NAME_INDEX} "
63+
"ON roms USING gin (name gin_trgm_ops)"
64+
)
65+
)
66+
op.execute(
67+
sa.text(
68+
f"CREATE INDEX IF NOT EXISTS {PG_FS_NAME_INDEX} "
69+
"ON roms USING gin (fs_name gin_trgm_ops)"
70+
)
71+
)
72+
73+
# 2. Plain index on roms.name.
74+
with op.batch_alter_table("roms", schema=None) as batch_op:
75+
batch_op.create_index(
76+
"idx_roms_name",
77+
["name"],
78+
unique=False,
79+
if_not_exists=True,
80+
)
81+
82+
# 3. Precomputed name_sort_key column for natural-sort ordering.
83+
op.add_column(
84+
"roms",
85+
sa.Column(
86+
"name_sort_key",
87+
sa.String(length=NAME_SORT_KEY_MAX_LENGTH),
88+
nullable=True,
89+
),
90+
if_not_exists=True,
91+
)
92+
93+
roms = sa.table(
94+
"roms",
95+
sa.column("id", sa.Integer),
96+
sa.column("name", sa.String),
97+
sa.column("name_sort_key", sa.String),
98+
)
99+
100+
result = bind.execute(sa.select(roms.c.id, roms.c.name))
101+
update_stmt = (
102+
roms.update()
103+
.where(roms.c.id == sa.bindparam("_id"))
104+
.values(name_sort_key=sa.bindparam("_key"))
105+
)
106+
while True:
107+
batch = result.fetchmany(_BACKFILL_BATCH)
108+
if not batch:
109+
break
110+
bind.execute(
111+
update_stmt,
112+
[{"_id": row.id, "_key": compute_name_sort_key(row.name)} for row in batch],
113+
)
114+
115+
op.create_index(
116+
"idx_roms_name_sort_key", "roms", ["name_sort_key"], if_not_exists=True
117+
)
118+
119+
120+
def downgrade() -> None:
121+
bind = op.get_bind()
122+
123+
# 3. name_sort_key column and its index.
124+
op.drop_index("idx_roms_name_sort_key", table_name="roms", if_exists=True)
125+
op.drop_column("roms", "name_sort_key", if_exists=True)
126+
127+
# 2. Plain index on roms.name.
128+
with op.batch_alter_table("roms", schema=None) as batch_op:
129+
batch_op.drop_index("idx_roms_name", if_exists=True)
130+
131+
# 1. DB-specific search indexes.
132+
if is_mysql(bind) or is_mariadb(bind):
133+
op.execute(sa.text(f"DROP INDEX {FULLTEXT_INDEX_NAME} ON roms"))
134+
elif is_postgresql(bind):
135+
# Leave the pg_trgm extension in place; other objects may depend on it.
136+
op.execute(sa.text(f"DROP INDEX IF EXISTS {PG_FS_NAME_INDEX}"))
137+
op.execute(sa.text(f"DROP INDEX IF EXISTS {PG_NAME_INDEX}"))
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Update rom_file.category column enum to include screenshots.
2+
3+
Revision ID: 0085_rom_category_screenshot
4+
Revises: 0084_add_roms_search_index
5+
Create Date: 2026-06-17 00:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
from utils.database import is_postgresql
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "0085_rom_category_screenshot"
16+
down_revision = "0084_add_roms_search_index"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade() -> None:
22+
connection = op.get_bind()
23+
24+
if is_postgresql(connection):
25+
# `ALTER TYPE ... ADD VALUE` must run outside a transaction in PostgreSQL.
26+
# autocommit_block breaks out of alembic's wrapping transaction for
27+
# exactly this operation.
28+
with op.get_context().autocommit_block():
29+
op.execute(
30+
"ALTER TYPE romfilecategory ADD VALUE IF NOT EXISTS 'SCREENSHOT'"
31+
)
32+
else:
33+
rom_file_category_enum = sa.Enum(
34+
"GAME",
35+
"DLC",
36+
"HACK",
37+
"MANUAL",
38+
"PATCH",
39+
"UPDATE",
40+
"MOD",
41+
"DEMO",
42+
"TRANSLATION",
43+
"PROTOTYPE",
44+
"CHEAT",
45+
"SOUNDTRACK",
46+
"SCREENSHOT",
47+
name="romfilecategory",
48+
)
49+
with op.batch_alter_table("rom_files", schema=None) as batch_op:
50+
batch_op.alter_column(
51+
"category", type_=rom_file_category_enum, nullable=True
52+
)
53+
54+
55+
def downgrade() -> None:
56+
# PostgreSQL cannot remove enum values, so the downgrade is a no-op there.
57+
# On other backends, narrow the enum back to its pre-screenshot set. Any
58+
# rows still holding the new value would block this, but the upload flow
59+
# is additive and a re-upgrade is a no-op.
60+
connection = op.get_bind()
61+
62+
if is_postgresql(connection):
63+
return
64+
65+
rom_file_category_enum = sa.Enum(
66+
"GAME",
67+
"DLC",
68+
"HACK",
69+
"MANUAL",
70+
"PATCH",
71+
"UPDATE",
72+
"MOD",
73+
"DEMO",
74+
"TRANSLATION",
75+
"PROTOTYPE",
76+
"CHEAT",
77+
"SOUNDTRACK",
78+
name="romfilecategory",
79+
)
80+
with op.batch_alter_table("rom_files", schema=None) as batch_op:
81+
batch_op.alter_column("category", type_=rom_file_category_enum, nullable=True)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Add is_gallery + is_public flags to screenshots for per-user community
2+
gallery screenshots (distinct from auto-captured save/state thumbnails).
3+
4+
Revision ID: 0086_screenshot_visibility
5+
Revises: 0085_rom_category_screenshot
6+
Create Date: 2026-06-18 00:00:00.000000
7+
8+
"""
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "0086_screenshot_visibility"
15+
down_revision = "0085_rom_category_screenshot"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade() -> None:
21+
op.add_column(
22+
"screenshots",
23+
sa.Column(
24+
"is_gallery",
25+
sa.Boolean(),
26+
nullable=False,
27+
server_default=sa.text("false"),
28+
),
29+
if_not_exists=True,
30+
)
31+
op.add_column(
32+
"screenshots",
33+
sa.Column(
34+
"is_public",
35+
sa.Boolean(),
36+
nullable=False,
37+
server_default=sa.text("false"),
38+
),
39+
if_not_exists=True,
40+
)
41+
op.create_index(
42+
"idx_screenshots_public", "screenshots", ["is_public"], if_not_exists=True
43+
)
44+
45+
46+
def downgrade() -> None:
47+
op.drop_index("idx_screenshots_public", table_name="screenshots", if_exists=True)
48+
op.drop_column("screenshots", "is_public", if_exists=True)
49+
op.drop_column("screenshots", "is_gallery", if_exists=True)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Add is_public flag to saves and states for per-user community sharing
2+
(mirrors screenshots/notes visibility).
3+
4+
Revision ID: 0087_save_state_visibility
5+
Revises: 0086_screenshot_visibility
6+
Create Date: 2026-06-20 00:00:00.000000
7+
8+
"""
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "0087_save_state_visibility"
15+
down_revision = "0086_screenshot_visibility"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade() -> None:
21+
for table in ("saves", "states"):
22+
op.add_column(
23+
table,
24+
sa.Column(
25+
"is_public",
26+
sa.Boolean(),
27+
nullable=False,
28+
server_default=sa.text("false"),
29+
),
30+
if_not_exists=True,
31+
)
32+
op.create_index(f"idx_{table}_public", table, ["is_public"], if_not_exists=True)
33+
34+
35+
def downgrade() -> None:
36+
for table in ("saves", "states"):
37+
op.drop_index(f"idx_{table}_public", table_name=table, if_exists=True)
38+
op.drop_column(table, "is_public", if_exists=True)

backend/config/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ def _get_env(var: str, fallback: str | None = None) -> str | None:
239239

240240
# FRONTEND
241241
KIOSK_MODE: Final[bool] = safe_str_to_bool(_get_env("KIOSK_MODE"))
242+
DISABLE_LOGS_VIEWER: Final[bool] = safe_str_to_bool(_get_env("DISABLE_LOGS_VIEWER"))
242243

243244
# LOGGING
244245
LOGLEVEL: Final[str] = _get_env("LOGLEVEL", "INFO").upper()

backend/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import os
2+
3+
# When running under pytest-xdist, give each worker its own database so the
4+
# autouse `clear_database` fixture in one worker can't wipe rows another worker
5+
# is mid-test with. This must run before any application module (config /
6+
# database handlers) is imported, so the engine built at import time binds to
7+
# the per-worker name. As the rootdir conftest, this file is imported before
8+
# `tests/conftest.py` (which imports those modules).
9+
#
10+
# The Redis cache needs no equivalent handling: under pytest it is an in-process
11+
# FakeRedis, so each worker process is already isolated.
12+
_xdist_worker = os.environ.get("PYTEST_XDIST_WORKER")
13+
if _xdist_worker:
14+
_base_db_name = os.environ.get("DB_NAME", "romm_test")
15+
os.environ["DB_NAME"] = f"{_base_db_name}_{_xdist_worker}"

0 commit comments

Comments
 (0)