diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 898a83d09..4b0005420 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -35,7 +35,7 @@ { "label": "Setup testing environment", "type": "shell", - "command": "export $(cat .env | grep DB_ROOT_PASSWD | xargs) && docker exec -i mariadb mariadb -u root -p$DB_ROOT_PASSWD < backend/romm_test/setup.sql", + "command": "export $(cat .env | grep DB_ROOT_PASSWD | xargs) && docker exec -i romm-db-dev mariadb -u root -p$DB_ROOT_PASSWD < backend/romm_test/setup.sql", "problemMatcher": [] }, { diff --git a/backend/alembic/versions/0030_user_email_null.py b/backend/alembic/versions/0030_user_email_null.py index 14d6ba41f..26657c928 100644 --- a/backend/alembic/versions/0030_user_email_null.py +++ b/backend/alembic/versions/0030_user_email_null.py @@ -1,6 +1,6 @@ """Change empty string in users.email to NULL. -Revision ID: 951473b0c581 +Revision ID: 0030_user_email_null Revises: 0029_platforms_custom_name Create Date: 2025-01-14 01:30:39.696257 diff --git a/backend/alembic/versions/0035_screenscraper.py b/backend/alembic/versions/0035_screenscraper.py new file mode 100644 index 000000000..ad6b6d5d6 --- /dev/null +++ b/backend/alembic/versions/0035_screenscraper.py @@ -0,0 +1,43 @@ +"""empty message + +Revision ID: 0035_screenscraper +Revises: 0034_virtual_collections_db_view +Create Date: 2025-01-02 18:58:55.557123 + +""" + +import sqlalchemy as sa +from alembic import op +from utils.database import CustomJSON + +# revision identifiers, used by Alembic. +revision = "0035_screenscraper" +down_revision = "0034_virtual_collections_db_view" +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.add_column(sa.Column("ss_id", sa.Integer(), nullable=True)) + + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.add_column(sa.Column("ss_id", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("ss_metadata", CustomJSON(), nullable=True)) + batch_op.add_column(sa.Column("url_manual", sa.Text(), nullable=True)), + batch_op.add_column(sa.Column("path_manual", sa.Text(), nullable=True)), + batch_op.create_index("idx_roms_ss_id", ["ss_id"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.drop_index("idx_roms_ss_id") + batch_op.drop_column("ss_metadata") + batch_op.drop_column("ss_id") + + with op.batch_alter_table("platforms", schema=None) as batch_op: + batch_op.drop_column("ss_id") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/0036_screenscraper_platforms_id.py b/backend/alembic/versions/0036_screenscraper_platforms_id.py new file mode 100644 index 000000000..e29fd71d3 --- /dev/null +++ b/backend/alembic/versions/0036_screenscraper_platforms_id.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 0036_screenscraper_platforms_id +Revises: 0035_screenscraper +Create Date: 2025-01-02 18:58:55.557123 + +""" + +import sqlalchemy as sa +from alembic import op +from handler.metadata.ss_handler import SLUG_TO_SS_ID + +# revision identifiers, used by Alembic. +revision = "0036_screenscraper_platforms_id" +down_revision = "0035_screenscraper" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + connection = op.get_bind() + for slug, ss_id in SLUG_TO_SS_ID.items(): + connection.execute( + sa.text("UPDATE platforms SET ss_id = :ss_id WHERE slug = :slug"), + {"ss_id": ss_id["id"], "slug": slug}, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 9e8ce4b69..fd747e4a0 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -57,6 +57,10 @@ def str_to_bool(value: str) -> bool: "IGDB_CLIENT_SECRET", os.environ.get("CLIENT_SECRET", "") ) +# SCREENSCRAPER +SCREENSCRAPER_USER: Final = os.environ.get("SCREENSCRAPER_USER", "") +SCREENSCRAPER_PASSWORD: Final = os.environ.get("SCREENSCRAPER_PASSWORD", "") + # STEAMGRIDDB STEAMGRIDDB_API_KEY: Final = os.environ.get("STEAMGRIDDB_API_KEY", "") diff --git a/backend/endpoints/collections.py b/backend/endpoints/collections.py index b3a345916..6fbf9932c 100644 --- a/backend/endpoints/collections.py +++ b/backend/endpoints/collections.py @@ -78,8 +78,8 @@ async def add_collection( ) else: path_cover_s, path_cover_l = await fs_resource_handler.get_cover( - overwrite=True, entity=_added_collection, + overwrite=True, url_cover=_added_collection.url_cover, ) @@ -247,8 +247,8 @@ async def update_collection( {"url_cover": data.get("url_cover", collection.url_cover)} ) path_cover_s, path_cover_l = await fs_resource_handler.get_cover( - overwrite=True, entity=collection, + overwrite=True, url_cover=data.get("url_cover", ""), # type: ignore ) cleaned_data.update( diff --git a/backend/endpoints/heartbeat.py b/backend/endpoints/heartbeat.py index b22cb2796..0903fc335 100644 --- a/backend/endpoints/heartbeat.py +++ b/backend/endpoints/heartbeat.py @@ -18,6 +18,7 @@ from handler.metadata.igdb_handler import IGDB_API_ENABLED from handler.metadata.moby_handler import MOBY_API_ENABLED from handler.metadata.sgdb_handler import STEAMGRIDDB_API_ENABLED +from handler.metadata.ss_handler import SS_API_ENABLED from utils import get_version from utils.router import APIRouter @@ -40,9 +41,12 @@ def heartbeat() -> HeartbeatResponse: "SHOW_SETUP_WIZARD": len(db_user_handler.get_admin_users()) == 0, }, "METADATA_SOURCES": { - "ANY_SOURCE_ENABLED": IGDB_API_ENABLED or MOBY_API_ENABLED, + "ANY_SOURCE_ENABLED": IGDB_API_ENABLED + or MOBY_API_ENABLED + or SS_API_ENABLED, "IGDB_API_ENABLED": IGDB_API_ENABLED, "MOBY_API_ENABLED": MOBY_API_ENABLED, + "SS_API_ENABLED": SS_API_ENABLED, "STEAMGRIDDB_ENABLED": STEAMGRIDDB_API_ENABLED, }, "FILESYSTEM": { diff --git a/backend/endpoints/responses/heartbeat.py b/backend/endpoints/responses/heartbeat.py index f44323f5d..fc1778bf3 100644 --- a/backend/endpoints/responses/heartbeat.py +++ b/backend/endpoints/responses/heartbeat.py @@ -25,6 +25,7 @@ class MetadataSourcesDict(TypedDict): ANY_SOURCE_ENABLED: bool IGDB_API_ENABLED: bool MOBY_API_ENABLED: bool + SS_API_ENABLED: bool STEAMGRIDDB_ENABLED: bool diff --git a/backend/endpoints/responses/platform.py b/backend/endpoints/responses/platform.py index 6ca60452e..beac49479 100644 --- a/backend/endpoints/responses/platform.py +++ b/backend/endpoints/responses/platform.py @@ -17,6 +17,7 @@ class PlatformSchema(BaseModel): igdb_id: int | None = None sgdb_id: int | None = None moby_id: int | None = None + ss_id: int | None = None category: str | None = None generation: int | None = None family_name: str | None = None diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index dea6171d2..3d25c0fd1 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -120,6 +120,7 @@ class RomSchema(BaseModel): igdb_id: int | None sgdb_id: int | None moby_id: int | None + ss_id: int | None platform_id: int platform_slug: str @@ -152,10 +153,16 @@ class RomSchema(BaseModel): age_ratings: list[str] igdb_metadata: RomIGDBMetadata | None moby_metadata: RomMobyMetadata | None + ss_metadata: RomMobyMetadata | None path_cover_small: str | None path_cover_large: str | None url_cover: str | None + + has_manual: bool + path_manual: str | None + url_manual: str | None + is_unidentified: bool revision: str | None diff --git a/backend/endpoints/responses/search.py b/backend/endpoints/responses/search.py index 43b264753..05d0494ce 100644 --- a/backend/endpoints/responses/search.py +++ b/backend/endpoints/responses/search.py @@ -4,11 +4,13 @@ class SearchRomSchema(BaseModel): igdb_id: int | None = None moby_id: int | None = None + ss_id: int | None = None slug: str name: str summary: str igdb_url_cover: str = "" moby_url_cover: str = "" + ss_url_cover: str = "" platform_id: int diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index fddc30cca..1520aa4f7 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -1,4 +1,5 @@ import binascii +import os from base64 import b64encode from datetime import datetime, timezone from io import BytesIO @@ -31,7 +32,8 @@ from handler.database import db_platform_handler, db_rom_handler from handler.filesystem import fs_resource_handler, fs_rom_handler from handler.filesystem.base_handler import CoverSize -from handler.metadata import meta_igdb_handler, meta_moby_handler +from handler.metadata import meta_igdb_handler, meta_moby_handler, meta_ss_handler +from logger.formatter import highlight as hl from logger.logger import log from models.rom import Rom, RomFile, RomUser from PIL import Image @@ -492,6 +494,7 @@ async def update_rom( "igdb_id": None, "sgdb_id": None, "moby_id": None, + "ss_id": None, "name": rom.fs_name, "summary": "", "url_screenshots": [], @@ -499,9 +502,11 @@ async def update_rom( "path_cover_s": "", "path_cover_l": "", "url_cover": "", + "url_manual": "", "slug": "", "igdb_metadata": {}, "moby_metadata": {}, + "ss_metadata": {}, "revision": "", }, ) @@ -515,6 +520,7 @@ async def update_rom( cleaned_data: dict[str, Any] = { "igdb_id": data.get("igdb_id", rom.igdb_id), "moby_id": data.get("moby_id", rom.moby_id), + "ss_id": data.get("ss_id", rom.ss_id), } moby_id = cleaned_data["moby_id"] @@ -527,9 +533,23 @@ async def update_rom( ) cleaned_data.update({"path_screenshots": path_screenshots}) - igdb_id = cleaned_data["igdb_id"] - if igdb_id and int(igdb_id) != rom.igdb_id: - igdb_rom = await meta_igdb_handler.get_rom_by_id(int(igdb_id)) + if ( + cleaned_data.get("ss_id", "") + and int(cleaned_data.get("ss_id", "")) != rom.ss_id + ): + ss_rom = await meta_ss_handler.get_rom_by_id(cleaned_data["ss_id"]) + cleaned_data.update(ss_rom) + path_screenshots = await fs_resource_handler.get_rom_screenshots( + rom=rom, + url_screenshots=cleaned_data.get("url_screenshots", []), + ) + cleaned_data.update({"path_screenshots": path_screenshots}) + + if ( + cleaned_data.get("igdb_id", "") + and int(cleaned_data.get("igdb_id", "")) != rom.igdb_id + ): + igdb_rom = await meta_igdb_handler.get_rom_by_id(cleaned_data["igdb_id"]) cleaned_data.update(igdb_rom) path_screenshots = await fs_resource_handler.get_rom_screenshots( rom=rom, @@ -613,14 +633,29 @@ async def update_rom( ): cleaned_data.update({"url_cover": data.get("url_cover", rom.url_cover)}) path_cover_s, path_cover_l = await fs_resource_handler.get_cover( - overwrite=True, entity=rom, + overwrite=True, url_cover=str(data.get("url_cover") or ""), ) cleaned_data.update( {"path_cover_s": path_cover_s, "path_cover_l": path_cover_l} ) + if data.get("url_manual", "") != rom.url_manual or not ( + await fs_resource_handler.manual_exists(rom) + ): + cleaned_data.update({"url_manual": data.get("url_manual", rom.url_manual)}) + url_manual = await fs_resource_handler.get_manual( + rom=rom, + overwrite=True, + url_manual=str(data.get("url_manual") or ""), + ) + cleaned_data.update({"url_manual": url_manual}) + + log.debug( + f"Updating {hl(cleaned_data.get('name', ''))} [{id}] with data {cleaned_data}" + ) + db_rom_handler.update_rom(id, cleaned_data) rom = db_rom_handler.get_rom(id) if not rom: @@ -629,6 +664,60 @@ async def update_rom( return DetailedRomSchema.from_orm_with_request(rom, request) +@protected_route(router.post, "/{id}/manuals", [Scope.ROMS_WRITE]) +async def add_rom_manuals(request: Request, id: int): + """Upload manuals for a rom + + Args: + request (Request): Fastapi Request object + + Raises: + HTTPException: No files were uploaded + """ + rom = db_rom_handler.get_rom(id) + if not rom: + raise RomNotFoundInDatabaseException(id) + + filename = request.headers.get("x-upload-filename") + + manuals_path = f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/manual" + file_location = Path(f"{manuals_path}/{rom.id}.pdf") + log.info(f"Uploading {file_location}") + + if not os.path.exists(manuals_path): + await Path(manuals_path).mkdir(parents=True, exist_ok=True) + + parser = StreamingFormDataParser(headers=request.headers) + parser.register("x-upload-platform", NullTarget()) + parser.register(filename, FileTarget(str(file_location))) + + async def cleanup_partial_file(): + if await file_location.exists(): + await file_location.unlink() + + try: + async for chunk in request.stream(): + parser.data_received(chunk) + except ClientDisconnect: + log.error("Client disconnected during upload") + await cleanup_partial_file() + except Exception as exc: + log.error("Error uploading files", exc_info=exc) + await cleanup_partial_file() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="There was an error uploading the file(s)", + ) from exc + + path_manual = await fs_resource_handler.get_manual( + rom=rom, overwrite=False, url_manual=None + ) + + db_rom_handler.update_rom(id, {"path_manual": path_manual}) + + return Response(status_code=status.HTTP_201_CREATED) + + @protected_route(router.post, "/delete", [Scope.ROMS_WRITE]) async def delete_roms( request: Request, diff --git a/backend/endpoints/search.py b/backend/endpoints/search.py index 2a804e34b..5a190ab76 100644 --- a/backend/endpoints/search.py +++ b/backend/endpoints/search.py @@ -1,13 +1,21 @@ +import asyncio + import emoji from decorators.auth import protected_route from endpoints.responses.search import SearchCoverSchema, SearchRomSchema from fastapi import HTTPException, Request, status from handler.auth.constants import Scope from handler.database import db_rom_handler -from handler.metadata import meta_igdb_handler, meta_moby_handler, meta_sgdb_handler +from handler.metadata import ( + meta_igdb_handler, + meta_moby_handler, + meta_sgdb_handler, + meta_ss_handler, +) from handler.metadata.igdb_handler import IGDB_API_ENABLED, IGDBRom from handler.metadata.moby_handler import MOBY_API_ENABLED, MobyGamesRom from handler.metadata.sgdb_handler import STEAMGRIDDB_API_ENABLED +from handler.metadata.ss_handler import SS_API_ENABLED, SSGamesRom from handler.scan_handler import _get_main_platform_igdb_id from logger.logger import log from utils.router import APIRouter @@ -39,7 +47,7 @@ async def search_rom( list[SearchRomSchema]: List of matched roms """ - if not IGDB_API_ENABLED and not MOBY_API_ENABLED: + if not IGDB_API_ENABLED and not SS_API_ENABLED and not MOBY_API_ENABLED: log.error("Search error: No metadata providers enabled") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -64,11 +72,15 @@ async def search_rom( igdb_matched_roms: list[IGDBRom] = [] moby_matched_roms: list[MobyGamesRom] = [] + ss_matched_roms: list[SSGamesRom] = [] if search_by.lower() == "id": try: - igdb_rom = await meta_igdb_handler.get_matched_rom_by_id(int(search_term)) - moby_rom = await meta_moby_handler.get_matched_rom_by_id(int(search_term)) + igdb_rom, moby_rom, ss_rom = await asyncio.gather( + meta_igdb_handler.get_matched_rom_by_id(int(search_term)), + meta_moby_handler.get_matched_rom_by_id(int(search_term)), + meta_ss_handler.get_matched_rom_by_id(int(search_term)), + ) except ValueError as exc: log.error(f"Search error: invalid ID '{search_term}'") raise HTTPException( @@ -78,24 +90,39 @@ async def search_rom( else: igdb_matched_roms = [igdb_rom] if igdb_rom else [] moby_matched_roms = [moby_rom] if moby_rom else [] + ss_matched_roms = [ss_rom] if ss_rom else [] elif search_by.lower() == "name": - main_platform_igdb_id = await _get_main_platform_igdb_id(rom.platform) - igdb_matched_roms = await meta_igdb_handler.get_matched_roms_by_name( - search_term, main_platform_igdb_id or rom.platform.igdb_id - ) - moby_matched_roms = await meta_moby_handler.get_matched_roms_by_name( - search_term, rom.platform.moby_id + igdb_matched_roms, moby_matched_roms, ss_matched_roms = await asyncio.gather( + meta_igdb_handler.get_matched_roms_by_name( + search_term, (await _get_main_platform_igdb_id(rom.platform)) + ), + meta_moby_handler.get_matched_roms_by_name( + search_term, rom.platform.moby_id + ), + meta_ss_handler.get_matched_roms_by_name(search_term, rom.platform.ss_id), ) - merged_dict = { - item["name"]: {**item, "igdb_url_cover": item.pop("url_cover", "")} # type: ignore - for item in igdb_matched_roms - } - for item in moby_matched_roms: - merged_dict[item["name"]] = { # type: ignore - **item, - "moby_url_cover": item.pop("url_cover", ""), - **merged_dict.get(item.get("name", ""), {}), + merged_dict: dict[str, dict] = {} + + for igdb_rom in igdb_matched_roms: + merged_dict[igdb_rom["name"]] = { + **igdb_rom, + "igdb_url_cover": igdb_rom.pop("url_cover", ""), + **merged_dict.get(igdb_rom.get("name", ""), {}), + } + + for moby_rom in moby_matched_roms: + merged_dict[moby_rom["name"]] = { # type: ignore + **moby_rom, + "moby_url_cover": moby_rom.pop("url_cover", ""), + **merged_dict.get(moby_rom.get("name", ""), {}), + } + + for ss_rom in ss_matched_roms: + merged_dict[ss_rom["name"]] = { + **ss_rom, + "ss_url_cover": ss_rom.pop("url_cover", ""), + **merged_dict.get(ss_rom.get("name", ""), {}), } matched_roms = [ @@ -106,6 +133,7 @@ async def search_rom( "summary": "", "igdb_url_cover": "", "moby_url_cover": "", + "ss_url_cover": "", "platform_id": rom.platform_id, }, **item, diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index 4973cd4e3..4481b3aba 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -25,7 +25,13 @@ ) from handler.filesystem.roms_handler import FSRom from handler.redis_handler import high_prio_queue, low_prio_queue, redis_client -from handler.scan_handler import ScanType, scan_firmware, scan_platform, scan_rom +from handler.scan_handler import ( + MetadataSource, + ScanType, + scan_firmware, + scan_platform, + scan_rom, +) from handler.socket_handler import socket_handler from logger.formatter import LIGHTYELLOW, RED from logger.formatter import highlight as hl @@ -153,7 +159,7 @@ async def scan_platforms( roms_ids = [] if not metadata_sources: - metadata_sources = ["igdb", "moby"] + metadata_sources = [MetadataSource.IGDB, MetadataSource.MOBY, MetadataSource.SS] sm = _get_socket_manager() @@ -485,11 +491,17 @@ async def _identify_rom( return scan_stats path_cover_s, path_cover_l = await fs_resource_handler.get_cover( - overwrite=True, entity=_added_rom, + overwrite=True, url_cover=_added_rom.url_cover, ) + path_manual = await fs_resource_handler.get_manual( + rom=_added_rom, + overwrite=True, + url_manual=_added_rom.url_manual, + ) + path_screenshots = await fs_resource_handler.get_rom_screenshots( rom=_added_rom, url_screenshots=_added_rom.url_screenshots, @@ -498,6 +510,7 @@ async def _identify_rom( _added_rom.path_cover_s = path_cover_s _added_rom.path_cover_l = path_cover_l _added_rom.path_screenshots = path_screenshots + _added_rom.path_manual = path_manual # Update the scanned rom with the cover and screenshots paths and update database db_rom_handler.update_rom( _added_rom.id, diff --git a/backend/handler/filesystem/resources_handler.py b/backend/handler/filesystem/resources_handler.py index feaa1f5c4..81f14f742 100644 --- a/backend/handler/filesystem/resources_handler.py +++ b/backend/handler/filesystem/resources_handler.py @@ -200,3 +200,66 @@ async def get_rom_screenshots( path_screenshots.append(self._get_screenshot_path(rom, str(idx))) return path_screenshots + + @staticmethod + async def manual_exists(rom: Rom) -> bool: + """Check if rom manual exists in filesystem + + Args: + rom: Rom object + Returns + True if manual exists in filesystem else False + """ + async for _ in Path( + f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/manual" + ).glob(f"{rom.id}.pdf"): + return True + return False + + @staticmethod + async def _store_manual(rom: Rom, url_manual: str): + manual_path = Path(f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/manual") + manual_file = manual_path / Path(f"{rom.id}.pdf") + + httpx_client = ctx_httpx_client.get() + try: + async with httpx_client.stream("GET", url_manual, timeout=120) as response: + if response.status_code == 200: + await manual_path.mkdir(parents=True, exist_ok=True) + async with await manual_file.open("wb") as f: + async for chunk in response.aiter_raw(): + await f.write(chunk) + except httpx.NetworkError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Unable to fetch cover at {url_manual}: {str(exc)}", + ) from exc + except httpx.ProtocolError: + log.warning(f"Failure writing cover {url_manual} to file (ProtocolError)") + + @staticmethod + async def _get_manual_path(rom: Rom) -> str: + """Returns rom manual filesystem path adapted to frontend folder structure + + Args: + rom: Rom object + """ + async for matched_file in Path( + f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/manual" + ).glob(f"{rom.id}.pdf"): + return str(matched_file.relative_to(RESOURCES_BASE_PATH)) + return "" + + async def get_manual( + self, rom: Rom | None, overwrite: bool, url_manual: str | None + ) -> str: + if not rom: + return "" + + manual_exists = await self.manual_exists(rom) + if url_manual and (overwrite or not manual_exists): + await self._store_manual(rom, url_manual) + manual_exists = await self.manual_exists(rom) + path_manual = (await self._get_manual_path(rom)) if manual_exists else "" + + return path_manual diff --git a/backend/handler/filesystem/tests/test_fs.py b/backend/handler/filesystem/tests/test_fs.py index 652259149..1cc9d9192 100644 --- a/backend/handler/filesystem/tests/test_fs.py +++ b/backend/handler/filesystem/tests/test_fs.py @@ -7,7 +7,7 @@ async def test_get_rom_cover(): path_cover_s, path_cover_l = await fs_resource_handler.get_cover( - overwrite=False, entity=None, url_cover="" + entity=None, overwrite=False, url_cover="" ) assert "" in path_cover_s diff --git a/backend/handler/metadata/__init__.py b/backend/handler/metadata/__init__.py index 65bd6ee37..f7a9e476a 100644 --- a/backend/handler/metadata/__init__.py +++ b/backend/handler/metadata/__init__.py @@ -1,7 +1,9 @@ from .igdb_handler import IGDBBaseHandler from .moby_handler import MobyGamesHandler from .sgdb_handler import SGDBBaseHandler +from .ss_handler import SSBaseHandler meta_igdb_handler = IGDBBaseHandler() meta_moby_handler = MobyGamesHandler() +meta_ss_handler = SSBaseHandler() meta_sgdb_handler = SGDBBaseHandler() diff --git a/backend/handler/metadata/base_hander.py b/backend/handler/metadata/base_hander.py index 1dbc48e48..afe17347b 100644 --- a/backend/handler/metadata/base_hander.py +++ b/backend/handler/metadata/base_hander.py @@ -202,13 +202,17 @@ def _mask_sensitive_values(self, values: dict[str, str]) -> dict[str, str]: - "client_id" - "client_secret" - "api_key" + - "ssid" + - "sspassword" + - "devid" + - "devpassword" """ return { key: ( - f"Bearer {values[key].split(' ')[1][:3]}***{values[key].split(' ')[1][-3:]}" + f"Bearer {values[key].split(' ')[1][:2]}***{values[key].split(' ')[1][-2:]}" if key == "Authorization" and values[key].startswith("Bearer ") else ( - f"{values[key][:3]}***{values[key][-3:]}" + f"{values[key][:2]}***{values[key][-2:]}" if key in { "Client-ID", @@ -216,6 +220,10 @@ def _mask_sensitive_values(self, values: dict[str, str]) -> dict[str, str]: "client_id", "client_secret", "api_key", + "ssid", + "sspassword", + "devid", + "devpassword", } # Leave other keys unchanged else values[key] diff --git a/backend/handler/metadata/igdb_handler.py b/backend/handler/metadata/igdb_handler.py index 63d879040..07cd7cd4c 100644 --- a/backend/handler/metadata/igdb_handler.py +++ b/backend/handler/metadata/igdb_handler.py @@ -808,222 +808,228 @@ async def get_oauth_token(self) -> str: # })) IGDB_PLATFORM_LIST = ( - {"slug": "visionos", "name": "visionOS"}, - {"slug": "meta-quest-3", "name": "Meta Quest 3"}, - {"slug": "atari2600", "name": "Atari 2600"}, - {"slug": "psvr2", "name": "PlayStation VR2"}, - {"slug": "switch", "name": "Nintendo Switch"}, - {"slug": "evercade", "name": "Evercade"}, - {"slug": "android", "name": "Android"}, - {"slug": "mac", "name": "Mac"}, - {"slug": "win", "name": "PC (Microsoft Windows)"}, - {"slug": "oculus-quest", "name": "Oculus Quest"}, - {"slug": "playdate", "name": "Playdate"}, - {"slug": "series-x", "name": "Xbox Series X"}, - {"slug": "meta-quest-2", "name": "Meta Quest 2"}, - {"slug": "ps5", "name": "PlayStation 5"}, - {"slug": "oculus-rift", "name": "Oculus Rift"}, - {"slug": "xboxone", "name": "Xbox One"}, - {"slug": "leaptv", "name": "LeapTV"}, - {"slug": "new-nintendo-3ds", "name": "New Nintendo 3DS"}, - {"slug": "gear-vr", "name": "Gear VR"}, - {"slug": "psvr", "name": "PlayStation VR"}, - {"slug": "3ds", "name": "Nintendo 3DS"}, - {"slug": "winphone", "name": "Windows Phone"}, - {"slug": "arduboy", "name": "Arduboy"}, - {"slug": "ps4--1", "name": "PlayStation 4"}, - {"slug": "oculus-go", "name": "Oculus Go"}, - {"slug": "psvita", "name": "PlayStation Vita"}, - {"slug": "wiiu", "name": "Wii U"}, - {"slug": "ouya", "name": "Ouya"}, - {"slug": "wii", "name": "Wii"}, - {"slug": "ps3", "name": "PlayStation 3"}, - {"slug": "psp", "name": "PlayStation Portable"}, - {"slug": "nintendo-dsi", "name": "Nintendo DSi"}, { - "slug": "leapster-explorer-slash-leadpad-explorer", + "name": "1292 Advanced Programmable Video System", + "slug": "1292-advanced-programmable-video-system", + }, + {"name": "3DO Interactive Multiplayer", "slug": "3do"}, + {"name": "Nintendo 3DS", "slug": "3ds"}, + {"name": "Acorn Archimedes", "slug": "acorn-archimedes"}, + {"name": "Acorn Electron", "slug": "acorn-electron"}, + {"name": "Amstrad CPC", "slug": "acpc"}, + {"name": "AirConsole", "slug": "airconsole"}, + {"name": "Amazon Fire TV", "slug": "amazon-fire-tv"}, + {"name": "Amiga", "slug": "amiga"}, + {"name": "Amiga CD32", "slug": "amiga-cd32"}, + {"name": "Amstrad PCW", "slug": "amstrad-pcw"}, + {"name": "Android", "slug": "android"}, + {"name": "Apple IIGS", "slug": "apple-iigs"}, + {"name": "Apple Pippin", "slug": "apple-pippin"}, + {"name": "Apple II", "slug": "appleii"}, + {"name": "Arcade", "slug": "arcade"}, + {"name": "Arcadia 2001", "slug": "arcadia-2001"}, + {"name": "Arduboy", "slug": "arduboy"}, + {"name": "Bally Astrocade", "slug": "astrocade"}, + {"name": "Atari Jaguar CD", "slug": "atari-jaguar-cd"}, + {"name": "Atari ST/STE", "slug": "atari-st"}, + {"name": "Atari 2600", "slug": "atari2600"}, + {"name": "Atari 5200", "slug": "atari5200"}, + {"name": "Atari 7800", "slug": "atari7800"}, + {"name": "Atari 8-bit", "slug": "atari8bit"}, + {"name": "AY-3-8500", "slug": "ay-3-8500"}, + {"name": "AY-3-8603", "slug": "ay-3-8603"}, + {"name": "AY-3-8605", "slug": "ay-3-8605"}, + {"name": "AY-3-8606", "slug": "ay-3-8606"}, + {"name": "AY-3-8607", "slug": "ay-3-8607"}, + {"name": "AY-3-8610", "slug": "ay-3-8610"}, + {"name": "AY-3-8710", "slug": "ay-3-8710"}, + {"name": "AY-3-8760", "slug": "ay-3-8760"}, + {"name": "BBC Microcomputer System", "slug": "bbcmicro"}, + {"name": "BlackBerry OS", "slug": "blackberry"}, + {"name": "Blu-ray Player", "slug": "blu-ray-player"}, + {"name": "Web browser", "slug": "browser"}, + {"name": "Commodore Plus/4", "slug": "c-plus-4"}, + {"name": "Commodore 16", "slug": "c16"}, + {"name": "Commodore C64/128/MAX", "slug": "c64"}, + { + "name": "Call-A-Computer time-shared mainframe computer system", + "slug": "call-a-computer", + }, + {"name": "Casio Loopy", "slug": "casio-loopy"}, + {"name": "CDC Cyber 70", "slug": "cdccyber70"}, + {"name": "ColecoVision", "slug": "colecovision"}, + {"name": "Commodore CDTV", "slug": "commodore-cdtv"}, + {"name": "Commodore PET", "slug": "cpet"}, + {"name": "Dreamcast", "slug": "dc"}, + {"name": "Donner Model 30", "slug": "donner30"}, + {"name": "DOS", "slug": "dos"}, + {"name": "Dragon 32/64", "slug": "dragon-32-slash-64"}, + {"name": "DVD Player", "slug": "dvd-player"}, + {"name": "EDSAC", "slug": "edsac--1"}, + {"name": "Epoch Cassette Vision", "slug": "epoch-cassette-vision"}, + {"name": "Epoch Super Cassette Vision", "slug": "epoch-super-cassette-vision"}, + {"name": "Evercade", "slug": "evercade"}, + {"name": "Exidy Sorcerer", "slug": "exidy-sorcerer"}, + {"name": "Fairchild Channel F", "slug": "fairchild-channel-f"}, + {"name": "Family Computer", "slug": "famicom"}, + {"name": "Family Computer Disk System", "slug": "fds"}, + {"name": "FM-7", "slug": "fm-7"}, + {"name": "FM Towns", "slug": "fm-towns"}, + {"name": "Game & Watch", "slug": "g-and-w"}, + {"name": "Gamate", "slug": "gamate"}, + {"name": "Game.com", "slug": "game-dot-com"}, + {"name": "Sega Game Gear", "slug": "gamegear"}, + {"name": "Game Boy", "slug": "gb"}, + {"name": "Game Boy Advance", "slug": "gba"}, + {"name": "Game Boy Color", "slug": "gbc"}, + {"name": "Gear VR", "slug": "gear-vr"}, + {"name": "Sega Mega Drive/Genesis", "slug": "genesis-slash-megadrive"}, + {"name": "Gizmondo", "slug": "gizmondo"}, + {"name": "Handheld Electronic LCD", "slug": "handheld-electronic-lcd"}, + {"name": "HP 2100", "slug": "hp2100"}, + {"name": "HP 3000", "slug": "hp3000"}, + {"name": "Hyper Neo Geo 64", "slug": "hyper-neo-geo-64"}, + {"name": "HyperScan", "slug": "hyperscan"}, + {"name": "Intellivision", "slug": "intellivision"}, + {"name": "Intellivision Amico", "slug": "intellivision-amico"}, + {"name": "iOS", "slug": "ios"}, + {"name": "Atari Jaguar", "slug": "jaguar"}, + {"name": "Leapster", "slug": "leapster"}, + { "name": "Leapster Explorer/LeadPad Explorer", + "slug": "leapster-explorer-slash-leadpad-explorer", }, - {"slug": "xbox360", "name": "Xbox 360"}, - {"slug": "nds", "name": "Nintendo DS"}, - {"slug": "ps2", "name": "PlayStation 2"}, - {"slug": "arcade", "name": "Arcade"}, - {"slug": "zeebo", "name": "Zeebo"}, - {"slug": "windows-mobile", "name": "Windows Mobile"}, - {"slug": "ios", "name": "iOS"}, - {"slug": "mobile", "name": "Legacy Mobile Device"}, - {"slug": "blu-ray-player", "name": "Blu-ray Player"}, - {"slug": "hyperscan", "name": "HyperScan"}, - {"slug": "gizmondo", "name": "Gizmondo"}, - {"slug": "gba", "name": "Game Boy Advance"}, - {"slug": "ngage", "name": "N-Gage"}, - {"slug": "vsmile", "name": "V.Smile"}, - {"slug": "n64", "name": "Nintendo 64"}, - {"slug": "leapster", "name": "Leapster"}, - {"slug": "zod", "name": "Tapwave Zodiac"}, - {"slug": "wonderswan-color", "name": "WonderSwan Color"}, - {"slug": "xbox", "name": "Xbox"}, - {"slug": "ngc", "name": "Nintendo GameCube"}, - {"slug": "wonderswan", "name": "WonderSwan"}, - {"slug": "pokemon-mini", "name": "Pokémon mini"}, - {"slug": "nuon", "name": "Nuon"}, - {"slug": "ps", "name": "PlayStation"}, - {"slug": "nintendo-64dd", "name": "Nintendo 64DD"}, - {"slug": "neo-geo-pocket-color", "name": "Neo Geo Pocket Color"}, - {"slug": "dvd-player", "name": "DVD Player"}, - {"slug": "pocketstation", "name": "PocketStation"}, + {"name": "LeapTV", "slug": "leaptv"}, + {"name": "Legacy Computer", "slug": "legacy-computer"}, + {"name": "Linux", "slug": "linux"}, + {"name": "Atari Lynx", "slug": "lynx"}, + {"name": "Mac", "slug": "mac"}, + {"name": "Mega Duck/Cougar Boy", "slug": "mega-duck-slash-cougar-boy"}, + {"name": "Meta Quest 2", "slug": "meta-quest-2"}, + {"name": "Meta Quest 3", "slug": "meta-quest-3"}, + {"name": "Microvision", "slug": "microvision--1"}, + {"name": "Legacy Mobile Device", "slug": "mobile"}, + {"name": "MSX", "slug": "msx"}, + {"name": "MSX2", "slug": "msx2"}, + {"name": "Nintendo 64", "slug": "n64"}, + {"name": "Nintendo DS", "slug": "nds"}, + {"name": "NEC PC-6000 Series", "slug": "nec-pc-6000-series"}, + {"name": "Neo Geo CD", "slug": "neo-geo-cd"}, + {"name": "Neo Geo Pocket", "slug": "neo-geo-pocket"}, + {"name": "Neo Geo Pocket Color", "slug": "neo-geo-pocket-color"}, + {"name": "Neo Geo AES", "slug": "neogeoaes"}, + {"name": "Neo Geo MVS", "slug": "neogeomvs"}, + {"name": "Nintendo Entertainment System", "slug": "nes"}, + {"name": "New Nintendo 3DS", "slug": "new-nintendo-3ds"}, + {"name": "N-Gage", "slug": "ngage"}, + {"name": "Nintendo GameCube", "slug": "ngc"}, + {"name": "Ferranti Nimrod Computer", "slug": "nimrod"}, + {"name": "Nintendo 64DD", "slug": "nintendo-64dd"}, + {"name": "Nintendo DSi", "slug": "nintendo-dsi"}, + {"name": "Nintendo PlayStation", "slug": "nintendo-playstation"}, + {"name": "Nuon", "slug": "nuon"}, + {"name": "Oculus Go", "slug": "oculus-go"}, + {"name": "Oculus Quest", "slug": "oculus-quest"}, + {"name": "Oculus Rift", "slug": "oculus-rift"}, + {"name": "Odyssey", "slug": "odyssey--1"}, { - "slug": "visual-memory-unit-slash-visual-memory-system", - "name": "Visual Memory Unit / Visual Memory System", + "name": "Odyssey 2 / Videopac G7000", + "slug": "odyssey-2-slash-videopac-g7000", }, - {"slug": "blackberry", "name": "BlackBerry OS"}, - {"slug": "dc", "name": "Dreamcast"}, - {"slug": "gbc", "name": "Game Boy Color"}, - {"slug": "gb", "name": "Game Boy"}, - {"slug": "neo-geo-pocket", "name": "Neo Geo Pocket"}, - {"slug": "snes", "name": "Super Nintendo Entertainment System"}, - {"slug": "genesis-slash-megadrive", "name": "Sega Mega Drive/Genesis"}, - {"slug": "sfam", "name": "Super Famicom"}, - {"slug": "game-dot-com", "name": "Game.com"}, - {"slug": "hyper-neo-geo-64", "name": "Hyper Neo Geo 64"}, - {"slug": "satellaview", "name": "Satellaview"}, - {"slug": "palm-os", "name": "Palm OS"}, - {"slug": "apple-pippin", "name": "Apple Pippin"}, - {"slug": "sega32", "name": "Sega 32X"}, - {"slug": "neo-geo-cd", "name": "Neo Geo CD"}, - {"slug": "virtualboy", "name": "Virtual Boy"}, - {"slug": "atari-jaguar-cd", "name": "Atari Jaguar CD"}, - {"slug": "saturn", "name": "Sega Saturn"}, - {"slug": "casio-loopy", "name": "Casio Loopy"}, - {"slug": "sega-pico", "name": "Sega Pico"}, - {"slug": "r-zone", "name": "R-Zone"}, - {"slug": "sms", "name": "Sega Master System/Mark III"}, - {"slug": "playdia", "name": "Playdia"}, - {"slug": "pc-fx", "name": "PC-FX"}, - {"slug": "3do", "name": "3DO Interactive Multiplayer"}, + {"name": "OnLive Game System", "slug": "onlive-game-system"}, + {"name": "OOParts", "slug": "ooparts"}, + {"name": "Ouya", "slug": "ouya"}, + {"name": "Palm OS", "slug": "palm-os"}, + {"name": "Panasonic Jungle", "slug": "panasonic-jungle"}, + {"name": "Panasonic M2", "slug": "panasonic-m2"}, + {"name": "PC-50X Family", "slug": "pc-50x-family"}, + {"name": "PC-8800 Series", "slug": "pc-8800-series"}, + {"name": "PC-9800 Series", "slug": "pc-9800-series"}, + {"name": "PC-FX", "slug": "pc-fx"}, + {"name": "PDP-8", "slug": "pdp-8--1"}, + {"name": "PDP-1", "slug": "pdp1"}, + {"name": "PDP-10", "slug": "pdp10"}, + {"name": "PDP-11", "slug": "pdp11"}, + {"name": "Philips CD-i", "slug": "philips-cd-i"}, + {"name": "PLATO", "slug": "plato--1"}, + {"name": "Playdate", "slug": "playdate"}, + {"name": "Playdia", "slug": "playdia"}, + {"name": "Plug & Play", "slug": "plug-and-play"}, + {"name": "PocketStation", "slug": "pocketstation"}, + {"name": "Pokémon mini", "slug": "pokemon-mini"}, + {"name": "PlayStation", "slug": "ps"}, + {"name": "PlayStation 2", "slug": "ps2"}, + {"name": "PlayStation 3", "slug": "ps3"}, + {"name": "PlayStation 4", "slug": "ps4--1"}, + {"name": "PlayStation 5", "slug": "ps5"}, + {"name": "PlayStation Portable", "slug": "psp"}, + {"name": "PlayStation Vita", "slug": "psvita"}, + {"name": "PlayStation VR", "slug": "psvr"}, + {"name": "PlayStation VR2", "slug": "psvr2"}, + {"name": "R-Zone", "slug": "r-zone"}, + {"name": "Satellaview", "slug": "satellaview"}, + {"name": "Sega Saturn", "slug": "saturn"}, + {"name": "SDS Sigma 7", "slug": "sdssigma7"}, + {"name": "Sega Pico", "slug": "sega-pico"}, + {"name": "Sega 32X", "slug": "sega32"}, + {"name": "Sega CD", "slug": "segacd"}, + {"name": "Xbox Series X", "slug": "series-x"}, + {"name": "Super Famicom", "slug": "sfam"}, + {"name": "SG-1000", "slug": "sg1000"}, + {"name": "Sharp MZ-2200", "slug": "sharp-mz-2200"}, + {"name": "Sharp X68000", "slug": "sharp-x68000"}, + {"name": "Sinclair QL", "slug": "sinclair-ql"}, + {"name": "Sinclair ZX81", "slug": "sinclair-zx81"}, + {"name": "Sega Master System/Mark III", "slug": "sms"}, + {"name": "Super Nintendo Entertainment System", "slug": "snes"}, + {"name": "Sol-20", "slug": "sol-20"}, + {"name": "Google Stadia", "slug": "stadia"}, + {"name": "PC Engine SuperGrafx", "slug": "supergrafx"}, + {"name": "SwanCrystal", "slug": "swancrystal"}, + {"name": "Nintendo Switch", "slug": "switch"}, + {"name": "Tatung Einstein", "slug": "tatung-einstein"}, { - "slug": "terebikko-slash-see-n-say-video-phone", "name": "Terebikko / See 'n Say Video Phone", + "slug": "terebikko-slash-see-n-say-video-phone", }, - {"slug": "jaguar", "name": "Atari Jaguar"}, - {"slug": "segacd", "name": "Sega CD"}, - {"slug": "nes", "name": "Nintendo Entertainment System"}, - {"slug": "amiga-cd32", "name": "Amiga CD32"}, - {"slug": "famicom", "name": "Family Computer"}, - {"slug": "mega-duck-slash-cougar-boy", "name": "Mega Duck/Cougar Boy"}, - {"slug": "amiga", "name": "Amiga"}, + {"name": "Thomson MO5", "slug": "thomson-mo5"}, + {"name": "Texas Instruments TI-99", "slug": "ti-99"}, + {"name": "TRS-80", "slug": "trs-80"}, + {"name": "TRS-80 Color Computer", "slug": "trs-80-color-computer"}, { - "slug": "watara-slash-quickshot-supervision", - "name": "Watara/QuickShot Supervision", + "name": "Turbografx-16/PC Engine CD", + "slug": "turbografx-16-slash-pc-engine-cd", }, - {"slug": "philips-cd-i", "name": "Philips CD-i"}, - {"slug": "gamegear", "name": "Sega Game Gear"}, - {"slug": "neogeoaes", "name": "Neo Geo AES"}, - {"slug": "linux", "name": "Linux"}, - {"slug": "turbografx-16-slash-pc-engine-cd", "name": "Turbografx-16/PC Engine CD"}, - {"slug": "neogeomvs", "name": "Neo Geo MVS"}, - {"slug": "commodore-cdtv", "name": "Commodore CDTV"}, - {"slug": "lynx", "name": "Atari Lynx"}, - {"slug": "gamate", "name": "Gamate"}, - {"slug": "bbcmicro", "name": "BBC Microcomputer System"}, - {"slug": "turbografx16--1", "name": "TurboGrafx-16/PC Engine"}, - {"slug": "supergrafx", "name": "PC Engine SuperGrafx"}, - {"slug": "fm-towns", "name": "FM Towns"}, - {"slug": "pc-9800-series", "name": "PC-9800 Series"}, - {"slug": "apple-iigs", "name": "Apple IIGS"}, - {"slug": "x1", "name": "Sharp X1"}, - {"slug": "sharp-x68000", "name": "Sharp X68000"}, - {"slug": "acorn-archimedes", "name": "Acorn Archimedes"}, - {"slug": "c64", "name": "Commodore C64/128/MAX"}, - {"slug": "fds", "name": "Family Computer Disk System"}, - {"slug": "dragon-32-slash-64", "name": "Dragon 32/64"}, - {"slug": "acorn-electron", "name": "Acorn Electron"}, - {"slug": "acpc", "name": "Amstrad CPC"}, - {"slug": "atari-st", "name": "Atari ST/STE"}, - {"slug": "tatung-einstein", "name": "Tatung Einstein"}, - {"slug": "amstrad-pcw", "name": "Amstrad PCW"}, - {"slug": "epoch-super-cassette-vision", "name": "Epoch Super Cassette Vision"}, - {"slug": "atari7800", "name": "Atari 7800"}, - {"slug": "hp3000", "name": "HP 3000"}, - {"slug": "atari5200", "name": "Atari 5200"}, - {"slug": "c16", "name": "Commodore 16"}, - {"slug": "sinclair-ql", "name": "Sinclair QL"}, - {"slug": "thomson-mo5", "name": "Thomson MO5"}, - {"slug": "c-plus-4", "name": "Commodore Plus/4"}, - {"slug": "sg1000", "name": "SG-1000"}, - {"slug": "vectrex", "name": "Vectrex"}, - {"slug": "sharp-mz-2200", "name": "Sharp MZ-2200"}, - {"slug": "nec-pc-6000-series", "name": "NEC PC-6000 Series"}, - {"slug": "msx2", "name": "MSX2"}, - {"slug": "msx", "name": "MSX"}, - {"slug": "colecovision", "name": "ColecoVision"}, - {"slug": "intellivision", "name": "Intellivision"}, - {"slug": "vic-20", "name": "Commodore VIC-20"}, - {"slug": "zxs", "name": "ZX Spectrum"}, - {"slug": "arcadia-2001", "name": "Arcadia 2001"}, - {"slug": "fm-7", "name": "FM-7"}, - {"slug": "trs-80", "name": "TRS-80"}, - {"slug": "epoch-cassette-vision", "name": "Epoch Cassette Vision"}, - {"slug": "dos", "name": "DOS"}, - {"slug": "ti-99", "name": "Texas Instruments TI-99"}, - {"slug": "sinclair-zx81", "name": "Sinclair ZX81"}, - {"slug": "pc-8800-series", "name": "PC-8800 Series"}, - {"slug": "microvision--1", "name": "Microvision"}, - {"slug": "g-and-w", "name": "Game & Watch"}, - {"slug": "atari8bit", "name": "Atari 8-bit"}, - {"slug": "trs-80-color-computer", "name": "TRS-80 Color Computer"}, + {"name": "TurboGrafx-16/PC Engine", "slug": "turbografx16--1"}, + {"name": "Virtual Console", "slug": "vc"}, + {"name": "VC 4000", "slug": "vc-4000"}, + {"name": "Vectrex", "slug": "vectrex"}, + {"name": "Commodore VIC-20", "slug": "vic-20"}, + {"name": "Virtual Boy", "slug": "virtualboy"}, + {"name": "visionOS", "slug": "visionos"}, { - "slug": "1292-advanced-programmable-video-system", - "name": "1292 Advanced Programmable Video System", + "name": "Visual Memory Unit / Visual Memory System", + "slug": "visual-memory-unit-slash-visual-memory-system", }, - {"slug": "odyssey-2-slash-videopac-g7000", "name": "Odyssey 2 / Videopac G7000"}, - {"slug": "exidy-sorcerer", "name": "Exidy Sorcerer"}, - {"slug": "pc-50x-family", "name": "PC-50X Family"}, - {"slug": "vc-4000", "name": "VC 4000"}, - {"slug": "appleii", "name": "Apple II"}, - {"slug": "astrocade", "name": "Bally Astrocade"}, - {"slug": "ay-3-8500", "name": "AY-3-8500"}, - {"slug": "cpet", "name": "Commodore PET"}, - {"slug": "fairchild-channel-f", "name": "Fairchild Channel F"}, - {"slug": "ay-3-8610", "name": "AY-3-8610"}, - {"slug": "ay-3-8605", "name": "AY-3-8605"}, - {"slug": "ay-3-8603", "name": "AY-3-8603"}, - {"slug": "ay-3-8710", "name": "AY-3-8710"}, - {"slug": "ay-3-8760", "name": "AY-3-8760"}, - {"slug": "ay-3-8606", "name": "AY-3-8606"}, - {"slug": "ay-3-8607", "name": "AY-3-8607"}, - {"slug": "sol-20", "name": "Sol-20"}, - {"slug": "odyssey--1", "name": "Odyssey"}, - {"slug": "plato--1", "name": "PLATO"}, - {"slug": "cdccyber70", "name": "CDC Cyber 70"}, - {"slug": "sdssigma7", "name": "SDS Sigma 7"}, - {"slug": "pdp11", "name": "PDP-11"}, - {"slug": "hp2100", "name": "HP 2100"}, - {"slug": "pdp10", "name": "PDP-10"}, + {"name": "V.Smile", "slug": "vsmile"}, { - "slug": "call-a-computer", - "name": "Call-A-Computer time-shared mainframe computer system", + "name": "Watara/QuickShot Supervision", + "slug": "watara-slash-quickshot-supervision", }, - {"slug": "pdp-8--1", "name": "PDP-8"}, - {"slug": "nintendo-playstation", "name": "Nintendo PlayStation"}, - {"slug": "pdp1", "name": "PDP-1"}, - {"slug": "donner30", "name": "Donner Model 30"}, - {"slug": "edsac--1", "name": "EDSAC"}, - {"slug": "nimrod", "name": "Ferranti Nimrod Computer"}, - {"slug": "swancrystal", "name": "SwanCrystal"}, - {"slug": "panasonic-jungle", "name": "Panasonic Jungle"}, - {"slug": "handheld-electronic-lcd", "name": "Handheld Electronic LCD"}, - {"slug": "intellivision-amico", "name": "Intellivision Amico"}, - {"slug": "legacy-computer", "name": "Legacy Computer"}, - {"slug": "panasonic-m2", "name": "Panasonic M2"}, - {"slug": "browser", "name": "Web browser"}, - {"slug": "ooparts", "name": "OOParts"}, - {"slug": "stadia", "name": "Google Stadia"}, - {"slug": "plug-and-play", "name": "Plug & Play"}, - {"slug": "amazon-fire-tv", "name": "Amazon Fire TV"}, - {"slug": "onlive-game-system", "name": "OnLive Game System"}, - {"slug": "vc", "name": "Virtual Console"}, - {"slug": "airconsole", "name": "AirConsole"}, + {"name": "Wii", "slug": "wii"}, + {"name": "Wii U", "slug": "wiiu"}, + {"name": "PC (Microsoft Windows)", "slug": "win"}, + {"name": "Windows Mobile", "slug": "windows-mobile"}, + {"name": "Windows Phone", "slug": "winphone"}, + {"name": "WonderSwan", "slug": "wonderswan"}, + {"name": "WonderSwan Color", "slug": "wonderswan-color"}, + {"name": "Sharp X1", "slug": "x1"}, + {"name": "Xbox", "slug": "xbox"}, + {"name": "Xbox 360", "slug": "xbox360"}, + {"name": "Xbox One", "slug": "xboxone"}, + {"name": "Zeebo", "slug": "zeebo"}, + {"name": "Tapwave Zodiac", "slug": "zod"}, + {"name": "ZX Spectrum", "slug": "zxs"}, ) IGDB_PLATFORM_CATEGORIES: dict[int, str] = { diff --git a/backend/handler/metadata/moby_handler.py b/backend/handler/metadata/moby_handler.py index 237280db8..e30278b37 100644 --- a/backend/handler/metadata/moby_handler.py +++ b/backend/handler/metadata/moby_handler.py @@ -79,8 +79,8 @@ def extract_metadata_from_moby_rom(rom: dict) -> MobyMetadata: class MobyGamesHandler(MetadataHandler): def __init__(self) -> None: self.BASE_URL = "https://api.mobygames.com/v1" - self.platform_url = f"{self.BASE_URL}/platforms" - self.games_url = f"{self.BASE_URL}/games" + self.platform_endpoint = f"{self.BASE_URL}/platforms" + self.games_endpoint = f"{self.BASE_URL}/games" async def _request(self, url: str, timeout: int = 120) -> dict: httpx_client = ctx_httpx_client.get() @@ -148,7 +148,7 @@ async def _search_rom(self, search_term: str, platform_moby_id: int) -> dict | N return None search_term = uc(search_term) - url = yarl.URL(self.games_url).with_query( + url = yarl.URL(self.games_endpoint).with_query( platform=[platform_moby_id], title=quote(search_term, safe="/ "), ) @@ -283,7 +283,7 @@ async def get_rom_by_id(self, moby_id: int) -> MobyGamesRom: if not MOBY_API_ENABLED: return MobyGamesRom(moby_id=None) - url = yarl.URL(self.games_url).with_query(id=moby_id) + url = yarl.URL(self.games_endpoint).with_query(id=moby_id) roms = (await self._request(str(url))).get("games", []) res = pydash.get(roms, "[0]", None) @@ -319,7 +319,7 @@ async def get_matched_roms_by_name( return [] search_term = uc(search_term) - url = yarl.URL(self.games_url).with_query( + url = yarl.URL(self.games_endpoint).with_query( platform=[platform_moby_id], title=quote(search_term, safe="/ ") ) matched_roms = (await self._request(str(url))).get("games", []) diff --git a/backend/handler/metadata/ss_handler.py b/backend/handler/metadata/ss_handler.py new file mode 100644 index 000000000..488758a61 --- /dev/null +++ b/backend/handler/metadata/ss_handler.py @@ -0,0 +1,665 @@ +import asyncio +import base64 +import http +import re +from typing import Final, NotRequired, TypedDict +from urllib.parse import quote + +import httpx +import pydash +import yarl +from config import SCREENSCRAPER_PASSWORD, SCREENSCRAPER_USER +from fastapi import HTTPException, status +from logger.logger import log +from unidecode import unidecode as uc +from utils.context import ctx_httpx_client + +from .base_hander import ( + PS2_OPL_REGEX, + SONY_SERIAL_REGEX, + SWITCH_PRODUCT_ID_REGEX, + SWITCH_TITLEDB_REGEX, + MetadataHandler, +) + +# Used to display the Screenscraper API status in the frontend +SS_API_ENABLED: Final = bool(SCREENSCRAPER_USER) and bool(SCREENSCRAPER_PASSWORD) +SS_DEV_ID: Final = base64.b64decode("enVyZGkxNQ==").decode() +SS_DEV_PASSWORD: Final = base64.b64decode("eFRKd29PRmpPUUc=").decode() + +PS1_SS_ID: Final = 57 +PS2_SS_ID: Final = 58 +PSP_SS_ID: Final = 61 +SWITCH_SS_ID: Final = 225 +ARCADE_SS_IDS: Final = [ + 6, + 7, + 8, + 47, + 49, + 52, + 53, + 54, + 55, + 56, + 68, + 69, + 75, + 112, + 142, + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 173, + 174, + 175, + 176, + 177, + 178, + 179, + 180, + 181, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 195, + 196, + 209, + 227, + 130, + 158, + 269, +] + + +class SSGamesPlatform(TypedDict): + slug: str + ss_id: int | None + name: NotRequired[str] + + +class SSMetadataPlatform(TypedDict): + ss_id: int + name: str + + +class SSMetadata(TypedDict): + ss_score: str + genres: list[str] + alternate_titles: list[str] + platforms: list[SSMetadataPlatform] + + +class SSGamesRom(TypedDict): + ss_id: int | None + slug: NotRequired[str] + name: NotRequired[str] + summary: NotRequired[str] + url_cover: NotRequired[str] + url_manual: NotRequired[str] + url_screenshots: NotRequired[list[str]] + ss_metadata: NotRequired[SSMetadata] + + +def extract_metadata_from_ss_rom(rom: dict) -> SSMetadata: + return SSMetadata( + { + "ss_score": "", + "genres": [], + "alternate_titles": [], + "platforms": [], + } + ) + + +class SSBaseHandler(MetadataHandler): + def __init__(self) -> None: + self.BASE_URL = "https://api.screenscraper.fr/api2" + self.search_endpoint = f"{self.BASE_URL}/jeuRecherche.php" + self.platform_endpoint = f"{self.BASE_URL}/systemesListe.php" + self.games_endpoint = f"{self.BASE_URL}/jeuInfos.php" + self.LOGIN_ERROR_CHECK: Final = "Erreur de login" + self.NO_GAME_ERROR: Final = "Erreur : Jeu non trouvée !" + + @staticmethod + def _extract_value_by_region(data_list, key, target_value): + """Extract the first matching value by region.""" + for item in data_list: + if item.get("region") == target_value: + return item.get(key, "") + return "" + + @staticmethod + def _extract_value_by_language(data_list, key, target_language): + """Extract the first matching value by language.""" + for item in data_list: + if item.get("langue") == target_language: + return item.get(key, "") + return "" + + @staticmethod + def _extract_box2d_cover_url(data_list): + """Extract the first matching cover URL.""" + for item in data_list: + if ( + item.get("region") == "us" + and item.get("type") == "box-2D" + and item.get("parent") == "jeu" + ): + return item.get("url", "") + return "" + + @staticmethod + def _extract_manual_url(data_list): + for item in data_list: + if ( + item.get("type") == "manuel" + and item.get("region") == "us" + and item.get("parent") == "jeu" + and item.get("format") == "pdf" + ): + return item.get("url", "") + return "" + + async def _request(self, url: str, timeout: int = 120) -> dict: + httpx_client = ctx_httpx_client.get() + authorized_url = yarl.URL(url).update_query( + ssid=SCREENSCRAPER_USER, + sspassword=SCREENSCRAPER_PASSWORD, + devid=SS_DEV_ID, + devpassword=SS_DEV_PASSWORD, + softname="romm", + output="json", + ) + masked_url = authorized_url.with_query( + self._mask_sensitive_values(dict(authorized_url.query)) + ) + + log.debug( + "API request: URL=%s, Timeout=%s", + masked_url, + timeout, + ) + + try: + res = await httpx_client.get(str(authorized_url), timeout=timeout) + res.raise_for_status() + if self.LOGIN_ERROR_CHECK in res.text: + log.error("Invalid screenscraper credentials") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid screenscraper credentials", + ) + elif self.NO_GAME_ERROR in res.text: + return {} + return res.json() + except httpx.NetworkError as exc: + log.critical( + "Connection error: can't connect to Screenscrapper", exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Can't connect to Screenscrapper, check your internet connection", + ) from exc + except httpx.HTTPStatusError as err: + if err.response.status_code == http.HTTPStatus.UNAUTHORIZED: + # Sometimes Screenscrapper returns 401 even with a valid API key + log.error(err) + return {} + elif err.response.status_code == http.HTTPStatus.TOO_MANY_REQUESTS: + # Retry after 2 seconds if rate limit hit + await asyncio.sleep(2) + else: + # Log the error and return an empty dict if the request fails with a different code + log.error(err) + return {} + except httpx.TimeoutException: + log.debug( + "Request to URL=%s timed out. Retrying with URL=%s", masked_url, url + ) + # Retry the request once if it times out + try: + log.debug( + "API request: URL=%s, Timeout=%s", + url, + timeout, + ) + res = await httpx_client.get(url, timeout=timeout) + res.raise_for_status() + if self.LOGIN_ERROR_CHECK in res.text: + log.error("Invalid screenscraper credentials") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid screenscraper credentials", + ) + elif self.NO_GAME_ERROR in res.text: + return {} + except (httpx.HTTPStatusError, httpx.TimeoutException) as err: + # Log the error and return an empty dict if the request fails with a different code + log.error(err) + return {} + + return res.json() + + async def _search_rom(self, search_term: str, platform_ss_id: int) -> dict | None: + if not platform_ss_id: + return None + + search_term = uc(search_term) + url = yarl.URL(self.search_endpoint).with_query( + systemeid=[platform_ss_id], + recherche=quote(search_term, safe="/ "), + ) + found_roms = (await self._request(str(url))).get("response", {}).get("jeux", []) + # If no roms are return, "jeux" is list with an empty dict that can lead to issues. It needs to be checked. + roms = [] if len(found_roms) == 1 and not found_roms[0] else found_roms + return pydash.get(roms, "[0]", None) + + def get_platform(self, slug: str) -> SSGamesPlatform: + platform = SLUG_TO_SS_ID.get(slug, None) + + if not platform: + return SSGamesPlatform(ss_id=None, slug=slug) + + return SSGamesPlatform( + ss_id=platform["id"], + slug=slug, + name=platform["name"], + ) + + async def get_rom(self, file_name: str, platform_ss_id: int) -> SSGamesRom: + from handler.filesystem import fs_rom_handler + + if not SS_API_ENABLED: + return SSGamesRom(ss_id=None) + + if not platform_ss_id: + return SSGamesRom(ss_id=None) + + search_term = fs_rom_handler.get_file_name_with_no_tags(file_name) + fallback_rom = SSGamesRom(ss_id=None) + + # Support for PS2 OPL filename format + match = PS2_OPL_REGEX.match(file_name) + if platform_ss_id == PS2_SS_ID and match: + search_term = await self._ps2_opl_format(match, search_term) + fallback_rom = SSGamesRom(ss_id=None, name=search_term) + + # Support for sony serial filename format (PS, PS3, PS3) + match = SONY_SERIAL_REGEX.search(file_name, re.IGNORECASE) + if platform_ss_id == PS1_SS_ID and match: + search_term = await self._ps1_serial_format(match, search_term) + fallback_rom = SSGamesRom(ss_id=None, name=search_term) + + if platform_ss_id == PS2_SS_ID and match: + search_term = await self._ps2_serial_format(match, search_term) + fallback_rom = SSGamesRom(ss_id=None, name=search_term) + + if platform_ss_id == PSP_SS_ID and match: + search_term = await self._psp_serial_format(match, search_term) + fallback_rom = SSGamesRom(ss_id=None, name=search_term) + + # Support for switch titleID filename format + match = SWITCH_TITLEDB_REGEX.search(file_name) + if platform_ss_id == SWITCH_SS_ID and match: + search_term, index_entry = await self._switch_titledb_format( + match, search_term + ) + if index_entry: + fallback_rom = SSGamesRom( + ss_id=None, + name=index_entry["name"], + summary=index_entry.get("description", ""), + url_cover=index_entry.get("iconUrl", ""), + url_manual=index_entry.get("iconUrl", ""), + url_screenshots=index_entry.get("screenshots", None) or [], + ) + + # Support for switch productID filename format + match = SWITCH_PRODUCT_ID_REGEX.search(file_name) + if platform_ss_id == SWITCH_SS_ID and match: + search_term, index_entry = await self._switch_productid_format( + match, search_term + ) + if index_entry: + fallback_rom = SSGamesRom( + ss_id=None, + name=index_entry["name"], + summary=index_entry.get("description", ""), + url_cover=index_entry.get("iconUrl", ""), + url_manual=index_entry.get("iconUrl", ""), + url_screenshots=index_entry.get("screenshots", None) or [], + ) + + # Support for MAME arcade filename format + if platform_ss_id in ARCADE_SS_IDS: + search_term = await self._mame_format(search_term) + fallback_rom = SSGamesRom(ss_id=None, name=search_term) + + search_term = self.normalize_search_term(search_term) + res = await self._search_rom(search_term, platform_ss_id) + + # Some MAME games have two titles split by a slash + if not res and "/" in search_term: + for term in search_term.split("/"): + res = await self._search_rom(term.strip(), platform_ss_id) + if res: + break + + if not res or not res.get("id", None): + return fallback_rom + + ss_id: int = int(res.get("id", None)) + + rom = { + "ss_id": ss_id, + "name": self._extract_value_by_region(res.get("noms", []), "text", "ss"), + "slug": self._extract_value_by_region(res.get("noms", []), "text", "ss"), + "summary": self._extract_value_by_language( + res.get("synopsis", []), "text", "en" + ), + "url_cover": self._extract_box2d_cover_url(res.get("medias", [])), + "url_manual": self._extract_manual_url(res.get("medias", [])), + "url_screenshots": [], + "ss_metadata": extract_metadata_from_ss_rom(res), + } + + return SSGamesRom({k: v for k, v in rom.items() if v}) # type: ignore[misc] + + async def get_rom_by_id(self, ss_id: int) -> SSGamesRom: + if not SS_API_ENABLED: + return SSGamesRom(ss_id=None) + + url = yarl.URL(self.games_endpoint).with_query(gameid=ss_id) + res = (await self._request(str(url))).get("response", {}).get("jeu", []) + + if not res: + return SSGamesRom(ss_id=None) + + rom = { + "ss_id": res.get("id"), + "name": self._extract_value_by_region(res.get("noms", []), "text", "ss"), + "slug": self._extract_value_by_region(res.get("noms", []), "text", "ss"), + "summary": self._extract_value_by_language( + res.get("synopsis", []), "text", "en" + ), + "url_cover": self._extract_box2d_cover_url(res.get("medias", [])), + "url_manual": self._extract_manual_url(res.get("medias", [])), + "url_screenshots": [], + "ss_metadata": extract_metadata_from_ss_rom(res), + } + + return SSGamesRom({k: v for k, v in rom.items() if v}) # type: ignore[misc] + + async def get_matched_rom_by_id(self, ss_id: int) -> SSGamesRom | None: + if not SS_API_ENABLED: + return None + + rom = await self.get_rom_by_id(ss_id) + return rom if rom.get("ss_id", "") else None + + async def get_matched_roms_by_name( + self, search_term: str, platform_ss_id: int + ) -> list[SSGamesRom]: + if not SS_API_ENABLED: + return [] + + if not platform_ss_id: + return [] + + search_term = uc(search_term) + url = yarl.URL(self.search_endpoint).with_query( + systemeid=[platform_ss_id], + recherche=quote(search_term, safe="/ "), + ) + roms = (await self._request(str(url))).get("response", {}).get("jeux", []) + # If no roms are return, "jeux" is list with an empty dict that can lead to issues. It needs to be checked. + matched_roms = [] if len(roms) == 1 and not roms[0] else roms + + return [ + SSGamesRom( # type: ignore[misc] + { + k: v + for k, v in { + "ss_id": rom.get("id"), + "name": self._extract_value_by_region( + rom.get("noms", []), "text", "ss" + ), + "slug": self._extract_value_by_region( + rom.get("noms", []), "text", "ss" + ), + "summary": self._extract_value_by_language( + rom.get("synopsis", []), "text", "en" + ), + "url_cover": self._extract_box2d_cover_url( + rom.get("medias", []) + ), + "url_manual": self._extract_manual_url(rom.get("medias", [])), + "url_screenshots": [], + "ss_metadata": extract_metadata_from_ss_rom(rom), + }.items() + if v + and self._extract_value_by_region(rom.get("noms", []), "text", "ss") + and rom.get("id", None) + } + ) + for rom in matched_roms + ] + + +class SlugToSSId(TypedDict): + id: int + name: str + + +SLUG_TO_SS_ID: dict[str, SlugToSSId] = { + "3do": {"id": 29, "name": "3DO"}, + "amiga": {"id": 64, "name": "Amiga"}, + "amiga-cd32": {"id": 134, "name": "Amiga CD"}, + "cpc": {"id": 60, "name": "CPC"}, + "acpc": {"id": 60, "name": "CPC"}, # IGDB + "android": {"id": 63, "name": "Android"}, + "apple2": {"id": 86, "name": "Apple II"}, + "appleii": {"id": 86, "name": "Apple II"}, # IGDB + "apple2gs": {"id": 217, "name": "Apple IIGS"}, + "apple-iigs": {"id": 51, "name": "Apple IIGS"}, # IGDB + "arcadia-2001": {"id": 94, "name": "Arcadia 2001"}, + "arduboy": {"id": 263, "name": "Arduboy"}, + "atari-2600": {"id": 26, "name": "Atari 2600"}, + "atari2600": {"id": 26, "name": "Atari 2600"}, # IGDB + "atari-5200": {"id": 40, "name": "Atari 5200"}, + "atari5200": {"id": 40, "name": "Atari 5200"}, # IGDB + "atari-7800": {"id": 41, "name": "Atari 7800"}, + "atari7800": {"id": 41, "name": "Atari 7800"}, # IGDB + "atari-8-bit": {"id": 43, "name": "Atari 8bit"}, + "atari8bit": {"id": 43, "name": "Atari 8bit"}, # IGDB + "atari-st": {"id": 42, "name": "Atari ST"}, + "atom": {"id": 36, "name": "Atom"}, + "bbc-micro": {"id": 37, "name": "BBC Micro"}, + "bbcmicro": {"id": 37, "name": "BBC Micro"}, # IGDB + "bally-astrocade": {"id": 44, "name": "Astrocade"}, + "astrocade": {"id": 44, "name": "Astrocade"}, # IGDB + "cd-i": {"id": 133, "name": "CD-i"}, + "philips-cd-i": {"id": 133, "name": "CD-i"}, # IGDB + "cdtv": {"id": 129, "name": "Amiga CDTV"}, + "commodore-cdtv": {"id": 129, "name": "Amiga CDTV"}, # IGDB + "camputers-lynx": {"id": 88, "name": "Camputers Lynx"}, + "casio-loopy": {"id": 98, "name": "Loopy"}, + "casio-pv-1000": {"id": 74, "name": "PV-1000"}, + "channel-f": {"id": 80, "name": "Channel F"}, + "fairchild-channel-f": {"id": 80, "name": "Channel F"}, # IGDB + "colecoadam": {"id": 89, "name": "Adam"}, + "colecovision": {"id": 48, "name": "Colecovision"}, + "colour-genie": {"id": 92, "name": "EG2000 Colour Genie"}, + "c128": {"id": 66, "name": "Commodore 64"}, + "commodore-16-plus4": {"id": 99, "name": "Plus/4"}, + "c-plus-4": {"id": 99, "name": "Plus/4"}, # IGDB + "c16": {"id": 99, "name": "Plus/4"}, # IGDB + "c64": {"id": 66, "name": "Commodore 64"}, + "pet": {"id": 240, "name": "PET"}, + "cpet": {"id": 240, "name": "PET"}, # IGDB + "creativision": {"id": 241, "name": "CreatiVision"}, + "dos": {"id": 135, "name": "PC Dos"}, + "dragon-3264": {"id": 91, "name": "Dragon 32/64"}, + "dragon-32-slash-64": {"id": 91, "name": "Dragon 32/64"}, # IGDB + "dreamcast": {"id": 23, "name": "Dreamcast"}, + "dc": {"id": 23, "name": "Dreamcast"}, # IGDB + "electron": {"id": 85, "name": "Electron"}, + "acorn-electron": {"id": 85, "name": "Electron"}, # IGDB + "epoch-game-pocket-computer": {"id": 95, "name": "Game Pocket Computer"}, + "epoch-super-cassette-vision": {"id": 67, "name": "Super Cassette Vision"}, + "exelvision": {"id": 96, "name": "EXL 100"}, + "exidy-sorcerer": {"id": 165, "name": "Exidy"}, + "fmtowns": {"id": 253, "name": "FM Towns"}, + "fm-towns": {"id": 253, "name": "FM Towns"}, # IGDB + "fm-7": {"id": 97, "name": "FM-7"}, + "g-and-w": {"id": 52, "name": "Game & Watch"}, # IGDB (Game & Watch) + "gp32": {"id": 101, "name": "GP32"}, + "gameboy": {"id": 9, "name": "Game Boy"}, + "gb": {"id": 9, "name": "Game Boy"}, # IGDB + "gameboy-advance": {"id": 12, "name": "Game Boy Advance"}, + "gba": {"id": 12, "name": "Game Boy Advance"}, # IGDB + "gameboy-color": {"id": 10, "name": "Game Boy Color"}, + "gbc": {"id": 10, "name": "Game Boy Color"}, # IGDB + "game-gear": {"id": 21, "name": "Game Gear"}, + "gamegear": {"id": 21, "name": "Game Gear"}, # IGDB + "game-com": {"id": 121, "name": "Game.com"}, + "game-dot-com": {"id": 121, "name": "Game.com"}, # IGDB + "gamecube": {"id": 13, "name": "GameCube"}, + "ngc": {"id": 13, "name": "GameCube"}, # IGDB + "genesis": {"id": 1, "name": "Megadrive"}, + "genesis-slash-megadrive": {"id": 1, "name": "Megadrive"}, + "intellivision": {"id": 115, "name": "Intellivision"}, + "jaguar": {"id": 27, "name": "Jaguar"}, + "jupiter-ace": {"id": 126, "name": "Jupiter Ace"}, + "linux": {"id": 145, "name": "Linux"}, + "lynx": {"id": 28, "name": "Lynx"}, + "msx": {"id": 113, "name": "MSX"}, + "macintosh": {"id": 146, "name": "Mac OS"}, + "mac": {"id": 146, "name": "Mac OS"}, # IGDB + "ngage": {"id": 30, "name": "N-Gage"}, + "nes": {"id": 3, "name": "NES"}, + "famicom": {"id": 3, "name": "NES"}, + "neo-geo": {"id": 142, "name": "Neo-Geo"}, + "neogeoaes": {"id": 142, "name": "Neo-Geo"}, # IGDB + "neogeomvs": {"id": 68, "name": "Neo-Geo MVS"}, # IGDB + "neo-geo-cd": {"id": 70, "name": "Neo-Geo CD"}, + "neo-geo-pocket": {"id": 25, "name": "Neo-Geo Pocket"}, + "neo-geo-pocket-color": {"id": 82, "name": "Neo-Geo Pocket Color"}, + "3ds": {"id": 17, "name": "Nintendo 3DS"}, + "n64": {"id": 14, "name": "Nintendo 64"}, + "nintendo-ds": {"id": 15, "name": "Nintendo DS"}, + "nds": {"id": 15, "name": "Nintendo DS"}, # IGDB + "nintendo-dsi": {"id": 15, "name": "Nintendo DS"}, + "switch": {"id": 225, "name": "Switch"}, + "odyssey-2": {"id": 104, "name": "Videopac G7000"}, + "odyssey-2-slash-videopac-g7000": {"id": 104, "name": "Videopac G7000"}, + "oric": {"id": 131, "name": "Oric 1 / Atmos"}, + "pc88": {"id": 221, "name": "NEC PC-8801"}, + "pc-8800-series": {"id": 221, "name": "NEC PC-8801"}, # IGDB + "pc98": {"id": 208, "name": "NEC PC-9801"}, + "pc-9800-series": {"id": 208, "name": "NEC PC-9801"}, # IGDB + "pc-fx": {"id": 72, "name": "PC-FX"}, + "pico": {"id": 234, "name": "Pico-8"}, + "ps-vita": {"id": 62, "name": "PS Vita"}, + "psvita": {"id": 62, "name": "PS Vita"}, # IGDB + "psp": {"id": 61, "name": "PSP"}, + "palmos": {"id": 219, "name": "Palm OS"}, + "palm-os": {"id": 219, "name": "Palm OS"}, # IGDB + "philips-vg-5000": {"id": 261, "name": "Philips VG 5000"}, + "playstation": {"id": 57, "name": "Playstation"}, + "ps": {"id": 57, "name": "Playstation"}, # IGDB + "ps2": {"id": 58, "name": "Playstation 2"}, + "ps3": {"id": 59, "name": "Playstation 3"}, + "playstation-4": {"id": 60, "name": "Playstation 4"}, + "ps4--1": {"id": 60, "name": "Playstation 4"}, # IGDB + "playstation-5": {"id": 284, "name": "Playstation 5"}, + "ps5": {"id": 284, "name": "Playstation 5"}, # IGDB + "pokemon-mini": {"id": 211, "name": "Pokémon mini"}, + "sam-coupe": {"id": 213, "name": "MGT SAM Coupé"}, + "sega-32x": {"id": 19, "name": "Megadrive 32X"}, + "sega32": {"id": 19, "name": "Megadrive 32X"}, # IGDB + "sega-cd": {"id": 20, "name": "Mega-CD"}, + "segacd": {"id": 20, "name": "Mega-CD"}, # IGDB + "sega-master-system": {"id": 2, "name": "Master System"}, + "sms": {"id": 2, "name": "Master System"}, # IGDB + "sega-pico": {"id": 250, "name": "Sega Pico"}, + "sega-saturn": {"id": 22, "name": "Saturn"}, + "saturn": {"id": 22, "name": "Saturn"}, # IGDB + "sg-1000": {"id": 109, "name": "SG-1000"}, + "snes": {"id": 4, "name": "Super Nintendo"}, + "sharp-x1": {"id": 220, "name": "Sharp X1"}, + "x1": {"id": 220, "name": "Sharp X1"}, # IGDB + "sharp-x68000": {"id": 79, "name": "Sharp X68000"}, + "spectravideo": {"id": 218, "name": "Spectravideo"}, + "super-acan": {"id": 100, "name": "Super A'can"}, + "supergrafx": {"id": 105, "name": "PC Engine SuperGrafx"}, + "supervision": {"id": 207, "name": "Watara Supervision"}, + "ti-99": {"id": 205, "name": "TI-99/4A"}, # IGDB + "trs-80-coco": {"id": 144, "name": "TRS-80 Color Computer"}, + "trs-80-color-computer": {"id": 144, "name": "TRS-80 Color Computer"}, # IGDB + "taito-x-55": {"id": 112, "name": "Type X"}, + "thomson-mo": {"id": 141, "name": "Thomson MO/TO"}, + "thomson-mo5": {"id": 141, "name": "Thomson MO/TO"}, + "thomson-to": {"id": 141, "name": "Thomson MO/TO"}, + "turbografx-cd": {"id": 114, "name": "PC Engine CD-Rom"}, + "turbografx-16-slash-pc-engine-cd": {"id": 114, "name": "PC Engine CD-Rom"}, + "turbo-grafx": {"id": 31, "name": "PC Engine"}, + "turbografx16--1": {"id": 31, "name": "PC Engine"}, # IGDB + "vsmile": {"id": 120, "name": "V.Smile"}, + "vic-20": {"id": 73, "name": "Vic-20"}, + "vectrex": {"id": 102, "name": "Vectrex"}, + "videopac-g7400": {"id": 104, "name": "Videopac G7000"}, + "virtual-boy": {"id": 11, "name": "Virtual Boy"}, + "virtualboy": {"id": 11, "name": "Virtual Boy"}, + "wii": {"id": 18, "name": "Wii"}, + "wii-u": {"id": 18, "name": "Wii U"}, + "wiiu": {"id": 18, "name": "Wii U"}, + "windows": {"id": 3, "name": "Windows"}, + "win": {"id": 138, "name": "PC Windows"}, # IGDB + "win3x": {"id": 136, "name": "PC Win3.xx"}, + "wonderswan": {"id": 45, "name": "WonderSwan"}, + "wonderswan-color": {"id": 46, "name": "WonderSwan Color"}, + "xbox": {"id": 32, "name": "Xbox"}, + "xbox360": {"id": 33, "name": "Xbox 360"}, + "xbox-one": {"id": 34, "name": "Xbox One"}, + "xboxone": {"id": 34, "name": "Xbox One"}, + "z-machine": {"id": 215, "name": "Z-Machine"}, + "zx-spectrum": {"id": 76, "name": "ZX Spectrum"}, + "zx81": {"id": 77, "name": "ZX81"}, + "sinclair-zx81": {"id": 77, "name": "ZX81"}, # IGDB +} + +# Reverse lookup +SS_ID_TO_SLUG = {v["id"]: k for k, v in SLUG_TO_SS_ID.items()} diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 17a1f7007..abcbc3b6c 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -7,9 +7,10 @@ from handler.database import db_platform_handler from handler.filesystem import fs_asset_handler, fs_firmware_handler, fs_rom_handler from handler.filesystem.roms_handler import FSRom -from handler.metadata import meta_igdb_handler, meta_moby_handler +from handler.metadata import meta_igdb_handler, meta_moby_handler, meta_ss_handler from handler.metadata.igdb_handler import IGDBPlatform, IGDBRom from handler.metadata.moby_handler import MobyGamesPlatform, MobyGamesRom +from handler.metadata.ss_handler import SSGamesPlatform, SSGamesRom from logger.formatter import BLUE from logger.formatter import highlight as hl from logger.logger import log @@ -29,6 +30,12 @@ class ScanType(Enum): HASHES = "hashes" +class MetadataSource: + IGDB = "igdb" + MOBY = "moby" + SS = "ss" + + async def _get_main_platform_igdb_id(platform: Platform): cnfg = cm.get_config() @@ -64,7 +71,7 @@ async def scan_platform( log.info(f"· {hl(fs_slug)}") if metadata_sources is None: - metadata_sources = ["igdb", "moby"] + metadata_sources = [MetadataSource.IGDB, MetadataSource.MOBY, MetadataSource.SS] platform_attrs: dict[str, Any] = {} platform_attrs["fs_slug"] = fs_slug @@ -92,19 +99,30 @@ async def scan_platform( igdb_platform = ( (await meta_igdb_handler.get_platform(platform_attrs["slug"])) - if "igdb" in metadata_sources + if MetadataSource.IGDB in metadata_sources else IGDBPlatform(igdb_id=None, slug=platform_attrs["slug"]) ) moby_platform = ( meta_moby_handler.get_platform(platform_attrs["slug"]) - if "moby" in metadata_sources + if MetadataSource.MOBY in metadata_sources else MobyGamesPlatform(moby_id=None, slug=platform_attrs["slug"]) ) + ss_platform = ( + meta_ss_handler.get_platform(platform_attrs["slug"]) + if MetadataSource.SS in metadata_sources + else SSGamesPlatform(ss_id=None, slug=platform_attrs["slug"]) + ) platform_attrs["name"] = platform_attrs["slug"].replace("-", " ").title() - platform_attrs.update({**moby_platform, **igdb_platform}) # Reverse order - - if platform_attrs["igdb_id"] or platform_attrs["moby_id"]: + platform_attrs.update( + {**moby_platform, **ss_platform, **igdb_platform} + ) # Reverse order + + if ( + platform_attrs["igdb_id"] + or platform_attrs["moby_id"] + or platform_attrs["ss_id"] + ): log.info( emoji.emojize( f" Identified as {hl(platform_attrs['name'], color=BLUE)} :video_game:" @@ -173,7 +191,7 @@ async def scan_rom( metadata_sources: list[str] | None = None, ) -> Rom: if not metadata_sources: - metadata_sources = ["igdb", "moby"] + metadata_sources = [MetadataSource.IGDB, MetadataSource.MOBY, MetadataSource.SS] roms_path = fs_rom_handler.get_roms_fs_structure(platform.fs_slug) @@ -191,6 +209,7 @@ async def scan_rom( "platform_id": platform.id, "name": fs_rom["fs_name"], "url_cover": "", + "url_manual": "", "url_screenshots": [], } @@ -200,6 +219,7 @@ async def scan_rom( { "igdb_id": rom.igdb_id, "moby_id": rom.moby_id, + "ss_id": rom.ss_id, "sgdb_id": rom.sgdb_id, "name": rom.name, "slug": rom.slug, @@ -207,6 +227,7 @@ async def scan_rom( "igdb_metadata": rom.igdb_metadata, "moby_metadata": rom.moby_metadata, "url_cover": rom.url_cover, + "url_manual": rom.url_manual, "path_cover_s": rom.path_cover_s, "path_cover_l": rom.path_cover_l, "path_screenshots": rom.path_screenshots, @@ -246,7 +267,7 @@ async def scan_rom( async def fetch_igdb_rom(): if ( - "igdb" in metadata_sources + MetadataSource.IGDB in metadata_sources and platform.igdb_id and ( not rom @@ -264,7 +285,7 @@ async def fetch_igdb_rom(): async def fetch_moby_rom(): if ( - "moby" in metadata_sources + MetadataSource.MOBY in metadata_sources and platform.moby_id and ( not rom @@ -279,16 +300,37 @@ async def fetch_moby_rom(): return MobyGamesRom(moby_id=None) + async def fetch_ss_rom(): + if ( + MetadataSource.SS in metadata_sources + and platform.ss_id + and ( + not rom + or scan_type == ScanType.COMPLETE + or (scan_type == ScanType.PARTIAL and not rom.ss_id) + or (scan_type == ScanType.UNIDENTIFIED and not rom.ss_id) + ) + ): + return await meta_ss_handler.get_rom( + rom_attrs["fs_name"], platform_ss_id=platform.ss_id + ) + + return SSGamesRom(ss_id=None) + # Run both metadata fetches concurrently - igdb_handler_rom, moby_handler_rom = await asyncio.gather( - fetch_igdb_rom(), fetch_moby_rom() + igdb_handler_rom, moby_handler_rom, ss_handler_rom = await asyncio.gather( + fetch_igdb_rom(), fetch_moby_rom(), fetch_ss_rom() ) # Reversed to prioritize IGDB - rom_attrs.update({**moby_handler_rom, **igdb_handler_rom}) - - # If not found in IGDB or MobyGames - if not igdb_handler_rom.get("igdb_id") and not moby_handler_rom.get("moby_id"): + rom_attrs.update({**moby_handler_rom, **ss_handler_rom, **igdb_handler_rom}) + + # If not found in IGDB, MobyGames and Screenscraper + if ( + not igdb_handler_rom.get("igdb_id") + and not moby_handler_rom.get("moby_id") + and not ss_handler_rom.get("ss_id") + ): log.warning( emoji.emojize( f"\t Rom {rom_attrs['fs_name']} not identified :cross_mark:" diff --git a/backend/models/platform.py b/backend/models/platform.py index c293e9831..7c1087b5d 100644 --- a/backend/models/platform.py +++ b/backend/models/platform.py @@ -21,6 +21,7 @@ class Platform(BaseModel): igdb_id: Mapped[int | None] sgdb_id: Mapped[int | None] moby_id: Mapped[int | None] + ss_id: Mapped[int | None] 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)) diff --git a/backend/models/rom.py b/backend/models/rom.py index 02d85c07e..77bce6b3c 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -71,10 +71,12 @@ class Rom(BaseModel): igdb_id: Mapped[int | None] sgdb_id: Mapped[int | None] moby_id: Mapped[int | None] + ss_id: Mapped[int | None] __table_args__ = ( Index("idx_roms_igdb_id", "igdb_id"), Index("idx_roms_moby_id", "moby_id"), + Index("idx_roms_ss_id", "ss_id"), ) fs_name: Mapped[str] = mapped_column(String(length=450)) @@ -92,6 +94,9 @@ class Rom(BaseModel): moby_metadata: Mapped[dict[str, Any] | None] = mapped_column( CustomJSON(), default=dict ) + ss_metadata: Mapped[dict[str, Any] | None] = mapped_column( + CustomJSON(), default=dict + ) path_cover_s: Mapped[str | None] = mapped_column(Text, default="") path_cover_l: Mapped[str | None] = mapped_column(Text, default="") @@ -99,6 +104,11 @@ class Rom(BaseModel): Text, default="", doc="URL to cover image stored in IGDB" ) + path_manual: Mapped[str | None] = mapped_column(Text, default="") + url_manual: Mapped[str | None] = mapped_column( + Text, default="", doc="URL to manual stored in ScreenScraper" + ) + 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=[]) @@ -160,6 +170,10 @@ def platform_display_name(self) -> str: def full_path(self) -> str: return f"{self.fs_path}/{self.fs_name}" + @cached_property + def has_manual(self) -> bool: + return bool(self.path_manual) + @cached_property def merged_screenshots(self) -> list[str]: screenshots = [s.download_path for s in self.screenshots] diff --git a/env.template b/env.template index c6705e26d..11ce80528 100644 --- a/env.template +++ b/env.template @@ -3,7 +3,8 @@ DEV_MODE=true KIOSK_MODE=false # Gunicorn (optional) -GUNICORN_WORKERS=4 # (2 × CPU cores) + 1 +# Workers -> (2 × CPU cores) + 1 +GUNICORN_WORKERS=4 # IGDB credentials IGDB_CLIENT_ID= @@ -12,6 +13,10 @@ IGDB_CLIENT_SECRET= # Mobygames MOBYGAMES_API_KEY= +# Screenscraper +SCREENSCRAPER_USER= +SCREENSCRAPER_PASSWORD= + # SteamGridDB STEAMGRIDDB_API_KEY= diff --git a/frontend/assets/auth_background_static.png b/frontend/assets/auth_background_static.png new file mode 100644 index 000000000..92c8838df Binary files /dev/null and b/frontend/assets/auth_background_static.png differ diff --git a/frontend/assets/logos/romm_logo_xbox_one_circle_grayscale.png b/frontend/assets/logos/romm_logo_xbox_one_circle_grayscale.png new file mode 100644 index 000000000..24fe4240c Binary files /dev/null and b/frontend/assets/logos/romm_logo_xbox_one_circle_grayscale.png differ diff --git a/frontend/assets/logos/romm_logo_xbox_one_circle_grayscale.svg b/frontend/assets/logos/romm_logo_xbox_one_circle_grayscale.svg new file mode 100644 index 000000000..0a27cfb4e --- /dev/null +++ b/frontend/assets/logos/romm_logo_xbox_one_circle_grayscale.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/scrappers/sgdb.svg b/frontend/assets/scrappers/sgdb.svg deleted file mode 100644 index b1084e1a4..000000000 --- a/frontend/assets/scrappers/sgdb.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/assets/scrappers/ss.png b/frontend/assets/scrappers/ss.png new file mode 100644 index 000000000..0faf0fe0a Binary files /dev/null and b/frontend/assets/scrappers/ss.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index feddc52e4..d5d8560a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "vue": "^3.4.27", "vue-i18n": "^11.1.1", "vue-router": "^4.3.2", + "vue3-pdf-app": "^1.0.3", "vuetify": "^3.7.9" }, "devDependencies": { @@ -8199,6 +8200,14 @@ "typescript": ">=5.0.0" } }, + "node_modules/vue3-pdf-app": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/vue3-pdf-app/-/vue3-pdf-app-1.0.3.tgz", + "integrity": "sha512-qegWTIF4wYKiocZ3KreB70wRXhqSdXWbdERDyyKzT7d5PbjKbS9tD6vaKkCqh3PzTM84NyKPYrQ3iuwJb60YPQ==", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vuetify": { "version": "3.7.11", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.11.tgz", diff --git a/frontend/package.json b/frontend/package.json index 11fb0b7a4..e4837fb61 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "vue": "^3.4.27", "vue-i18n": "^11.1.1", "vue-router": "^4.3.2", + "vue3-pdf-app": "^1.0.3", "vuetify": "^3.7.9" }, "devDependencies": { diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index ab9bcf1a0..19158bf3b 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -17,6 +17,7 @@ export type DetailedRomSchema = { igdb_id: (number | null); sgdb_id: (number | null); moby_id: (number | null); + ss_id: (number | null); platform_id: number; platform_slug: string; platform_fs_slug: string; @@ -44,9 +45,13 @@ export type DetailedRomSchema = { age_ratings: Array; igdb_metadata: (RomIGDBMetadata | null); moby_metadata: (RomMobyMetadata | null); + ss_metadata: (RomMobyMetadata | null); path_cover_small: (string | null); path_cover_large: (string | null); url_cover: (string | null); + has_manual: boolean; + path_manual: (string | null); + url_manual: (string | null); is_unidentified: boolean; revision: (string | null); regions: Array; diff --git a/frontend/src/__generated__/models/MetadataSourcesDict.ts b/frontend/src/__generated__/models/MetadataSourcesDict.ts index 57dd7c677..881d6fd15 100644 --- a/frontend/src/__generated__/models/MetadataSourcesDict.ts +++ b/frontend/src/__generated__/models/MetadataSourcesDict.ts @@ -6,6 +6,7 @@ export type MetadataSourcesDict = { ANY_SOURCE_ENABLED: boolean; IGDB_API_ENABLED: boolean; MOBY_API_ENABLED: boolean; + SS_API_ENABLED: boolean; STEAMGRIDDB_ENABLED: boolean; }; diff --git a/frontend/src/__generated__/models/PlatformSchema.ts b/frontend/src/__generated__/models/PlatformSchema.ts index 068be4979..11fd8a0e0 100644 --- a/frontend/src/__generated__/models/PlatformSchema.ts +++ b/frontend/src/__generated__/models/PlatformSchema.ts @@ -13,6 +13,7 @@ export type PlatformSchema = { igdb_id?: (number | null); sgdb_id?: (number | null); moby_id?: (number | null); + ss_id?: (number | null); category?: (string | null); generation?: (number | null); family_name?: (string | null); diff --git a/frontend/src/__generated__/models/RomSchema.ts b/frontend/src/__generated__/models/RomSchema.ts index 71de447b5..ad938b338 100644 --- a/frontend/src/__generated__/models/RomSchema.ts +++ b/frontend/src/__generated__/models/RomSchema.ts @@ -10,6 +10,7 @@ export type RomSchema = { igdb_id: (number | null); sgdb_id: (number | null); moby_id: (number | null); + ss_id: (number | null); platform_id: number; platform_slug: string; platform_fs_slug: string; @@ -37,9 +38,13 @@ export type RomSchema = { age_ratings: Array; igdb_metadata: (RomIGDBMetadata | null); moby_metadata: (RomMobyMetadata | null); + ss_metadata: (RomMobyMetadata | null); path_cover_small: (string | null); path_cover_large: (string | null); url_cover: (string | null); + has_manual: boolean; + path_manual: (string | null); + url_manual: (string | null); is_unidentified: boolean; revision: (string | null); regions: Array; diff --git a/frontend/src/__generated__/models/SearchRomSchema.ts b/frontend/src/__generated__/models/SearchRomSchema.ts index ba92277e1..32ccb806f 100644 --- a/frontend/src/__generated__/models/SearchRomSchema.ts +++ b/frontend/src/__generated__/models/SearchRomSchema.ts @@ -5,11 +5,13 @@ export type SearchRomSchema = { igdb_id?: (number | null); moby_id?: (number | null); + ss_id?: (number | null); slug: string; name: string; summary: string; igdb_url_cover?: string; moby_url_cover?: string; + ss_url_cover?: string; platform_id: number; }; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 314c121e8..bfa2cb83f 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -12,6 +12,7 @@ export type SimpleRomSchema = { igdb_id: (number | null); sgdb_id: (number | null); moby_id: (number | null); + ss_id: (number | null); platform_id: number; platform_slug: string; platform_fs_slug: string; @@ -39,9 +40,13 @@ export type SimpleRomSchema = { age_ratings: Array; igdb_metadata: (RomIGDBMetadata | null); moby_metadata: (RomMobyMetadata | null); + ss_metadata: (RomMobyMetadata | null); path_cover_small: (string | null); path_cover_large: (string | null); url_cover: (string | null); + has_manual: boolean; + path_manual: (string | null); + url_manual: (string | null); is_unidentified: boolean; revision: (string | null); regions: Array; diff --git a/frontend/src/components/Details/PDFViewer.vue b/frontend/src/components/Details/PDFViewer.vue new file mode 100644 index 000000000..944bca300 --- /dev/null +++ b/frontend/src/components/Details/PDFViewer.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/frontend/src/components/Details/Title.vue b/frontend/src/components/Details/Title.vue index 5e048fb5a..d71922fde 100644 --- a/frontend/src/components/Details/Title.vue +++ b/frontend/src/components/Details/Title.vue @@ -138,7 +138,7 @@ const hasReleaseDate = Number(props.rom.first_release_date) > 0; 0; :href="`https://www.igdb.com/games/${rom.slug}`" target="_blank" > - - IGDB + + + + + {{ rom.igdb_id }} - ID: {{ rom.igdb_id }} - - Rating: {{ rom.igdb_metadata?.total_rating }} + {{ rom.igdb_metadata?.total_rating }} + mdi-star 0; target="_blank" :class="{ 'ml-1': rom.igdb_id }" > - - Mobygames - - ID: {{ rom.moby_id }} + + + + + {{ rom.moby_id }} - Rating: {{ rom.moby_metadata?.moby_score }} + {{ rom.moby_metadata?.moby_score }} + mdi-star + + + + + + + + {{ rom.ss_id }} diff --git a/frontend/src/components/Gallery/AppBar/Collection/CollectionInfoDrawer.vue b/frontend/src/components/Gallery/AppBar/Collection/CollectionInfoDrawer.vue index 277e20baf..6d05b29e9 100644 --- a/frontend/src/components/Gallery/AppBar/Collection/CollectionInfoDrawer.vue +++ b/frontend/src/components/Gallery/AppBar/Collection/CollectionInfoDrawer.vue @@ -195,7 +195,6 @@ async function updateCollection() { :with-link="false" :collection="currentCollection" :src="imagePreviewUrl" - title-on-hover > + diff --git a/frontend/src/components/common/Game/Dialog/EditRom.vue b/frontend/src/components/common/Game/Dialog/EditRom.vue index 20179a567..213d186cd 100644 --- a/frontend/src/components/common/Game/Dialog/EditRom.vue +++ b/frontend/src/components/common/Game/Dialog/EditRom.vue @@ -6,6 +6,7 @@ import storeGalleryView from "@/stores/galleryView"; import storeHeartbeat from "@/stores/heartbeat"; import storePlatforms from "@/stores/platforms"; import storeRoms, { type SimpleRom } from "@/stores/roms"; +import storeUpload from "@/stores/upload"; import type { Events } from "@/types/emitter"; import type { Emitter } from "mitt"; import { computed, inject, ref } from "vue"; @@ -24,8 +25,10 @@ const rom = ref(); const romsStore = storeRoms(); const imagePreviewUrl = ref(""); const removeCover = ref(false); +const manualFiles = ref([]); const platfotmsStore = storePlatforms(); const galleryViewStore = storeGalleryView(); +const uploadStore = storeUpload(); const emitter = inject>("emitter"); emitter?.on("showEditRomDialog", (romToEdit: UpdateRom | undefined) => { show.value = true; @@ -78,7 +81,7 @@ async function removeArtwork() { } const noMetadataMatch = computed(() => { - return !rom.value?.igdb_id && !rom.value?.moby_id && !rom.value?.sgdb_id; + return !rom.value?.igdb_id && !rom.value?.moby_id && !rom.value?.ss_id; }); async function handleRomUpdate( @@ -120,6 +123,52 @@ async function handleRomUpdate( }); } +async function uploadManuals() { + show.value = false; + if (!rom.value) return; + + await romApi + .uploadManuals({ + romId: rom.value.id, + filesToUpload: manualFiles.value, + }) + .then((responses: PromiseSettledResult[]) => { + const successfulUploads = responses.filter( + (d) => d.status == "fulfilled", + ); + const failedUploads = responses.filter((d) => d.status == "rejected"); + + if (failedUploads.length == 0) { + uploadStore.clearAll(); + } + + if (successfulUploads.length == 0) { + return emitter?.emit("snackbarShow", { + msg: `All manuals skipped, nothing to upload.`, + icon: "mdi-close-circle", + color: "orange", + timeout: 5000, + }); + } + + emitter?.emit("snackbarShow", { + msg: `${successfulUploads.length} manuals uploaded successfully (and ${failedUploads.length} skipped/failed).`, + icon: "mdi-check-bold", + color: "green", + timeout: 3000, + }); + }) + .catch(({ response, message }) => { + emitter?.emit("snackbarShow", { + msg: `Unable to upload manuals: ${response?.data?.detail || response?.statusText || message}`, + icon: "mdi-close-circle", + color: "red", + timeout: 4000, + }); + }); + manualFiles.value = []; +} + async function unmatchRom() { if (!rom.value) return; await handleRomUpdate( @@ -213,6 +262,54 @@ function closeDialog() { /> + + + + {{ t("rom.manual") + }}{{ + rom.has_manual ? "mdi-check" : "mdi-close" + }} + + mdi-upload + + + +
+ + + mdi-folder-file-outline + + /romm/resources/{{ rom.path_manual }} + +
+
+
(null); const romsStore = storeRoms(); const galleryViewStore = storeGalleryView(); +const platfotmsStore = storePlatforms(); const searching = ref(false); const route = useRoute(); const searchTerm = ref(""); @@ -42,9 +44,17 @@ const sources = ref([]); const heartbeat = storeHeartbeat(); const isIGDBFiltered = ref(true); const isMobyFiltered = ref(true); +const isSSFiltered = ref(true); +const computedAspectRatio = computed(() => { + const ratio = + platfotmsStore.getAspectRatio(rom.value?.platform_id ?? -1) || + galleryViewStore.defaultAspectRatioCover; + return parseFloat(ratio.toString()); +}); emitter?.on("showMatchRomDialog", (romToSearch) => { rom.value = romToSearch; show.value = true; + matchedRoms.value = []; // Use name as search term, only when it's matched // Otherwise use the filename without tags and extensions @@ -70,11 +80,17 @@ function toggleSourceFilter(source: MatchedSource["name"]) { heartbeat.value.METADATA_SOURCES.MOBY_API_ENABLED ) { isMobyFiltered.value = !isMobyFiltered.value; + } else if ( + source == "Screenscraper" && + heartbeat.value.METADATA_SOURCES.SS_API_ENABLED + ) { + isSSFiltered.value = !isSSFiltered.value; } filteredMatchedRoms.value = matchedRoms.value.filter((rom) => { if ( (rom.igdb_id && isIGDBFiltered.value) || - (rom.moby_id && isMobyFiltered.value) + (rom.moby_id && isMobyFiltered.value) || + (rom.ss_id && isSSFiltered.value) ) { return true; } @@ -103,7 +119,8 @@ async function searchRom() { filteredMatchedRoms.value = matchedRoms.value.filter((rom) => { if ( (rom.igdb_id && isIGDBFiltered.value) || - (rom.moby_id && isMobyFiltered.value) + (rom.moby_id && isMobyFiltered.value) || + (rom.ss_id && isSSFiltered.value) ) { return true; } @@ -131,16 +148,31 @@ function showSources(matchedRom: SearchRomSchema) { } showSelectSource.value = true; selectedMatchRom.value = matchedRom; - sources.value.push({ - url_cover: matchedRom.igdb_url_cover, - name: "IGDB", - logo_path: "/assets/scrappers/igdb.png", - }); - sources.value.push({ - url_cover: matchedRom.moby_url_cover, - name: "Mobygames", - logo_path: "/assets/scrappers/moby.png", - }); + sources.value = []; + if (matchedRom.igdb_url_cover || matchedRom.igdb_id) { + sources.value.push({ + url_cover: matchedRom.igdb_url_cover, + name: "IGDB", + logo_path: "/assets/scrappers/igdb.png", + }); + } + if (matchedRom.moby_url_cover || matchedRom.moby_id) { + sources.value.push({ + url_cover: matchedRom.moby_url_cover, + name: "Mobygames", + logo_path: "/assets/scrappers/moby.png", + }); + } + if (matchedRom.ss_url_cover || matchedRom.ss_id) { + sources.value.push({ + url_cover: matchedRom.ss_url_cover, + name: "Screenscraper", + logo_path: "/assets/scrappers/ss.png", + }); + } + if (sources.value.length == 1) { + selectedCover.value = sources.value[0]; + } } function selectCover(source: MatchedSource) { @@ -213,7 +245,6 @@ function closeDialog() { selectedCover.value = undefined; selectedMatchRom.value = undefined; renameAsSource.value = false; - matchedRoms.value = []; } onBeforeUnmount(() => { @@ -291,6 +322,34 @@ onBeforeUnmount(() => { > + +