Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
72 changes: 72 additions & 0 deletions backend/alembic/versions/0080_rom_category_soundtrack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Update rom_file.category column enum to include soundtracks, and add
audio_meta JSON column for tag/duration/cover metadata.

Revision ID: 0080_rom_category_soundtrack
Revises: 0079_add_rom_files_rom_id_index
Create Date: 2026-04-17 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

from utils.database import CustomJSON, is_postgresql

# revision identifiers, used by Alembic.
revision = "0080_rom_category_soundtrack"
down_revision = "0079_add_rom_files_rom_id_index"
branch_labels = None
depends_on = None


def upgrade() -> None:
connection = op.get_bind()

if is_postgresql(connection):
# `ALTER TYPE ... ADD VALUE` must run outside a transaction in PostgreSQL.
# autocommit_block breaks out of alembic's wrapping transaction for
# exactly this operation.
with op.get_context().autocommit_block():
op.execute(
"ALTER TYPE romfilecategory ADD VALUE IF NOT EXISTS 'SOUNDTRACK'"
)

# audio_meta column (added separately so the enum change sticks even
# if the JSON column already exists from a partial prior run).
op.add_column(
"rom_files",
sa.Column("audio_meta", CustomJSON(), nullable=True),
if_not_exists=True,
)
else:
rom_file_category_enum = sa.Enum(
"GAME",
"DLC",
"HACK",
"MANUAL",
"PATCH",
"UPDATE",
"MOD",
"DEMO",
"TRANSLATION",
"PROTOTYPE",
"CHEAT",
"SOUNDTRACK",
name="romfilecategory",
)
with op.batch_alter_table("rom_files", schema=None) as batch_op:
batch_op.alter_column(
"category", type_=rom_file_category_enum, nullable=True
)
batch_op.add_column(
sa.Column("audio_meta", CustomJSON(), nullable=True),
if_not_exists=True,
)


def downgrade() -> None:
# PostgreSQL cannot remove enum values, so on downgrade we only drop the
# audio_meta column and leave SOUNDTRACK in the enum. Any rows that still
# hold the new value keep working; a re-upgrade is a no-op.
with op.batch_alter_table("rom_files", schema=None) as batch_op:
batch_op.drop_column("audio_meta", if_exists=True)
27 changes: 27 additions & 0 deletions backend/endpoints/responses/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,21 @@ def for_user(cls, user_id: int, db_rom: Rom) -> RomUserSchema:
return rom_user_schema_factory()


class RomFileAudioMetaSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

title: str | None = None
artist: str | None = None
album: str | None = None
year: str | None = None
genre: str | None = None
track: str | None = None
disc: str | None = None
duration_seconds: float | None = None
has_embedded_cover: bool = False
cover_path: str | None = None


class RomFileSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

Expand All @@ -164,6 +179,16 @@ class RomFileSchema(BaseModel):
sha1_hash: str | None
ra_hash: str | None
category: RomFileCategory | None
audio_meta: RomFileAudioMetaSchema | None = None


class SoundtrackTrackMetaSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

file_id: int
file_name: str
file_size_bytes: int
audio_meta: RomFileAudioMetaSchema | None = None


class RomMetadataSchema(BaseModel):
Expand Down Expand Up @@ -266,6 +291,8 @@ class RomSchema(BaseModel):
url_cover: str | None

has_manual: bool
has_manual_files: bool
has_soundtrack: bool
path_manual: str | None
url_manual: str | None

Expand Down
2 changes: 2 additions & 0 deletions backend/endpoints/roms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
from .files import router as files_router
from .manual import router as manual_router
from .notes import router as notes_router
from .soundtrack import router as soundtrack_router
from .upload import router as upload_router

router = APIRouter(
Expand All @@ -83,6 +84,7 @@
router.include_router(upload_router)
router.include_router(files_router)
router.include_router(manual_router)
router.include_router(soundtrack_router)
router.include_router(notes_router)


Expand Down
39 changes: 32 additions & 7 deletions backend/endpoints/roms/files.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import mimetypes
import os
from typing import Annotated
from urllib.parse import quote

from anyio import Path
from fastapi import HTTPException
Expand All @@ -16,11 +17,28 @@
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
from models.rom import RomFileCategory
from utils.nginx import FileRedirectResponse
from utils.router import APIRouter

router = APIRouter()

_AUDIO_MIME_OVERRIDES = {
".flac": "audio/flac",
".opus": "audio/ogg",
".m4a": "audio/mp4",
".oga": "audio/ogg",
".ogg": "audio/ogg",
}


def _guess_media_type(file_name: str) -> str:
ext = os.path.splitext(file_name)[1].lower()
if ext in _AUDIO_MIME_OVERRIDES:
return _AUDIO_MIME_OVERRIDES[ext]
guessed, _ = mimetypes.guess_type(file_name)
return guessed or "application/octet-stream"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe these two should be moved to roms/soundtrack.py?



@protected_route(
router.get,
Expand Down Expand Up @@ -70,20 +88,27 @@ async def get_romfile_content(

log.info(f"User {hl(current_username, color=BLUE)} is downloading {hl(file_name)}")

is_audio = file.category == RomFileCategory.SOUNDTRACK
media_type = (
_guess_media_type(file_name) if is_audio else "application/octet-stream"
)
disposition = "inline" if is_audio else "attachment"

# Serve the file directly in development mode for emulatorjs
if DEV_MODE:
rom_path = fs_rom_handler.validate_path(file.full_path)
# Starlette sets Content-Length and honors Range natively — inline
# disposition lets <audio> seek via Range requests.
return FileResponse(
path=rom_path,
filename=file_name,
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}; filename=\"{quote(file_name)}\"",
"Content-Type": "application/octet-stream",
"Content-Length": str(file.file_size_bytes),
},
media_type=media_type,
content_disposition_type=disposition,
)
Comment on lines 89 to 107
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_romfile_content uses the untrusted file_name path parameter to derive media_type/Content-Disposition for soundtrack files. Because the handler doesn’t validate that file_name matches the DB file.file_name, a caller can request the same bytes with an arbitrary extension and force a different Content-Type while the response is inline, which can enable content-sniffing/XSS style issues. Use file.file_name (or enforce file_name == file.file_name and 404 otherwise) when computing media_type/disposition, and use that same canonical name for the FileResponse/headers.

Copilot uses AI. Check for mistakes.

# Otherwise proxy through nginx
# Otherwise proxy through nginx (which parses Range itself via X-Accel-Redirect)
return FileRedirectResponse(
download_path=Path(f"/library/{file.full_path}"),
disposition=disposition,
media_type=media_type,
)
Loading
Loading