From dfe3004f5d9627ade1e99f91fbb769ce4ac09986 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 18 Mar 2025 09:48:27 -0400 Subject: [PATCH] Direct download for rom files --- backend/endpoints/feeds.py | 40 +++++++++++++++++--- backend/endpoints/rom.py | 77 +++++++++++++++++++++++++++++++++++++- backend/models/rom.py | 18 +++++++++ 3 files changed, 128 insertions(+), 7 deletions(-) diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index 9ceecabf2..ecafc3502 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -17,7 +17,7 @@ from fastapi import Request from handler.auth.constants import Scope from handler.database import db_platform_handler, db_rom_handler from handler.metadata import meta_igdb_handler -from handler.metadata.base_hander import SWITCH_TITLEDB_REGEX +from handler.metadata.base_hander import SWITCH_PRODUCT_ID_REGEX, SWITCH_TITLEDB_REGEX from models.rom import Rom from starlette.datastructures import URLPath from utils.router import APIRouter @@ -141,10 +141,32 @@ async def tinfoil_index_feed( ) -> dict[str, TinfoilFeedTitleDBSchema]: titledb = {} for rom in roms: - match = SWITCH_TITLEDB_REGEX.search(rom.fs_name) - if match: + tdb_match = SWITCH_TITLEDB_REGEX.search(rom.fs_name) + pid_match = SWITCH_PRODUCT_ID_REGEX.search(rom.fs_name) + if tdb_match: _search_term, index_entry = ( - await meta_igdb_handler._switch_titledb_format(match, rom.fs_name) + await meta_igdb_handler._switch_titledb_format( + tdb_match, rom.fs_name + ) + ) + if index_entry: + titledb[str(index_entry["nsuId"])] = TinfoilFeedTitleDBSchema( + id=str(index_entry["nsuId"]), + name=index_entry["name"], + description=index_entry["description"], + size=index_entry["size"], + version=index_entry["version"] or 0, + region=index_entry["region"] or "US", + releaseDate=index_entry["releaseDate"] or 19700101, + rating=index_entry["rating"] or 0, + publisher=index_entry["publisher"] or "", + rank=0, + ) + elif pid_match: + _search_term, index_entry = ( + await meta_igdb_handler._switch_productid_format( + pid_match, rom.fs_name + ) ) if index_entry: titledb[str(index_entry["nsuId"])] = TinfoilFeedTitleDBSchema( @@ -166,11 +188,17 @@ async def tinfoil_index_feed( files=[ TinfoilFeedFileSchema( url=str( - request.url_for("get_rom_content", id=rom.id, file_name=rom.fs_name) + request.url_for( + "get_romfile_content", + id=rom_file.id, + file_name=rom_file.file_name, + ) ), - size=rom.fs_size_bytes, + size=rom_file.file_size_bytes, ) for rom in roms + for rom_file in rom.files + if rom_file.file_extension in ["xci", "nsp", "nsz", "xcz", "nro"] ], directories=[], success="RomM Switch Library", diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 0fcf7dbe2..a4e77a9f7 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -21,6 +21,7 @@ from decorators.auth import protected_route from endpoints.responses import MessageResponse from endpoints.responses.rom import ( DetailedRomSchema, + RomFileSchema, RomSchema, RomUserSchema, SimpleRomSchema, @@ -288,9 +289,11 @@ async def get_rom_content( file_name: Zip file output name Returns: - FileResponse: Returns one file for single file roms + Response: Returns a response with headers Yields: + FileResponse: Returns one file for single file roms + FileRedirectResponse: Redirects to the file download path ZipResponse: Returns a response for nginx to serve a Zip file for multi-part roms """ @@ -785,3 +788,75 @@ async def update_rom_user(request: Request, id: int) -> RomUserSchema: rom_user = db_rom_handler.update_rom_user(db_rom_user.id, cleaned_data) return RomUserSchema.model_validate(rom_user) + + +@protected_route( + router.get, + "files/{id}", + [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], +) +async def get_romfile( + request: Request, + id: int, +) -> RomFileSchema: + file = db_rom_handler.get_rom_file_by_id(id) + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found", + ) + + return RomFileSchema.model_validate(file) + + +@protected_route( + router.get, + "files/{id}/content/{file_name}", + [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], +) +async def get_romfile_content( + request: Request, + id: int, + file_name: str, +): + """Download rom file endpoint + + Args: + request (Request): Fastapi Request object + id (int): Rom internal id + file_id (int): File internal id + + Returns: + FileResponse: Returns the response with headers + """ + + current_username = ( + request.user.username if request.user.is_authenticated else "unknown" + ) + + file = db_rom_handler.get_rom_file_by_id(id) + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found", + ) + + log.info(f"User {current_username} is downloading {file_name}") + + # Serve the file directly in development mode for emulatorjs + if DEV_MODE: + rom_path = f"{LIBRARY_BASE_PATH}/{file.full_path}" + return FileResponse( + path=rom_path, + filename=file_name, + headers={ + "Content-Disposition": f'attachment; filename="{quote(file_name)}"', + "Content-Type": "application/octet-stream", + "Content-Length": str(file.file_size_bytes), + }, + ) + + # Otherwise proxy through nginx + return FileRedirectResponse( + download_path=Path(f"/library/{file.full_path}"), + ) diff --git a/backend/models/rom.py b/backend/models/rom.py index 123a7e315..e07338ef9 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -62,6 +62,24 @@ class RomFile(BaseModel): def full_path(self) -> str: return f"{self.file_path}/{self.file_name}" + @cached_property + def file_name_no_tags(self) -> str: + from handler.filesystem import fs_rom_handler + + return fs_rom_handler.get_file_name_with_no_tags(self.file_name) + + @cached_property + def file_name_no_ext(self) -> str: + from handler.filesystem import fs_rom_handler + + return fs_rom_handler.get_file_name_with_no_extension(self.file_name) + + @cached_property + def file_extension(self) -> str: + from handler.filesystem import fs_rom_handler + + return fs_rom_handler.parse_file_extension(self.file_name) + def file_name_for_download(self, rom: Rom, hidden_folder: bool = False) -> str: # This needs a trailing slash in the path to work! return self.full_path.replace(