Merge branch 'master' into ui-theme-redesign

This commit is contained in:
Georges-Antoine Assi
2025-01-25 22:30:08 -05:00
57 changed files with 2432 additions and 1712 deletions

View File

@@ -2,7 +2,6 @@
name: Bug report
about: Report a bug, issue or problem
title: "[Bug] Bug title"
labels: bug
assignees: ""
---

View File

@@ -2,6 +2,5 @@
name: Custom issue template
about: Describe this issue template's purpose here.
title: "[Other] Custom issue title"
labels: other
assignees: ""
---

View File

@@ -2,7 +2,6 @@
name: Feature request
about: Suggest an idea for this project
title: "[Feature] Feature title"
labels: feature
assignees: ""
---

View File

@@ -1 +0,0 @@
blank_pull_request_template_enabled: false

16
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
reviewers:
- "gantoine"
target-branch: "master"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
reviewers:
- "adamantike"
target-branch: "master"

View File

@@ -50,7 +50,7 @@ jobs:
- name: Install dependencies
run: |
poetry install --sync
poetry sync --extras test
- name: Initiate database
run: |

View File

@@ -45,14 +45,14 @@ Then create the virtual environment
```sh
# Fix disable parallel installation stuck: $> poetry config experimental.new-installer false
# Fix Loading macOS/linux stuck: $> export PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring
poetry install --sync
poetry sync
```
If you are on Arch Linux or another Arch-based distro, you need to run the command as follows:
```sh
# https://bbs.archlinux.org/viewtopic.php?id=296542
CFLAGS="-Wno-error=incompatible-pointer-types" poetry install --sync
CFLAGS="-Wno-error=incompatible-pointer-types" poetry sync
```
#### - Spin up mariadb in docker

View File

@@ -9,7 +9,7 @@ Create Date: 2023-09-12 18:18:27.158732
import sqlalchemy as sa
from alembic import op
from sqlalchemy.exc import OperationalError
from utils.database import CustomJSON
from utils.database import CustomJSON, is_postgresql
# revision identifiers, used by Alembic.
revision = "0009_models_refactor"
@@ -21,6 +21,10 @@ depends_on = None
def upgrade() -> None:
connection = op.get_bind()
json_array_build_func = (
"jsonb_build_array()" if is_postgresql(connection) else "JSON_ARRAY()"
)
try:
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.alter_column(
@@ -87,13 +91,13 @@ def upgrade() -> None:
"url_screenshots",
existing_type=CustomJSON(),
nullable=True,
existing_server_default=sa.text("(JSON_ARRAY())"),
existing_server_default=sa.text(f"({json_array_build_func})"),
)
batch_op.alter_column(
"path_screenshots",
existing_type=CustomJSON(),
nullable=True,
existing_server_default=sa.text("(JSON_ARRAY())"),
existing_server_default=sa.text(f"({json_array_build_func})"),
)
try:
@@ -108,6 +112,10 @@ def upgrade() -> None:
def downgrade() -> None:
connection = op.get_bind()
json_array_build_func = (
"jsonb_build_array()" if is_postgresql(connection) else "JSON_ARRAY()"
)
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.alter_column(
"igdb_id",
@@ -136,13 +144,13 @@ def downgrade() -> None:
"path_screenshots",
existing_type=CustomJSON(),
nullable=False,
existing_server_default=sa.text("(JSON_ARRAY())"),
existing_server_default=sa.text(f"({json_array_build_func})"),
)
batch_op.alter_column(
"url_screenshots",
existing_type=CustomJSON(),
nullable=False,
existing_server_default=sa.text("(JSON_ARRAY())"),
existing_server_default=sa.text(f"({json_array_build_func})"),
)
batch_op.alter_column(
"file_size_units", existing_type=sa.VARCHAR(length=10), nullable=True

View File

@@ -18,19 +18,22 @@ depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
connection = op.get_bind()
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.add_column(sa.Column("regions", CustomJSON(), nullable=True))
batch_op.add_column(sa.Column("languages", CustomJSON(), nullable=True))
with op.batch_alter_table("roms", schema=None) as batch_op:
# Set default values for languages and regions
batch_op.execute("UPDATE roms SET languages = JSON_ARRAY()")
batch_op.execute("UPDATE roms SET regions = JSON_ARRAY(region)")
if is_postgresql(connection):
batch_op.execute("UPDATE roms SET languages = jsonb_build_array()")
batch_op.execute("UPDATE roms SET regions = jsonb_build_array(region)")
else:
batch_op.execute("UPDATE roms SET languages = JSON_ARRAY()")
batch_op.execute("UPDATE roms SET regions = JSON_ARRAY(region)")
batch_op.drop_column("region")
# ### end Alembic commands ###
def downgrade() -> None:
connection = op.get_bind()

View File

@@ -191,7 +191,10 @@ def upgrade() -> None:
# Move data around
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.execute("update roms set igdb_metadata = JSON_OBJECT()")
if is_postgresql(connection):
batch_op.execute("update roms set igdb_metadata = jsonb_build_object()")
else:
batch_op.execute("update roms set igdb_metadata = JSON_OBJECT()")
batch_op.execute(
"update roms set path_cover_s = '', path_cover_l = '', url_cover = '' where url_cover = 'https://images.igdb.com/igdb/image/upload/t_cover_big/nocover.png'"
)

View File

@@ -8,7 +8,7 @@ Create Date: 2024-02-13 17:57:25.936825
import sqlalchemy as sa
from alembic import op
from utils.database import CustomJSON
from utils.database import CustomJSON, is_postgresql
# revision identifiers, used by Alembic.
revision = "0015_mobygames_data"
@@ -18,7 +18,8 @@ depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
connection = op.get_bind()
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.add_column(sa.Column("moby_id", sa.Integer(), nullable=True))
@@ -27,9 +28,10 @@ def upgrade() -> None:
batch_op.add_column(sa.Column("moby_metadata", CustomJSON(), nullable=True))
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.execute("update roms set moby_metadata = JSON_OBJECT()")
# ### end Alembic commands ###
if is_postgresql(connection):
batch_op.execute("update roms set moby_metadata = jsonb_build_object()")
else:
batch_op.execute("update roms set moby_metadata = JSON_OBJECT()")
def downgrade() -> None:

View File

@@ -0,0 +1,25 @@
"""Change empty string in users.email to NULL.
Revision ID: 951473b0c581
Revises: 0029_platforms_custom_name
Create Date: 2025-01-14 01:30:39.696257
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "0030_user_email_null"
down_revision = "0029_platforms_custom_name"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.execute("UPDATE users SET email = NULL WHERE email = ''")
def downgrade() -> None:
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.execute("UPDATE users SET email = '' WHERE email IS NULL")

View File

@@ -0,0 +1,351 @@
"""empty message
Revision ID: 0031_datetime_to_timestamp
Revises: 0030_user_email_null
Create Date: 2025-01-14 04:13:33.209508
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = "0031_datetime_to_timestamp"
down_revision = "0030_user_email_null"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("collections", schema=None) as batch_op:
batch_op.alter_column(
"created_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"updated_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("firmware", schema=None) as batch_op:
batch_op.alter_column(
"created_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"updated_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.alter_column(
"created_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"updated_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("rom_user", schema=None) as batch_op:
batch_op.alter_column(
"last_played",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=True,
)
batch_op.alter_column(
"created_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"updated_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.alter_column(
"created_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"updated_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("saves", schema=None) as batch_op:
batch_op.alter_column(
"created_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"updated_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("screenshots", schema=None) as batch_op:
batch_op.alter_column(
"created_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"updated_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("states", schema=None) as batch_op:
batch_op.alter_column(
"created_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"updated_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.alter_column(
"last_login",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=True,
)
batch_op.alter_column(
"last_active",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=True,
)
batch_op.alter_column(
"created_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"updated_at",
existing_type=mysql.DATETIME(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"created_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"last_active",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=True,
)
batch_op.alter_column(
"last_login",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=True,
)
with op.batch_alter_table("states", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"created_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("screenshots", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"created_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("saves", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"created_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"created_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("rom_user", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"created_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"last_played",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=True,
)
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"created_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("firmware", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"created_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
with op.batch_alter_table("collections", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
batch_op.alter_column(
"created_at",
existing_type=sa.TIMESTAMP(timezone=True),
type_=mysql.DATETIME(),
existing_nullable=False,
existing_server_default=sa.text("now()"),
)
# ### end Alembic commands ###

View File

@@ -0,0 +1,66 @@
"""empty message
Revision ID: 0032_longer_fs_fields
Revises: 0031_datetime_to_timestamp
Create Date: 2025-01-24 02:18:30.069263
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = "0032_longer_fs_fields"
down_revision = "0031_datetime_to_timestamp"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.alter_column(
"slug",
existing_type=mysql.VARCHAR(length=50),
type_=sa.String(length=100),
existing_nullable=False,
)
batch_op.alter_column(
"fs_slug",
existing_type=mysql.VARCHAR(length=50),
type_=sa.String(length=100),
existing_nullable=False,
)
batch_op.alter_column(
"category",
existing_type=mysql.VARCHAR(length=50),
type_=sa.String(length=100),
existing_nullable=True,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.alter_column(
"category",
existing_type=sa.String(length=100),
type_=mysql.VARCHAR(length=50),
existing_nullable=True,
)
batch_op.alter_column(
"fs_slug",
existing_type=sa.String(length=100),
type_=mysql.VARCHAR(length=50),
existing_nullable=False,
)
batch_op.alter_column(
"slug",
existing_type=sa.String(length=100),
type_=mysql.VARCHAR(length=50),
existing_nullable=False,
)
# ### end Alembic commands ###

View File

@@ -8,7 +8,7 @@ Create Date: 2023-04-17 12:03:19.163501
import sqlalchemy as sa
from alembic import op
from utils.database import CustomJSON
from utils.database import CustomJSON, is_postgresql
# revision identifiers, used by Alembic.
revision = "1.8.1"
@@ -18,14 +18,19 @@ depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
connection = op.get_bind()
json_array_build_func = (
"jsonb_build_array()" if is_postgresql(connection) else "JSON_ARRAY()"
)
with op.batch_alter_table("roms") as batch_op:
batch_op.add_column(
sa.Column(
"url_screenshots",
CustomJSON(),
nullable=False,
server_default=sa.text("(JSON_ARRAY())"),
server_default=sa.text(f"({json_array_build_func})"),
)
)
batch_op.add_column(
@@ -33,10 +38,9 @@ def upgrade() -> None:
"path_screenshots",
CustomJSON(),
nullable=False,
server_default=sa.text("(JSON_ARRAY())"),
server_default=sa.text(f"({json_array_build_func})"),
)
)
# ### end Alembic commands ###
def downgrade() -> None:

View File

@@ -117,7 +117,7 @@ DISABLE_RUFFLE_RS = str_to_bool(os.environ.get("DISABLE_RUFFLE_RS", "false"))
UPLOAD_TIMEOUT = int(os.environ.get("UPLOAD_TIMEOUT", 600))
# LOGGING
LOGLEVEL: Final = os.environ.get("LOGLEVEL", "INFO")
LOGLEVEL: Final = os.environ.get("LOGLEVEL", "INFO").upper()
FORCE_COLOR: Final = str_to_bool(os.environ.get("FORCE_COLOR", "false"))
NO_COLOR: Final = str_to_bool(os.environ.get("NO_COLOR", "false"))

View File

@@ -283,6 +283,18 @@ async def get_rom_content(
log.info(f"User {current_username} is downloading {rom.file_name}")
if not rom.multi:
# Serve the file directly in development mode for emulatorjs
if DEV_MODE:
return FileResponse(
path=rom_path,
filename=rom.file_name,
headers={
"Content-Disposition": f'attachment; filename="{quote(rom.file_name)}"',
"Content-Type": "application/octet-stream",
"Content-Length": str(rom.file_size_bytes),
},
)
return FileRedirectResponse(
download_path=Path(f"/library/{rom.full_path}"),
filename=rom.file_name,
@@ -377,8 +389,8 @@ async def update_rom(
)
cleaned_data = {
"igdb_id": data.get("igdb_id", None),
"moby_id": data.get("moby_id", None),
"igdb_id": data.get("igdb_id", rom.igdb_id),
"moby_id": data.get("moby_id", rom.moby_id),
}
if (
@@ -558,7 +570,7 @@ async def delete_roms(
@protected_route(router.put, "/roms/{id}/props", [Scope.ROMS_USER_WRITE])
async def update_rom_user(request: Request, id: int) -> RomUserSchema:
data = await request.json()
data = data.get("data", {})
rom_user_data = data.get("data", {})
rom = db_rom_handler.get_rom(id)
@@ -580,10 +592,13 @@ async def update_rom_user(request: Request, id: int) -> RomUserSchema:
"difficulty",
"completion",
"status",
"last_played",
]
cleaned_data = {field: data[field] for field in fields_to_update if field in data}
cleaned_data = {
field: rom_user_data[field]
for field in fields_to_update
if field in rom_user_data
}
if data.get("update_last_played", False):
cleaned_data.update({"last_played": datetime.now(timezone.utc)})

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Annotated
from typing import Annotated, Any
from anyio import open_file
from config import ASSETS_BASE_PATH
@@ -51,8 +51,7 @@ def add_user(
detail="Forbidden",
)
existing_user_by_username = db_user_handler.get_user_by_username(username.lower())
if existing_user_by_username:
if db_user_handler.get_user_by_username(username.lower()):
msg = f"Username {username.lower()} already exists"
log.error(msg)
raise HTTPException(
@@ -60,9 +59,8 @@ def add_user(
detail=msg,
)
existing_user_by_email = db_user_handler.get_user_by_email(email.lower())
if existing_user_by_email:
msg = f"Uesr with email {email.lower()} already exists"
if email and db_user_handler.get_user_by_email(email.lower()):
msg = f"User with email {email.lower()} already exists"
log.error(msg)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -72,7 +70,7 @@ def add_user(
user = User(
username=username.lower(),
hashed_password=auth_handler.get_password_hash(password),
email=email.lower(),
email=email.lower() or None,
role=Role[role.upper()],
)
@@ -154,7 +152,7 @@ async def update_user(
if db_user.id != request.user.id and request.user.role != Role.ADMIN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
cleaned_data = {}
cleaned_data: dict[str, Any] = {}
if form_data.username and form_data.username != db_user.username:
existing_user = db_user_handler.get_user_by_username(form_data.username.lower())
@@ -173,9 +171,10 @@ async def update_user(
form_data.password
)
if form_data.email and form_data.email != db_user.email:
existing_user = db_user_handler.get_user_by_email(form_data.email.lower())
if existing_user:
if form_data.email is not None and form_data.email != db_user.email:
if form_data.email and db_user_handler.get_user_by_email(
form_data.email.lower()
):
msg = f"User with email {form_data.email} already exists"
log.error(msg)
raise HTTPException(
@@ -183,7 +182,7 @@ async def update_user(
detail=msg,
)
cleaned_data["email"] = form_data.email.lower()
cleaned_data["email"] = form_data.email.lower() or None
# You can't change your own role
if form_data.role and request.user.id != id:

View File

@@ -5,17 +5,26 @@ from joserfc import jwt
from joserfc.errors import BadSignatureError
from joserfc.jwk import OctKey
from starlette.datastructures import MutableHeaders, Secret
from starlette.requests import HTTPConnection
from starlette.requests import HTTPConnection, Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from starlette_csrf.middleware import CSRFMiddleware
class CustomCSRFMiddleware(CSRFMiddleware):
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
# Skip CSRF check if not an HTTP request, like websockets
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope, receive)
# Skip CSRF check if Authorization header is present
auth_scheme = request.headers.get("Authorization", "").split(" ", 1)[0].lower()
if auth_scheme == "bearer" or auth_scheme == "basic":
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)

View File

@@ -276,18 +276,19 @@ class DBRomsHandler(DBBaseHandler):
rom_user = self.get_rom_user_by_id(id)
if data.get("is_main_sibling", False):
rom = self.get_rom(rom_user.rom_id)
if not data.get("is_main_sibling", False):
return rom_user
session.execute(
update(RomUser)
.where(
and_(
RomUser.rom_id.in_(r.id for r in rom.sibling_roms),
RomUser.user_id == rom_user.user_id,
)
rom = self.get_rom(rom_user.rom_id)
session.execute(
update(RomUser)
.where(
and_(
RomUser.rom_id.in_(r.id for r in rom.sibling_roms),
RomUser.user_id == rom_user.user_id,
)
.values(is_main_sibling=False)
)
.values(is_main_sibling=False)
)
return self.get_rom_user_by_id(id)

View File

@@ -46,7 +46,7 @@ NON_HASHABLE_PLATFORMS = frozenset(
"switch",
"wiiu",
"win",
"xbox-360",
"xbox360",
"xboxone",
)
)

View File

@@ -1,5 +1,6 @@
import socketio # type: ignore
from config import REDIS_URL
from utils import json as json_module
class SocketHandler:
@@ -7,6 +8,7 @@ class SocketHandler:
self.socket_server = socketio.AsyncServer(
cors_allowed_origins="*",
async_mode="asgi",
json=json_module,
logger=False,
engineio_logger=False,
client_manager=socketio.AsyncRedisManager(str(REDIS_URL)),

View File

@@ -1,13 +1,13 @@
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy import TIMESTAMP, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class BaseModel(DeclarativeBase):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
TIMESTAMP(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

@@ -21,11 +21,11 @@ class Platform(BaseModel):
igdb_id: Mapped[int | None]
sgdb_id: Mapped[int | None]
moby_id: Mapped[int | None]
slug: Mapped[str] = mapped_column(String(length=50))
fs_slug: Mapped[str] = mapped_column(String(length=50))
slug: Mapped[str] = mapped_column(String(length=100))
fs_slug: Mapped[str] = mapped_column(String(length=100))
name: Mapped[str] = mapped_column(String(length=400))
custom_name: Mapped[str | None] = mapped_column(String(length=400), default="")
category: Mapped[str | None] = mapped_column(String(length=50), default="")
category: Mapped[str | None] = mapped_column(String(length=100), default="")
generation: Mapped[int | None]
family_name: Mapped[str | None] = mapped_column(String(length=1000), default="")
family_slug: Mapped[str | None] = mapped_column(String(length=1000), default="")

View File

@@ -8,8 +8,8 @@ from typing import TYPE_CHECKING, Any, TypedDict
from config import FRONTEND_RESOURCES_PATH
from models.base import BaseModel
from sqlalchemy import (
TIMESTAMP,
BigInteger,
DateTime,
Enum,
ForeignKey,
Index,
@@ -72,7 +72,7 @@ class Rom(BaseModel):
Text, default="", doc="URL to cover image stored in IGDB"
)
revision: Mapped[str | None] = mapped_column(String(100))
revision: Mapped[str | None] = mapped_column(String(length=100))
regions: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[])
languages: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[])
tags: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[])
@@ -84,9 +84,9 @@ class Rom(BaseModel):
multi: Mapped[bool] = mapped_column(default=False)
files: Mapped[list[RomFile] | None] = mapped_column(CustomJSON(), default=[])
crc_hash: Mapped[str | None] = mapped_column(String(100))
md5_hash: Mapped[str | None] = mapped_column(String(100))
sha1_hash: Mapped[str | None] = mapped_column(String(100))
crc_hash: Mapped[str | None] = mapped_column(String(length=100))
md5_hash: Mapped[str | None] = mapped_column(String(length=100))
sha1_hash: Mapped[str | None] = mapped_column(String(length=100))
platform_id: Mapped[int] = mapped_column(
ForeignKey("platforms.id", ondelete="CASCADE")
@@ -254,7 +254,7 @@ class RomUser(BaseModel):
note_is_public: Mapped[bool] = mapped_column(default=False)
is_main_sibling: Mapped[bool] = mapped_column(default=False)
last_played: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_played: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
backlogged: Mapped[bool] = mapped_column(default=False)
now_playing: Mapped[bool] = mapped_column(default=False)

View File

@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
from handler.auth.constants import DEFAULT_SCOPES, FULL_SCOPES, WRITE_SCOPES, Scope
from models.base import BaseModel
from sqlalchemy import DateTime, Enum, String
from sqlalchemy import TIMESTAMP, Enum, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from starlette.authentication import SimpleUser
@@ -36,8 +36,8 @@ class User(BaseModel, SimpleUser):
enabled: Mapped[bool] = mapped_column(default=True)
role: Mapped[Role] = mapped_column(Enum(Role), default=Role.VIEWER)
avatar_path: Mapped[str] = mapped_column(String(length=255), default="")
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_active: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_login: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
last_active: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
saves: Mapped[list[Save]] = relationship(back_populates="user")
states: Mapped[list[State]] = relationship(back_populates="user")

View File

@@ -31,7 +31,7 @@ def json_array_contains_value(
return func.json_contains(column, value)
def safe_float(value, default=0.0):
def safe_float(value: Any, default: float = 0.0) -> float:
"""Safely convert a value to float, returning default if conversion fails."""
try:
return float(value)
@@ -39,7 +39,7 @@ def safe_float(value, default=0.0):
return default
def safe_int(value, default=0):
def safe_int(value: Any, default: int = 0) -> int:
"""Safely convert a value to int, returning default if conversion fails."""
try:
return int(value)

29
backend/utils/json.py Normal file
View File

@@ -0,0 +1,29 @@
"""JSON-compatible module with sane defaults.
Inspiration taken from `python-engineio`.
https://github.com/miguelgrinberg/python-engineio/blob/main/src/engineio/json.py
"""
import datetime
import decimal
import json
import uuid
from json import * # noqa: F401, F403
from json import dumps as __original_dumps
from typing import Any
class DefaultJSONEncoder(json.JSONEncoder):
"""Custom JSON encoder that supports encoding additional types."""
def default(self, o: Any) -> Any:
if isinstance(o, (datetime.date, datetime.datetime, datetime.time)):
return o.isoformat()
if isinstance(o, (decimal.Decimal, uuid.UUID)):
return str(o)
return super().default(o)
def dumps(*args: Any, **kwargs: Any) -> str: # type: ignore[no-redef]
kwargs.setdefault("cls", DefaultJSONEncoder)
return __original_dumps(*args, **kwargs)

View File

@@ -1,10 +1,10 @@
import sentry_sdk
from config import SENTRY_DSN
from handler.redis_handler import redis_client
from rq import Connection, Queue, Worker
from rq import Queue, Worker
from utils import get_version
listen = ["high", "default", "low"]
listen = ("high", "default", "low")
sentry_sdk.init(
dsn=SENTRY_DSN,
@@ -14,6 +14,5 @@ sentry_sdk.init(
if __name__ == "__main__":
# Start the worker
with Connection(redis_client):
worker = Worker(map(Queue, listen))
worker.work()
worker = Worker([Queue(name, connection=redis_client) for name in listen])
worker.work()

View File

@@ -55,7 +55,7 @@ RUN poetry install --no-ansi --no-cache --only main
FROM backend-build AS backend-dev-build
RUN poetry install --no-ansi --no-cache
RUN poetry install --no-ansi --no-cache --all-extras
# TODO: Upgrade Alpine to the same version as the other stages, when RAHasher is updated to work

View File

@@ -1,17 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
__generated__
*.config.js

View File

@@ -1,16 +1,30 @@
/* eslint-disable */
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import globals from "globals";
import vue from "eslint-plugin-vue";
let eslint = require("@eslint/js");
let tseslint = require("typescript-eslint");
let globals = require("globals");
let vue = require("eslint-plugin-vue");
module.exports = tseslint.config(
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
...vue.configs["flat/recommended"],
{
ignores: ["node_modules", "dist", "__generated__", "*.config.js"],
ignores: [
"logs",
"*.log",
"npm-debug.log*",
"yarn-debug.log*",
"yarn-error.log*",
"pnpm-debug.log*",
"lerna-debug.log*",
"node_modules",
".DS_Store",
"dist",
"dist-ssr",
"coverage",
"*.local",
"__generated__",
"*.config.js",
],
languageOptions: {
parserOptions: {
parser: "@typescript-eslint/parser",

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@
"manager",
"emulation"
],
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "npm run typecheck && vite build",
@@ -27,7 +28,7 @@
"generate": "openapi --input http://127.0.0.1:5000/openapi.json --output ./src/__generated__ --client axios --useOptions --useUnionTypes --exportServices false --exportSchemas false --exportCore false"
},
"dependencies": {
"@mdi/font": "7.0.96",
"@mdi/font": "7.4.47",
"axios": "^1.7.4",
"core-js": "^3.37.1",
"cronstrue": "^2.50.0",
@@ -46,7 +47,7 @@
"vue": "^3.4.27",
"vue-i18n": "^10.0.5",
"vue-router": "^4.3.2",
"vuetify": "^3.7.4",
"vuetify": "^3.7.7",
"webfontloader": "^1.6.28"
},
"devDependencies": {
@@ -59,19 +60,19 @@
"@types/qrcode": "^1.5.5",
"@types/semver": "^7.5.8",
"@types/webfontloader": "^1.6.38",
"@vitejs/plugin-vue": "^3.2.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.5.1",
"esbuild": "^0.20.2",
"esbuild": "^0.24.2",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.26.0",
"globals": "^15.3.0",
"openapi-typescript-codegen": "^0.25.0",
"openapi-typescript-codegen": "^0.29.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5",
"typescript": "^5.7.3",
"typescript-eslint": "^7.11.0",
"vite": "^3.2.11",
"vite-plugin-pwa": "^0.14.7",
"vite-plugin-vuetify": "^1.0.2",
"vite": "^6.0.11",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-vuetify": "^2.0.4",
"vue-tsc": "^2.1.10"
},
"engines": {

View File

@@ -4,6 +4,7 @@ import CopyRomDownloadLinkDialog from "@/components/common/Game/Dialog/CopyDownl
import romApi from "@/services/api/rom";
import storeDownload from "@/stores/download";
import storeHeartbeat from "@/stores/heartbeat";
import storeConfig from "@/stores/config";
import type { DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import {
@@ -14,6 +15,7 @@ import {
} from "@/utils";
import type { Emitter } from "mitt";
import { computed, inject, ref } from "vue";
import { storeToRefs } from "pinia";
// Props
const props = defineProps<{ rom: DetailedRom }>();
@@ -22,13 +24,23 @@ const heartbeatStore = storeHeartbeat();
const emitter = inject<Emitter<Events>>("emitter");
const playInfoIcon = ref("mdi-play");
const qrCodeIcon = ref("mdi-qrcode");
const configStore = storeConfig();
const { config } = storeToRefs(configStore);
const ejsEmulationSupported = computed(() =>
isEJSEmulationSupported(props.rom.platform_slug, heartbeatStore.value),
);
const ruffleEmulationSupported = computed(() =>
isRuffleEmulationSupported(props.rom.platform_slug, heartbeatStore.value),
const platformSlug = computed(() =>
props.rom.platform_slug in config.value.PLATFORMS_VERSIONS
? config.value.PLATFORMS_VERSIONS[props.rom.platform_slug]
: props.rom.platform_slug,
);
const ejsEmulationSupported = computed(() => {
return isEJSEmulationSupported(platformSlug.value, heartbeatStore.value);
});
const ruffleEmulationSupported = computed(() => {
return isRuffleEmulationSupported(platformSlug.value, heartbeatStore.value);
});
const is3DSRom = computed(() => {
return is3DSCIARom(props.rom);
});

View File

@@ -6,13 +6,15 @@ import type { DetailedRom } from "@/stores/roms";
import { storeToRefs } from "pinia";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import { useDisplay, useTheme } from "vuetify";
import { useI18n } from "vue-i18n";
import { MdPreview } from "md-editor-v3";
// Props
const { t } = useI18n();
const props = defineProps<{ rom: DetailedRom }>();
const { xs } = useDisplay();
const theme = useTheme();
const show = ref(false);
const carousel = ref(0);
const router = useRouter();
@@ -119,7 +121,13 @@ function onFilterClick(filter: FilterType, value: string) {
<v-divider class="mx-2 my-4" />
<v-row no-gutters>
<v-col class="text-caption">
<span>{{ rom.summary }}</span>
<MdPreview
:model-value="rom.summary ?? ''"
:theme="theme.name.value == 'dark' ? 'dark' : 'light'"
preview-theme="vuepress"
code-theme="github"
:readonly="true"
/>
</v-col>
</v-row>
</template>

View File

@@ -3,7 +3,7 @@ import romApi from "@/services/api/rom";
import storeAuth from "@/stores/auth";
import type { DetailedRom } from "@/stores/roms";
import type { RomUserStatus } from "@/__generated__";
import { difficultyEmojis, getTextForStatus, getEmojiForStatus } from "@/utils";
import { getTextForStatus, getEmojiForStatus } from "@/utils";
import { MdEditor, MdPreview } from "md-editor-v3";
import "md-editor-v3/lib/style.css";
import { ref, watch } from "vue";
@@ -125,39 +125,37 @@ watch(
romUser.rating =
typeof $event === 'number' ? $event : parseInt($event)
"
active-color="primary"
active-color="yellow"
/>
</v-col>
</v-row>
<v-row class="d-flex align-center mt-4" no-gutters>
<v-col cols="auto">
<v-col cols="12" md="2">
<v-label>{{ t("rom.difficulty") }}</v-label>
</v-col>
<v-col>
<v-slider
:class="{ 'ml-4': mdAndUp }"
<v-col cols="12" md="10">
<v-rating
:class="{ 'ml-2': mdAndUp }"
hover
ripple
length="10"
size="26"
full-icon="mdi-chili-mild"
empty-icon="mdi-chili-mild-outline"
v-model="romUser.difficulty"
min="1"
max="10"
step="1"
hide-details
track-fill-color="primary"
><template #append>
<v-label class="opacity-100">
{{
difficultyEmojis[Math.floor(romUser.difficulty) - 1] ??
difficultyEmojis[3]
}}
</v-label>
</template></v-slider
>
@update:model-value="
romUser.difficulty =
typeof $event === 'number' ? $event : parseInt($event)
"
active-color="red"
/>
</v-col>
</v-row>
<v-row class="d-flex align-center mt-4" no-gutters>
<v-col cols="auto">
<v-col cols="12" md="2">
<v-label>{{ t("rom.completion") }} %</v-label>
</v-col>
<v-col>
<v-col cols="12" md="10">
<v-slider
:class="{ 'ml-4': mdAndUp }"
v-model="romUser.completion"

View File

@@ -5,15 +5,17 @@ import storeGalleryFilter from "@/stores/galleryFilter";
import storePlatforms from "@/stores/platforms";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { inject } from "vue";
import { inject, onMounted, watch } from "vue";
import { storeToRefs } from "pinia";
import { useDisplay } from "vuetify";
import { useRouter } from "vue-router";
import type { Platform } from "@/stores/platforms";
import { useI18n } from "vue-i18n";
// Props
const { xs } = useDisplay();
const { t } = useI18n();
const router = useRouter();
const romsStore = storeRoms();
const { gettingRoms } = storeToRefs(romsStore);
const emitter = inject<Emitter<Events>>("emitter");
@@ -61,47 +63,68 @@ function setFilters() {
]);
}
function fetchRoms() {
async function fetchRoms() {
if (searchText.value) {
// Auto hide android keyboard
const inputElement = document.getElementById("search-text-field");
inputElement?.blur();
gettingRoms.value = true;
romApi
.getRoms({ searchTerm: searchText.value })
.then(({ data }) => {
data = data.sort((a, b) => {
return a.platform_name.localeCompare(b.platform_name);
});
romsStore.set(data);
romsStore.setFiltered(data, galleryFilterStore);
})
.catch((error) => {
emitter?.emit("snackbarShow", {
msg: `Couldn't fetch roms: ${error}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
console.error(`Couldn't fetch roms: ${error}`);
})
.finally(() => {
gettingRoms.value = false;
// Update URL with search term
router.replace({ query: { search: searchText.value } });
try {
const { data } = await romApi.getRoms({ searchTerm: searchText.value });
const sortedData = data.sort((a, b) => {
return a.platform_name.localeCompare(b.platform_name);
});
galleryFilterStore.setFilterPlatforms([
...new Map(
romsStore.filteredRoms.map((rom) => {
const platform = allPlatforms.value.find(
(p) => p.id === rom.platform_id,
);
return [rom.platform_name, platform];
}),
).values(),
] as Platform[]);
setFilters();
galleryFilterStore.activeFilterDrawer = false;
romsStore.set(sortedData);
romsStore.setFiltered(sortedData, galleryFilterStore);
} catch (error) {
emitter?.emit("snackbarShow", {
msg: `Couldn't fetch roms: ${error}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
console.error(`Couldn't fetch roms: ${error}`);
} finally {
gettingRoms.value = false;
galleryFilterStore.setFilterPlatforms([
...new Map(
romsStore.filteredRoms.map((rom) => {
const platform = allPlatforms.value.find(
(p) => p.id === rom.platform_id,
);
return [rom.platform_name, platform];
}),
).values(),
] as Platform[]);
setFilters();
galleryFilterStore.activeFilterDrawer = false;
}
}
}
onMounted(() => {
const { search: searchTerm } = router.currentRoute.value.query;
if (searchTerm && searchTerm !== searchText.value) {
searchText.value = searchTerm as string;
fetchRoms();
}
});
watch(
router.currentRoute.value.query,
(query) => {
if (query.search && query.search !== searchText.value) {
searchText.value = query.search as string;
fetchRoms();
}
},
{ deep: true },
);
</script>
<template>

View File

@@ -4,22 +4,45 @@ import type { Events } from "@/types/emitter";
import { debounce } from "lodash";
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject, nextTick } from "vue";
import { inject, nextTick, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
// Props
const { t } = useI18n();
const emitter = inject<Emitter<Events>>("emitter");
const router = useRouter();
const galleryFilterStore = storeGalleryFilter();
const { filterText } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
const filterRoms = debounce(() => {
// Update URL with search term
router.replace({ query: { search: filterText.value } });
emitter?.emit("filter", null);
}, 500);
function clear() {
filterText.value = "";
}
onMounted(() => {
const { search: searchTerm } = router.currentRoute.value.query;
if (searchTerm && searchTerm !== filterText.value) {
filterText.value = searchTerm as string;
filterRoms();
}
});
watch(
router.currentRoute.value.query,
(query) => {
if (query.search && query.search !== filterText.value) {
filterText.value = query.search as string;
filterRoms();
}
},
{ deep: true },
);
</script>
<template>

View File

@@ -64,7 +64,7 @@ async function switchFromFavourites() {
}
await collectionApi
.updateCollection({ collection: favCollection.value as Collection })
.then(({ data }) => {
.then(() => {
emitter?.emit("snackbarShow", {
msg: `${props.rom.name} ${
collectionsStore.isFav(props.rom) ? "added to" : "removed from"
@@ -101,6 +101,8 @@ async function resetLastPlayed() {
color: "green",
timeout: 2000,
});
romsStore.removeFromContinuePlaying(props.rom);
})
.catch((error) => {
console.log(error);

View File

@@ -3,6 +3,7 @@ import AdminMenu from "@/components/common/Game/AdminMenu.vue";
import romApi from "@/services/api/rom";
import storeDownload from "@/stores/download";
import storeHeartbeat from "@/stores/heartbeat";
import storeConfig from "@/stores/config";
import type { SimpleRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import {
@@ -12,22 +13,28 @@ import {
} from "@/utils";
import type { Emitter } from "mitt";
import { computed, inject } from "vue";
import { storeToRefs } from "pinia";
// Props
const props = defineProps<{ rom: SimpleRom }>();
const downloadStore = storeDownload();
const heartbeatStore = storeHeartbeat();
const emitter = inject<Emitter<Events>>("emitter");
const configStore = storeConfig();
const { config } = storeToRefs(configStore);
const platformSlug = computed(() => {
return props.rom.platform_slug in config.value.PLATFORMS_VERSIONS
? config.value.PLATFORMS_VERSIONS[props.rom.platform_slug]
: props.rom.platform_slug;
});
const ejsEmulationSupported = computed(() => {
return isEJSEmulationSupported(props.rom.platform_slug, heartbeatStore.value);
return isEJSEmulationSupported(platformSlug.value, heartbeatStore.value);
});
const ruffleEmulationSupported = computed(() => {
return isRuffleEmulationSupported(
props.rom.platform_slug,
heartbeatStore.value,
);
return isRuffleEmulationSupported(platformSlug.value, heartbeatStore.value);
});
const is3DSRom = computed(() => {

View File

@@ -1,214 +0,0 @@
<script setup lang="ts">
import GameCard from "@/components/common/Game/Card/Base.vue";
import PlatformIcon from "@/components/common/Platform/Icon.vue";
import RDialog from "@/components/common/RDialog.vue";
import romApi from "@/services/api/rom";
import type { SimpleRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { inject, onBeforeUnmount, ref } from "vue";
import { useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import { useI18n } from "vue-i18n";
// Define types
type Platform = {
platform_name: string;
platform_slug: string;
};
type SelectItem = {
raw: Platform;
};
// Props
const { t } = useI18n();
const { lgAndUp } = useDisplay();
const show = ref(false);
const searching = ref(false);
const searched = ref(false);
const router = useRouter();
const searchedRoms = ref<Platform[]>([]);
const filteredRoms = ref<SimpleRom[]>([]);
const platforms = ref<Platform[]>([]);
const selectedPlatform = ref<Platform | null>(null);
const searchValue = ref("");
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showSearchRomDialog", () => {
show.value = true;
});
async function filterRoms() {
if (!selectedPlatform.value) {
filteredRoms.value = searchedRoms.value as SimpleRom[];
} else {
filteredRoms.value = searchedRoms.value.filter(
(rom: { platform_name: string }) =>
rom.platform_name == selectedPlatform.value?.platform_name,
) as SimpleRom[];
}
}
function clearFilter() {
selectedPlatform.value = null;
filterRoms();
}
async function searchRoms() {
if (searchValue.value != "") {
// Auto hide android keyboard
const inputElement = document.getElementById("search-text-field");
inputElement?.blur();
searching.value = true;
searched.value = true;
searchedRoms.value = (
await romApi.getRoms({ searchTerm: searchValue.value })
).data.sort((a, b) => {
return a.platform_name.localeCompare(b.platform_name);
});
platforms.value = [
...new Map(
searchedRoms.value.map((rom): [string, Platform] => [
rom.platform_name,
{
platform_name: rom.platform_name,
platform_slug: rom.platform_slug,
},
]),
).values(),
];
filterRoms();
searching.value = false;
}
}
function onGameClick(emitData: { rom: SimpleRom; event: MouseEvent }) {
router.push({ name: "rom", params: { rom: emitData.rom.id } });
closeDialog();
}
function closeDialog() {
show.value = false;
searched.value = false;
}
onBeforeUnmount(() => {
emitter?.off("showSearchRomDialog");
});
</script>
<template>
<r-dialog
v-model="show"
icon="mdi-magnify"
:loading-condition="searching"
:empty-state-condition="searchedRoms?.length == 0 && searched"
empty-state-type="game"
scroll-content
:width="lgAndUp ? '60vw' : '95vw'"
:height="lgAndUp ? '90vh' : '775px'"
>
<template #toolbar>
<v-row class="align-center" no-gutters>
<v-col cols="5" md="6" lg="7">
<v-text-field
autofocus
id="search-text-field"
@keyup.enter="searchRoms"
@click:clear="searchRoms"
v-model="searchValue"
:disabled="searching"
:label="t('common.search')"
hide-details
class="bg-toplayer"
/>
</v-col>
<v-col cols="5" lg="4">
<v-select
@click:clear="clearFilter"
:label="t('common.platform')"
class="bg-toplayer"
item-title="platform_name"
:disabled="platforms.length == 0 || searching"
hide-details
clearable
single-line
return-object
v-model="selectedPlatform"
@update:model-value="filterRoms"
:items="platforms"
>
<template #item="{ props, item }">
<v-list-item
class="py-2"
v-bind="props"
:title="(item as SelectItem).raw.platform_name ?? ''"
>
<template #prepend>
<platform-icon
:size="35"
:key="(item as SelectItem).raw.platform_slug"
:slug="(item as SelectItem).raw.platform_slug"
:name="(item as SelectItem).raw.platform_name"
/>
</template>
</v-list-item>
</template>
<template #selection="{ item }">
<v-list-item
class="px-0"
:title="(item as SelectItem).raw.platform_name ?? ''"
>
<template #prepend>
<platform-icon
:size="35"
:key="(item as SelectItem).raw.platform_slug"
:slug="(item as SelectItem).raw.platform_slug"
:name="(item as SelectItem).raw.platform_name"
/>
</template>
</v-list-item>
</template>
</v-select>
</v-col>
<v-col>
<v-btn
type="submit"
@click="searchRoms"
class="bg-toplayer"
variant="text"
icon="mdi-magnify"
block
:disabled="searching"
/>
</v-col>
</v-row>
</template>
<template #content>
<v-row no-gutters class="align-content-start align-center">
<v-col
class="pa-1 align-self-end"
cols="4"
sm="3"
md="2"
v-show="!searching"
v-for="rom in filteredRoms"
>
<game-card
:key="rom.updated_at"
:rom="rom"
@click="onGameClick({ rom, event: $event })"
titleOnHover
pointerOnHover
withLink
showFlags
showFav
transformScale
showActionBar
showPlatformIcon
/>
</v-col>
</v-row>
</template>
</r-dialog>
</template>

View File

@@ -5,6 +5,7 @@ import RAvatarRom from "@/components/common/Game/RAvatar.vue";
import romApi from "@/services/api/rom";
import storeDownload from "@/stores/download";
import storeRoms, { type SimpleRom } from "@/stores/roms";
import storeConfig from "@/stores/config";
import storeHeartbeat from "@/stores/heartbeat";
import type { Events } from "@/types/emitter";
import {
@@ -34,6 +35,8 @@ const downloadStore = storeDownload();
const romsStore = storeRoms();
const { filteredRoms, selectedRoms } = storeToRefs(romsStore);
const heartbeatStore = storeHeartbeat();
const configStore = storeConfig();
const { config } = storeToRefs(configStore);
const page = ref(parseInt(window.location.hash.slice(1)) || 1);
const storedRomsPerPage = parseInt(localStorage.getItem("romsPerPage") ?? "");
const itemsPerPage = ref(isNaN(storedRomsPerPage) ? 25 : storedRomsPerPage);
@@ -106,12 +109,20 @@ function updateUrlHash() {
window.location.hash = String(page.value);
}
function getTruePlatformSlug(platformSlug: string) {
return platformSlug in config.value.PLATFORMS_VERSIONS
? config.value.PLATFORMS_VERSIONS[platformSlug]
: platformSlug;
}
function checkIfEJSEmulationSupported(platformSlug: string) {
return isEJSEmulationSupported(platformSlug, heartbeatStore.value);
const slug = getTruePlatformSlug(platformSlug);
return isEJSEmulationSupported(slug, heartbeatStore.value);
}
function checkIfRuffleEmulationSupported(platformSlug: string) {
return isRuffleEmulationSupported(platformSlug, heartbeatStore.value);
const slug = getTruePlatformSlug(platformSlug);
return isRuffleEmulationSupported(slug, heartbeatStore.value);
}
function updateSelectAll() {
@@ -251,13 +262,13 @@ onMounted(() => {
>
{{ languageToEmoji(language) }}
</span>
<spa class="reglang-super">
<span class="reglang-super">
{{
item.languages.length > 3
? `&nbsp;+${item.languages.length - 3}`
: ""
}}
</spa>
</span>
</div>
<span v-else>-</span>
</template>

View File

@@ -1,3 +1,4 @@
<
<script setup lang="ts">
import HomeBtn from "@/components/common/Navigation/HomeBtn.vue";
import PlatformsBtn from "@/components/common/Navigation/PlatformsBtn.vue";
@@ -6,7 +7,6 @@ import ScanBtn from "@/components/common/Navigation/ScanBtn.vue";
import SearchBtn from "@/components/common/Navigation/SearchBtn.vue";
import UploadBtn from "@/components/common/Navigation/UploadBtn.vue";
import UserBtn from "@/components/common/Navigation/UserBtn.vue";
import SearchRomDialog from "@/components/common/Game/Dialog/SearchRom.vue";
import PlatformsDrawer from "@/components/common/Navigation/PlatformsDrawer.vue";
import CollectionsDrawer from "@/components/common/Navigation/CollectionsDrawer.vue";
import UploadRomDialog from "@/components/common/Game/Dialog/UploadRom.vue";
@@ -78,9 +78,9 @@ const { activePlatformsDrawer, activeCollectionsDrawer, activeSettingsDrawer } =
</template>
</v-navigation-drawer>
<search-rom-dialog />
<platforms-drawer />
<collections-drawer />
<upload-rom-dialog />
<settings-drawer />
</template>
>

View File

@@ -37,7 +37,7 @@
"tags": "Tags",
"genres": "Genres",
"franchises": "Franchises",
"collections": "Collecciones",
"collections": "Collections",
"companies": "Companies",
"age-rating": "Age rating",
"no-saves-found": "No saves found",

View File

@@ -37,7 +37,7 @@
"tags": "Tags",
"genres": "Genres",
"franchises": "Franchises",
"collections": "Collecciones",
"collections": "Collections",
"companies": "Companies",
"age-rating": "Age rating",
"no-saves-found": "No saves found",

View File

@@ -188,7 +188,7 @@ async function updateUserRomProps({
romId: number;
data: Partial<RomUserSchema>;
updateLastPlayed?: boolean;
}): Promise<{ data: DetailedRom }> {
}): Promise<{ data: RomUserSchema }> {
return api.put(`/roms/${romId}/props`, {
data: data,
update_last_played: updateLastPlayed,

View File

@@ -99,9 +99,17 @@ export default defineStore("roms", {
addToRecent(rom: SimpleRom) {
this.recentRoms = [rom, ...this.recentRoms];
},
removeFromRecent(rom: SimpleRom) {
this.recentRoms = this.recentRoms.filter((value) => value.id !== rom.id);
},
addToContinuePlaying(rom: SimpleRom) {
this.continuePlayingRoms = [rom, ...this.continuePlayingRoms];
},
removeFromContinuePlaying(rom: SimpleRom) {
this.continuePlayingRoms = this.continuePlayingRoms.filter(
(value) => value.id !== rom.id,
);
},
update(rom: SimpleRom) {
this.allRoms = this.allRoms.map((value) =>
value.id === rom.id ? rom : value,

View File

@@ -21,7 +21,6 @@ export type Events = {
showMatchRomDialog: SimpleRom;
showSearchCoverDialog: { term: string; aspectRatio: number | null };
updateUrlCover: string;
showSearchRomDialog: null;
showEditRomDialog: SimpleRom;
showCopyDownloadLinkDialog: string;
showDeleteRomDialog: SimpleRom[];

View File

@@ -360,10 +360,10 @@ const _EJS_CORES_MAP = {
"game-boy-micro": ["mgba"],
gbc: ["gambatte", "mgba"],
"pc-fx": ["mednafen_pcfx"],
ps: ["pcsx_rearmed", "mednafen_psx"],
ps: ["pcsx_rearmed", "mednafen_psx_hw"],
psp: ["ppsspp"],
segacd: ["genesis_plus_gx", "picodrive"],
// sega32: ["picodrive"], // Broken: https://github.com/EmulatorJS/EmulatorJS/issues/579
sega32: ["picodrive"],
gamegear: ["genesis_plus_gx"],
sms: ["genesis_plus_gx"],
"sega-mark-iii": ["genesis_plus_gx"],
@@ -453,6 +453,41 @@ export function isEJSThreadsSupported(): boolean {
return typeof SharedArrayBuffer !== "undefined";
}
// This is a workaround to set the control scheme for Sega systems using the same cores
const _EJS_CONTROL_SCHEMES = {
segacd: "segaCD",
sega32: "sega32x",
gamegear: "segaGG",
sms: "segaMS",
"sega-mark-iii": "segaMS",
"sega-master-system-ii": "segaMS",
"master-system-super-compact": "segaMS",
"master-system-girl": "segaMS",
"genesis-slash-megadrive": "segaMD",
"sega-mega-drive-2-slash-genesis": "segaMD",
"sega-mega-jet": "segaMD",
"mega-pc": "segaMD",
"tera-drive": "segaMD",
"sega-nomad": "segaMD",
saturn: "segaSaturn",
};
type EJSControlSlug = keyof typeof _EJS_CONTROL_SCHEMES;
/**
* Get the control scheme for a given platform.
*
* @param platformSlug The platform slug.
* @returns The control scheme.
*/
export function getControlSchemeForPlatform(
platformSlug: string,
): string | null {
return platformSlug in _EJS_CONTROL_SCHEMES
? _EJS_CONTROL_SCHEMES[platformSlug as EJSControlSlug]
: null;
}
/**
* Check if Ruffle emulation is supported for a given platform.
*
@@ -472,22 +507,6 @@ export function isRuffleEmulationSupported(
type PlayingStatus = RomUserStatus | "backlogged" | "now_playing" | "hidden";
/**
* Array of difficulty emojis.
*/
export const difficultyEmojis = [
"😴",
"🥱",
"😐",
"😄",
"🤔",
"🤯",
"😓",
"😡",
"🤬",
"😵",
];
/**
* Map of ROM statuses to their corresponding emoji and text.
*/

View File

@@ -53,34 +53,36 @@ async function fetchRoms() {
scrim: false,
});
await romApi
.getRoms({
try {
const { data } = await romApi.getRoms({
collectionId: romsStore.currentCollection?.id,
searchTerm: normalizeString(galleryFilterStore.filterText),
})
.then(({ data }) => {
romsStore.set(data);
romsStore.setFiltered(data, galleryFilterStore);
})
.catch((error) => {
emitter?.emit("snackbarShow", {
msg: `Couldn't fetch roms for collection ID ${currentCollection.value?.id}: ${error}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
console.error(
`Couldn't fetch roms for collection ID ${currentCollection.value?.id}: ${error}`,
);
noCollectionError.value = true;
})
.finally(() => {
gettingRoms.value = false;
emitter?.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
});
romsStore.set(data);
romsStore.setFiltered(data, galleryFilterStore);
gettingRoms.value = false;
emitter?.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
} catch (error) {
emitter?.emit("snackbarShow", {
msg: `Couldn't fetch roms for collection ID ${currentCollection.value?.id}: ${error}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
console.error(
`Couldn't fetch roms for collection ID ${currentCollection.value?.id}: ${error}`,
);
noCollectionError.value = true;
} finally {
gettingRoms.value = false;
emitter?.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
}
}
function setFilters() {
@@ -216,7 +218,7 @@ onMounted(async () => {
watch(
() => allCollections.value,
(collections) => {
async (collections) => {
if (
collections.length > 0 &&
collections.some((collection) => collection.id === routeCollectionId)
@@ -233,7 +235,7 @@ onMounted(async () => {
) {
romsStore.setCurrentCollection(collection);
resetGallery();
fetchRoms();
await fetchRoms();
setFilters();
}
@@ -256,7 +258,7 @@ onBeforeRouteUpdate(async (to, from) => {
watch(
() => allCollections.value,
(collections) => {
async (collections) => {
if (collections.length > 0) {
const collection = collections.find(
(collection) => collection.id === routeCollectionId,
@@ -269,7 +271,7 @@ onBeforeRouteUpdate(async (to, from) => {
collection
) {
romsStore.setCurrentCollection(collection);
fetchRoms();
await fetchRoms();
setFilters();
}
}

View File

@@ -42,7 +42,7 @@ const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("filter", onFilterChange);
// Functions
function fetchRoms() {
async function fetchRoms() {
if (gettingRoms.value) return;
gettingRoms.value = true;
@@ -51,34 +51,31 @@ function fetchRoms() {
scrim: false,
});
romApi
.getRoms({
try {
const { data } = await romApi.getRoms({
platformId: romsStore.currentPlatform?.id,
searchTerm: normalizeString(galleryFilterStore.filterText),
})
.then(({ data }) => {
romsStore.set(data);
romsStore.setFiltered(data, galleryFilterStore);
})
.catch((error) => {
emitter?.emit("snackbarShow", {
msg: `Couldn't fetch roms for platform ID ${currentPlatform.value?.id}: ${error}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
console.error(
`Couldn't fetch roms for platform ID ${currentPlatform.value?.id}: ${error}`,
);
noPlatformError.value = true;
})
.finally(() => {
gettingRoms.value = false;
emitter?.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
});
romsStore.set(data);
romsStore.setFiltered(data, galleryFilterStore);
} catch (error) {
emitter?.emit("snackbarShow", {
msg: `Couldn't fetch roms for platform ID ${currentPlatform.value?.id}: ${error}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
console.error(
`Couldn't fetch roms for platform ID ${currentPlatform.value?.id}: ${error}`,
);
noPlatformError.value = true;
} finally {
gettingRoms.value = false;
emitter?.emit("showLoadingDialog", {
loading: gettingRoms.value,
scrim: false,
});
}
}
function setFilters() {
@@ -223,7 +220,7 @@ onMounted(async () => {
watch(
() => allPlatforms.value,
(platforms) => {
async (platforms) => {
if (platforms.length > 0) {
if (platforms.some((platform) => platform.id === routePlatformId)) {
const platform = platforms.find(
@@ -238,7 +235,7 @@ onMounted(async () => {
) {
romsStore.setCurrentPlatform(platform);
resetGallery();
fetchRoms();
await fetchRoms();
setFilters();
}
@@ -272,7 +269,7 @@ onBeforeRouteUpdate(async (to, from) => {
watch(
() => allPlatforms.value,
(platforms) => {
async (platforms) => {
if (platforms.length > 0) {
const platform = platforms.find(
(platform) => platform.id === routePlatformId,
@@ -285,7 +282,7 @@ onBeforeRouteUpdate(async (to, from) => {
platform
) {
romsStore.setCurrentPlatform(platform);
fetchRoms();
await fetchRoms();
setFilters();
} else {
noPlatformError.value = true;

View File

@@ -11,7 +11,6 @@ import storeRoms, { type SimpleRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { views } from "@/utils";
import { useI18n } from "vue-i18n";
import { storeToRefs } from "pinia";
import { inject, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
@@ -22,6 +21,8 @@ const { scrolledToTop, currentView } = storeToRefs(galleryViewStore);
const galleryFilterStore = storeGalleryFilter();
const { searchText } = storeToRefs(galleryFilterStore);
const romsStore = storeRoms();
const router = useRouter();
const initialSearch = ref(false);
const {
allRoms,
filteredRoms,
@@ -31,13 +32,12 @@ const {
itemsPerBatch,
gettingRoms,
} = storeToRefs(romsStore);
const itemsShown = ref(itemsPerBatch.value);
let timeout: ReturnType<typeof setTimeout>;
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("filter", onFilterChange);
const { t } = useI18n();
const router = useRouter();
const initialSearch = ref(false);
// Functions
function setFilters() {

View File

@@ -4,7 +4,11 @@ import saveApi, { saveApi as api } from "@/services/api/save";
import screenshotApi from "@/services/api/screenshot";
import stateApi from "@/services/api/state";
import type { DetailedRom } from "@/stores/roms";
import { areThreadsRequiredForEJSCore, getSupportedEJSCores } from "@/utils";
import {
areThreadsRequiredForEJSCore,
getSupportedEJSCores,
getControlSchemeForPlatform,
} from "@/utils";
import { onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps<{
@@ -39,6 +43,7 @@ declare global {
EJS_startOnLoaded: boolean;
EJS_fullscreenOnLoaded: boolean;
EJS_threads: boolean;
EJS_controlScheme: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
EJS_emulator: any;
EJS_onGameStart: () => void;
@@ -52,6 +57,9 @@ declare global {
const supportedCores = getSupportedEJSCores(romRef.value.platform_slug);
window.EJS_core =
supportedCores.find((core) => core === props.core) ?? supportedCores[0];
window.EJS_controlScheme = getControlSchemeForPlatform(
romRef.value.platform_slug,
);
window.EJS_threads = areThreadsRequiredForEJSCore(window.EJS_core);
window.EJS_gameID = romRef.value.id;
window.EJS_gameUrl = `/api/roms/${romRef.value.id}/content/${romRef.value.file_name}`;

623
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,69 +1,79 @@
[tool.poetry]
package-mode = false
[project]
name = "romm"
version = "0.0.1"
description = "A beautiful, powerful, self-hosted rom manager"
license = "GNU AGPLv3"
repository = "https://github.com/rommapp/romm"
authors = ["Zurdi <zurdi@romm.app>", "Arcane <arcane@romm.app>"]
readme = "README.md"
authors = [
{ name = "Zurdi", email = "zurdi@romm.app" },
{ name = "Arcane", email = "arcane@romm.app" },
{ name = "Adamantike", email = "adamantike@romm.app" },
]
requires-python = "^3.12"
dependencies = [
"PyYAML == 6.0.1",
"SQLAlchemy[mariadb-connector,mysql-connector,postgresql-psycopg] ~= 2.0",
"Unidecode == 1.3.8",
"alembic == 1.13.1",
"anyio ~= 4.4",
"authlib ~= 1.3",
"certifi == 2024.07.04",
"colorama ~= 0.4",
"emoji == 2.10.1",
"fastapi == 0.115.6",
"gunicorn == 22.0.0",
"httpx ~= 0.27",
"itsdangerous ~= 2.1",
"joserfc ~= 0.9",
"passlib[bcrypt] ~= 1.7",
"pillow ~= 10.3",
"psycopg[c] ~= 3.2",
"py7zr == 1.0.0rc2",
"pydash ~= 7.0",
"python-dotenv == 1.0.1",
"python-magic ~= 0.4",
"python-multipart ~= 0.0.18",
"python-socketio == 5.11.1",
"redis ~= 5.0",
"rq ~= 2.1",
"rq-scheduler ~= 0.14",
"sentry-sdk ~= 2.19",
"sqlakeyset ~= 2.0",
"starlette-csrf ~= 3.0",
# TODO: Move back to official releases once the following PR is merged and released:
# https://github.com/python-poetry/poetry-core/pull/803
"streaming-form-data @ git+https://github.com/gantoine/streaming-form-data.git@b8a49ba",
"types-colorama ~= 0.4",
"types-passlib ~= 1.7",
"types-pyyaml ~= 6.0",
"types-redis ~= 4.6",
"uvicorn == 0.29.0",
"watchdog ~= 4.0",
"websockets == 12.0",
"yarl ~= 1.14",
"zipfile-deflate64 ~= 0.2",
]
[tool.poetry.dependencies]
python = "^3.12"
anyio = "^4.4"
fastapi = "0.115.6"
uvicorn = "0.29.0"
gunicorn = "22.0.0"
websockets = "12.0"
python-socketio = "5.11.1"
psycopg = { version = "^3.2", extras = ["c"] }
SQLAlchemy = { version = "^2.0.30", extras = [
"mariadb-connector",
"mysql-connector",
"postgresql-psycopg",
] }
alembic = "1.13.1"
PyYAML = "6.0.1"
Unidecode = "1.3.8"
emoji = "2.10.1"
python-dotenv = "1.0.1"
sqlakeyset = "^2.0.1708907391"
pydash = "^7.0.7"
rq = "^1.16.1"
redis = "^5.0"
passlib = { extras = ["bcrypt"], version = "^1.7.4" }
itsdangerous = "^2.1.2"
rq-scheduler = "^0.13.1"
starlette-csrf = "^3.0.0"
httpx = "^0.27.0"
python-multipart = "^0.0.18"
watchdog = "^4.0.0"
yarl = "^1.14"
joserfc = "^0.9.0"
pillow = "^10.3.0"
certifi = "2024.07.04"
authlib = "^1.3.1"
python-magic = "^0.4.27"
py7zr = "1.0.0rc2"
sentry-sdk = "^2.19"
# TODO: Move back to official releases once the following PR is merged and released:
# https://github.com/python-poetry/poetry-core/pull/803
streaming-form-data = { git = "https://github.com/gantoine/streaming-form-data.git", rev = "b8a49ba" }
zipfile-deflate64 = "^0.2.0"
colorama = "^0.4.6"
types-colorama = "^0.4"
types-passlib = "^1.7.7.20240311"
types-pyyaml = "^6.0.12.20240311"
types-redis = "^4.6.0.20240311 "
[project.optional-dependencies]
dev = [
"ipdb ~= 0.13",
"ipykernel ~= 6.29",
"memray ~= 1.15",
"mypy ~= 1.13",
"pyinstrument ~= 5.0",
]
test = [
"fakeredis ~= 2.21",
"pytest ~= 8.3",
"pytest-asyncio ~= 0.23",
"pytest-env ~= 1.1",
"pytest-mock ~= 3.12",
"pytest-recording ~= 0.13",
]
[tool.poetry.group.test.dependencies]
fakeredis = "^2.21.3"
pytest = "^8.3"
pytest-env = "^1.1.3"
pytest-mock = "^3.12.0"
pytest-asyncio = "^0.23.5"
pytest-recording = "^0.13"
[project.urls]
Homepage = "https://romm.app/"
Source = "https://github.com/rommapp/romm"
[tool.poetry.group.dev.dependencies]
ipdb = "^0.13.13"
mypy = "^1.13"
ipykernel = "^6.29.4"
[tool.poetry]
package-mode = false
requires-poetry = ">=2.0"