diff --git a/.gitignore b/.gitignore index ac9ce7197..93aad4671 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ romm_mock # testing backend/romm_test/resources backend/romm_test/logs +backend/romm_test/config .pytest_cache # service worker diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index ef2ad7395..9933eeb49 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -36,6 +36,7 @@ class Config: EXCLUDED_MULTI_PARTS_EXT: list[str] EXCLUDED_MULTI_PARTS_FILES: list[str] PLATFORMS_BINDING: dict[str, str] + PLATFORMS_VERSIONS: dict[str, str] ROMS_FOLDER_NAME: str SAVES_FOLDER_NAME: str STATES_FOLDER_NAME: str @@ -128,6 +129,7 @@ class ConfigManager: self._raw_config, "exclude.roms.multi_file.parts.names", [] ), PLATFORMS_BINDING=pydash.get(self._raw_config, "system.platforms", {}), + PLATFORMS_VERSIONS=pydash.get(self._raw_config, "system.versions", {}), ROMS_FOLDER_NAME=pydash.get( self._raw_config, "filesystem.roms_folder", "roms" ), @@ -189,6 +191,17 @@ class ConfigManager: ) sys.exit(3) + if not isinstance(self.config.PLATFORMS_VERSIONS, dict): + log.critical("Invalid config.yml: system.versions must be a dictionary") + sys.exit(3) + else: + for fs_slug, slug in self.config.PLATFORMS_VERSIONS.items(): + if slug is None: + log.critical( + f"Invalid config.yml: system.versions.{fs_slug} must be a string" + ) + sys.exit(3) + if not isinstance(self.config.ROMS_FOLDER_NAME, str): log.critical("Invalid config.yml: filesystem.roms_folder must be a string") sys.exit(3) @@ -276,6 +289,25 @@ class ConfigManager: pass self.update_config() + def add_version(self, fs_slug: str, slug: str) -> None: + try: + _ = self._raw_config["system"] + except KeyError: + self._raw_config = {"system": {"versions": {}}} + try: + _ = self._raw_config["system"]["versions"] + except KeyError: + self._raw_config["system"]["versions"] = {} + self._raw_config["system"]["versions"][fs_slug] = slug + self.update_config() + + def remove_version(self, fs_slug: str) -> None: + try: + del self._raw_config["system"]["versions"][fs_slug] + except KeyError: + pass + self.update_config() + # def _get_exclude_path(self, exclude): # exclude_base = self._raw_config["exclude"] # exclusions = { diff --git a/backend/endpoints/config.py b/backend/endpoints/config.py index 54a19f688..e8c45ce3f 100644 --- a/backend/endpoints/config.py +++ b/backend/endpoints/config.py @@ -67,6 +67,42 @@ async def delete_platform_binding(request: Request, fs_slug: str) -> MessageResp return {"msg": f"{fs_slug} bind removed successfully!"} +@protected_route(router.post, "/config/system/versions", ["platforms.write"]) +async def add_platform_version(request: Request) -> MessageResponse: + """Add platform version to the configuration""" + + data = await request.json() + fs_slug = data["fs_slug"] + slug = data["slug"] + + try: + cm.add_version(fs_slug, slug) + except ConfigNotWritableException as e: + log.critical(e.message) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.message + ) + + return {"msg": f"Added {fs_slug} as version of: {slug} successfully!"} + + +@protected_route( + router.delete, "/config/system/versions/{fs_slug}", ["platforms.write"] +) +async def delete_platform_version(request: Request, fs_slug: str) -> MessageResponse: + """Delete platform version from the configuration""" + + try: + cm.remove_version(fs_slug) + except ConfigNotWritableException as e: + log.critical(e.message) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.message + ) + + return {"msg": f"{fs_slug} version removed successfully!"} + + # @protected_route(router.post, "/config/exclude", ["platforms.write"]) # async def add_exclusion(request: Request) -> MessageResponse: # """Add platform binding to the configuration""" diff --git a/backend/endpoints/responses/config.py b/backend/endpoints/responses/config.py index 9bc53ba08..e9714c932 100644 --- a/backend/endpoints/responses/config.py +++ b/backend/endpoints/responses/config.py @@ -9,6 +9,7 @@ class ConfigResponse(TypedDict): EXCLUDED_MULTI_PARTS_EXT: list[str] EXCLUDED_MULTI_PARTS_FILES: list[str] PLATFORMS_BINDING: dict[str, str] + PLATFORMS_VERSIONS: dict[str, str] ROMS_FOLDER_NAME: str SAVES_FOLDER_NAME: str STATES_FOLDER_NAME: str diff --git a/backend/endpoints/responses/search.py b/backend/endpoints/responses/search.py index 65e146e3e..68e08e71c 100644 --- a/backend/endpoints/responses/search.py +++ b/backend/endpoints/responses/search.py @@ -1,7 +1,7 @@ -from handler.igdb_handler import IGDBRomType +from handler.igdb_handler import IGDBRom from typing_extensions import TypedDict class RomSearchResponse(TypedDict): msg: str - roms: list[IGDBRomType] + roms: list[IGDBRom] diff --git a/backend/handler/igdb_handler.py b/backend/handler/igdb_handler.py index 18652c55b..78b85cfa5 100644 --- a/backend/handler/igdb_handler.py +++ b/backend/handler/igdb_handler.py @@ -43,12 +43,12 @@ SWITCH_PRODUCT_ID_FILE: Final = os.path.join( MAME_XML_FILE: Final = os.path.join(os.path.dirname(__file__), "fixtures", "mame.xml") -class IGDBPlatformType(TypedDict): +class IGDBPlatform(TypedDict): igdb_id: int name: str -class IGDBRomType(TypedDict): +class IGDBRom(TypedDict): igdb_id: int slug: str name: str @@ -60,6 +60,7 @@ class IGDBRomType(TypedDict): class IGDBHandler: def __init__(self) -> None: self.platform_url = "https://api.igdb.com/v4/platforms/" + self.platform_version_url = "https://api.igdb.com/v4/platform_versions/" self.games_url = "https://api.igdb.com/v4/games/" self.covers_url = "https://api.igdb.com/v4/covers/" self.screenshots_url = "https://api.igdb.com/v4/screenshots/" @@ -263,23 +264,36 @@ class IGDBHandler: return search_term @check_twitch_token - def get_platform(self, slug: str) -> IGDBPlatformType: + def get_platform(self, slug: str) -> IGDBPlatform: platforms = self._request( self.platform_url, data=f'fields id, name; where slug="{slug.lower()}";', ) platform = pydash.get(platforms, "[0]", None) - if not platform: - return IGDBPlatformType(igdb_id=None, name=slug.replace("-", " ").title()) - return IGDBPlatformType( + # Check if platform is a version if not found + if not platform: + platform_versions = self._request( + self.platform_version_url, + data=f'fields id, name; where slug="{slug.lower()}";', + ) + version = pydash.get(platform_versions, "[0]", None) + if not version: + return IGDBPlatform(igdb_id=None, igdb_id_base_platform=None, name=slug.replace("-", " ").title()) + + return IGDBPlatform( + igdb_id=version["id"], + name=version["name"], + ) + + return IGDBPlatform( igdb_id=platform["id"], name=platform["name"], ) @check_twitch_token - async def get_rom(self, file_name: str, platform_idgb_id: int) -> IGDBRomType: + async def get_rom(self, file_name: str, platform_idgb_id: int) -> IGDBRom: from handler import fs_rom_handler search_term = fs_rom_handler.get_file_name_with_no_tags(file_name) @@ -314,7 +328,7 @@ class IGDBHandler: ) igdb_id = res.get("id", None) - rom = IGDBRomType( + rom = IGDBRom( igdb_id=igdb_id, slug=res.get("slug", ""), name=res.get("name", search_term), @@ -330,7 +344,7 @@ class IGDBHandler: return rom @check_twitch_token - def get_rom_by_id(self, igdb_id: int) -> IGDBRomType: + def get_rom_by_id(self, igdb_id: int) -> IGDBRom: roms = self._request( self.games_url, f"fields slug, name, summary; where id={igdb_id};", @@ -347,7 +361,7 @@ class IGDBHandler: } @check_twitch_token - def get_matched_roms_by_id(self, igdb_id: int) -> list[IGDBRomType]: + def get_matched_roms_by_id(self, igdb_id: int) -> list[IGDBRom]: matched_rom = self.get_rom_by_id(igdb_id) matched_rom.update( url_cover=matched_rom["url_cover"].replace("t_thumb", "t_cover_big"), @@ -357,7 +371,7 @@ class IGDBHandler: @check_twitch_token def get_matched_roms_by_name( self, search_term: str, platform_idgb_id: int - ) -> list[IGDBRomType]: + ) -> list[IGDBRom]: if not platform_idgb_id: return [] @@ -371,7 +385,7 @@ class IGDBHandler: ) return [ - IGDBRomType( + IGDBRom( igdb_id=rom["id"], slug=rom["slug"], name=rom["name"], diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 15390b07f..d75ee5f39 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -1,17 +1,38 @@ import os from typing import Any -import emoji +import emoji from config.config_manager import config_manager as cm -from handler import fs_asset_handler, igdb_handler, fs_resource_handler, fs_rom_handler, db_platform_handler +from handler import ( + db_platform_handler, + fs_asset_handler, + fs_resource_handler, + fs_rom_handler, + igdb_handler, +) from logger.logger import log +from models.assets import Save, Screenshot, State from models.platform import Platform from models.rom import Rom -from models.assets import Save, Screenshot, State -SWAPPED_PLATFORM_BINDINGS = dict( - (v, k) for k, v in cm.config.PLATFORMS_BINDING.items() -) +SWAPPED_PLATFORM_BINDINGS = dict((v, k) for k, v in cm.config.PLATFORMS_BINDING.items()) + + +def _get_main_platform_igdb_id(platform: Platform): + if platform.fs_slug in cm.config.PLATFORMS_VERSIONS.keys(): + main_platform_slug = cm.config.PLATFORMS_VERSIONS[platform.fs_slug] + main_platform = db_platform_handler.get_platform_by_slug(main_platform_slug) + if main_platform: + main_platform_igdb_id = main_platform.igdb_id + else: + main_platform_igdb_id = igdb_handler.get_platform(main_platform_slug)[ + "igdb_id" + ] + if not main_platform_igdb_id: + main_platform_igdb_id = platform.igdb_id + else: + main_platform_igdb_id = platform.igdb_id + return main_platform_igdb_id def scan_platform(fs_slug: str, fs_platforms) -> Platform: @@ -51,7 +72,9 @@ def scan_platform(fs_slug: str, fs_platforms) -> Platform: if platform["igdb_id"]: log.info(emoji.emojize(f" Identified as {platform['name']} :video_game:")) else: - log.warning(emoji.emojize(f" {platform_attrs['slug']} not found in IGDB :cross_mark:")) + log.warning( + emoji.emojize(f" {platform_attrs['slug']} not found in IGDB :cross_mark:") + ) platform_attrs.update(platform) @@ -85,8 +108,12 @@ async def scan_rom( "platform_id": platform.id, "file_path": roms_path, "file_name": rom_attrs["file_name"], - "file_name_no_tags": fs_rom_handler.get_file_name_with_no_tags(rom_attrs["file_name"]), - "file_extension": fs_rom_handler.parse_file_extension(rom_attrs["file_name"]), + "file_name_no_tags": fs_rom_handler.get_file_name_with_no_tags( + rom_attrs["file_name"] + ), + "file_extension": fs_rom_handler.parse_file_extension( + rom_attrs["file_name"] + ), "file_size_bytes": file_size, "multi": rom_attrs["multi"], "regions": regs, @@ -96,11 +123,13 @@ async def scan_rom( } ) + main_platform_igdb_id = _get_main_platform_igdb_id(platform) + # Search in IGDB igdb_handler_rom = ( igdb_handler.get_rom_by_id(int(r_igbd_id_search)) if r_igbd_id_search - else await igdb_handler.get_rom(rom_attrs["file_name"], platform.igdb_id) + else await igdb_handler.get_rom(rom_attrs["file_name"], main_platform_igdb_id) ) rom_attrs.update(igdb_handler_rom) @@ -108,11 +137,15 @@ async def scan_rom( # Return early if not found in IGDB if not igdb_handler_rom["igdb_id"]: log.warning( - emoji.emojize(f"\t {r_igbd_id_search or rom_attrs['file_name']} not found in IGDB :cross_mark:") + emoji.emojize( + f"\t {r_igbd_id_search or rom_attrs['file_name']} not found in IGDB :cross_mark:" + ) ) return Rom(**rom_attrs) - log.info(emoji.emojize(f"\t Identified as {igdb_handler_rom['name']} :alien_monster:")) + log.info( + emoji.emojize(f"\t Identified as {igdb_handler_rom['name']} :alien_monster:") + ) # Update properties from IGDB rom_attrs.update( @@ -180,6 +213,4 @@ def scan_screenshot(file_name: str, platform_slug: Platform = None) -> Screensho if platform_slug: return Screenshot(**_scan_asset(file_name, screenshots_path)) - return Screenshot( - **_scan_asset(file_name, cm.config.SCREENSHOTS_FOLDER_NAME) - ) + return Screenshot(**_scan_asset(file_name, cm.config.SCREENSHOTS_FOLDER_NAME)) diff --git a/examples/docker-compose.example.yml b/examples/docker-compose.example.yml index 56d379437..5af89a814 100644 --- a/examples/docker-compose.example.yml +++ b/examples/docker-compose.example.yml @@ -41,7 +41,7 @@ services: volumes: - "/path/to/library:/romm/library" - "/path/to/resources:/romm/resources" # [Optional] Path where roms metadata (covers) are stored - - "/path/to/config.yml:/romm/config.yml" # [Optional] Path where config is stored + - "/path/to/config:/romm/config" # [Optional] Path where config is stored - "/path/to/database:/romm/database" # [Optional] Only needed if ROMM_DB_DRIVER=sqlite or not set - "/path/to/logs:/romm/logs" # [Optional] Path where logs are stored ports: diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 00e6a80f6..7b74bfcbb 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -15,7 +15,7 @@ export type { CursorPage_RomSchema_ } from './models/CursorPage_RomSchema_'; export type { EnhancedRomSchema } from './models/EnhancedRomSchema'; export type { HeartbeatResponse } from './models/HeartbeatResponse'; export type { HTTPValidationError } from './models/HTTPValidationError'; -export type { IGDBRomType } from './models/IGDBRomType'; +export type { IGDBRom } from './models/IGDBRom'; export type { MessageResponse } from './models/MessageResponse'; export type { PlatformSchema } from './models/PlatformSchema'; export type { Role } from './models/Role'; diff --git a/frontend/src/__generated__/models/ConfigResponse.ts b/frontend/src/__generated__/models/ConfigResponse.ts index 0a65c4f0f..b8d852fd6 100644 --- a/frontend/src/__generated__/models/ConfigResponse.ts +++ b/frontend/src/__generated__/models/ConfigResponse.ts @@ -11,6 +11,7 @@ export type ConfigResponse = { EXCLUDED_MULTI_PARTS_EXT: Array; EXCLUDED_MULTI_PARTS_FILES: Array; PLATFORMS_BINDING: Record; + PLATFORMS_VERSIONS: Record; ROMS_FOLDER_NAME: string; SAVES_FOLDER_NAME: string; STATES_FOLDER_NAME: string; diff --git a/frontend/src/__generated__/models/IGDBRomType.ts b/frontend/src/__generated__/models/IGDBRom.ts similarity index 90% rename from frontend/src/__generated__/models/IGDBRomType.ts rename to frontend/src/__generated__/models/IGDBRom.ts index cb0c1e221..8d23bfccb 100644 --- a/frontend/src/__generated__/models/IGDBRomType.ts +++ b/frontend/src/__generated__/models/IGDBRom.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ -export type IGDBRomType = { +export type IGDBRom = { igdb_id: number; slug: string; name: string; diff --git a/frontend/src/__generated__/models/RomSearchResponse.ts b/frontend/src/__generated__/models/RomSearchResponse.ts index 2d4261e63..cf5404a84 100644 --- a/frontend/src/__generated__/models/RomSearchResponse.ts +++ b/frontend/src/__generated__/models/RomSearchResponse.ts @@ -3,10 +3,10 @@ /* tslint:disable */ /* eslint-disable */ -import type { IGDBRomType } from './IGDBRomType'; +import type { IGDBRom } from './IGDBRom'; export type RomSearchResponse = { msg: string; - roms: Array; + roms: Array; }; diff --git a/frontend/src/components/Dialog/Config/CreatePlatformVersion.vue b/frontend/src/components/Dialog/Config/CreatePlatformVersion.vue new file mode 100644 index 000000000..f3292a8f1 --- /dev/null +++ b/frontend/src/components/Dialog/Config/CreatePlatformVersion.vue @@ -0,0 +1,104 @@ + + diff --git a/frontend/src/components/Dialog/Config/DeletePlatformVersion.vue b/frontend/src/components/Dialog/Config/DeletePlatformVersion.vue new file mode 100644 index 000000000..c52d15403 --- /dev/null +++ b/frontend/src/components/Dialog/Config/DeletePlatformVersion.vue @@ -0,0 +1,97 @@ + + diff --git a/frontend/src/components/Settings/Config/PlatformVersionsCard.vue b/frontend/src/components/Settings/Config/PlatformVersionsCard.vue new file mode 100644 index 000000000..d71e912ee --- /dev/null +++ b/frontend/src/components/Settings/Config/PlatformVersionsCard.vue @@ -0,0 +1,133 @@ + + + + diff --git a/frontend/src/services/api_config.ts b/frontend/src/services/api_config.ts index 200c0ea23..0a7507096 100644 --- a/frontend/src/services/api_config.ts +++ b/frontend/src/services/api_config.ts @@ -21,6 +21,24 @@ async function deletePlatformBindConfig({ return api.delete(`/config/system/platforms/${fsSlug}`); } +async function addPlatformVersionConfig({ + fsSlug, + slug, +}: { + fsSlug: string; + slug: string; +}): Promise<{ data: MessageResponse }> { + return api.post("/config/system/versions", { fs_slug: fsSlug, slug: slug }); +} + +async function deletePlatformVersionConfig({ + fsSlug, +}: { + fsSlug: string; +}): Promise<{ data: MessageResponse }> { + return api.delete(`/config/system/versions/${fsSlug}`); +} + async function addExclusion({ exclude, exclusion, @@ -37,5 +55,7 @@ async function addExclusion({ export default { addPlatformBindConfig, deletePlatformBindConfig, + addPlatformVersionConfig, + deletePlatformVersionConfig, addExclusion, }; diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index 814946763..d7a10abb7 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -17,5 +17,11 @@ export default defineStore("config", { removePlatformBinding(fsSlug: string) { delete this.value.PLATFORMS_BINDING[fsSlug]; }, + addPlatformVersion(fsSlug: string, slug: string) { + this.value.PLATFORMS_VERSIONS[fsSlug] = slug; + }, + removePlatformVersion(fsSlug: string) { + delete this.value.PLATFORMS_VERSIONS[fsSlug]; + }, }, }); diff --git a/frontend/src/types/emitter.d.ts b/frontend/src/types/emitter.d.ts index 79fa0c9b1..f4befdeb5 100644 --- a/frontend/src/types/emitter.d.ts +++ b/frontend/src/types/emitter.d.ts @@ -30,6 +30,14 @@ export type Events = { fsSlug: string; slug: string; }; + showCreatePlatformVersionDialog: { + fsSlug: string; + slug: string; + }; + showDeletePlatformVersionDialog: { + fsSlug: string; + slug: string; + }; showCreateExclusionDialog: { exclude: string }; showCreateUserDialog: null; showEditUserDialog: UserItem; diff --git a/frontend/src/views/Settings/ControlPanel/Config/Base.vue b/frontend/src/views/Settings/ControlPanel/Config/Base.vue index a56920f9e..7dfe4ea2b 100644 --- a/frontend/src/views/Settings/ControlPanel/Config/Base.vue +++ b/frontend/src/views/Settings/ControlPanel/Config/Base.vue @@ -1,9 +1,11 @@