diff --git a/backend/alembic/versions/0080_rom_category_soundtrack.py b/backend/alembic/versions/0080_rom_category_soundtrack.py new file mode 100644 index 0000000000..769a6f34ae --- /dev/null +++ b/backend/alembic/versions/0080_rom_category_soundtrack.py @@ -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) diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 1653e771a6..669654a7a4 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -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) @@ -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): @@ -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 diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 12c8241a51..148cfe3edf 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -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( @@ -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) diff --git a/backend/endpoints/roms/files.py b/backend/endpoints/roms/files.py index dedbd38945..41124fdad0 100644 --- a/backend/endpoints/roms/files.py +++ b/backend/endpoints/roms/files.py @@ -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 @@ -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" + @protected_route( router.get, @@ -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