diff --git a/backend/alembic/versions/0058_roms_metadata_launchbox.py b/backend/alembic/versions/0058_roms_metadata_launchbox.py new file mode 100644 index 000000000..4f0c4e494 --- /dev/null +++ b/backend/alembic/versions/0058_roms_metadata_launchbox.py @@ -0,0 +1,363 @@ +"""empty message + +Revision ID: 0058_roms_metadata_launchbox +Revises: 0057_multi_notes +Create Date: 2025-12-22 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +from utils.database import is_postgresql + +# revision identifiers, used by Alembic. +revision = "0058_roms_metadata_launchbox" +down_revision = "0057_multi_notes" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + if is_postgresql(connection): + connection.execute( + sa.text( + """ + CREATE OR REPLACE VIEW roms_metadata AS + SELECT + r.id AS rom_id, + NOW() AS created_at, + NOW() AS updated_at, + COALESCE( + (r.igdb_metadata -> 'genres'), + (r.moby_metadata -> 'genres'), + (r.ss_metadata -> 'genres'), + (r.launchbox_metadata -> 'genres'), + (r.ra_metadata -> 'genres'), + (r.flashpoint_metadata -> 'genres'), + (r.gamelist_metadata -> 'genres'), + '[]'::jsonb + ) AS genres, + + COALESCE( + (r.igdb_metadata -> 'franchises'), + (r.ss_metadata -> 'franchises'), + (r.flashpoint_metadata -> 'franchises'), + (r.gamelist_metadata -> 'franchises'), + '[]'::jsonb + ) AS franchises, + + COALESCE( + (r.igdb_metadata -> 'collections'), + '[]'::jsonb + ) AS collections, + + COALESCE( + (r.igdb_metadata -> 'companies'), + (r.ss_metadata -> 'companies'), + (r.ra_metadata -> 'companies'), + (r.launchbox_metadata -> 'companies'), + (r.flashpoint_metadata -> 'companies'), + (r.gamelist_metadata -> 'companies'), + '[]'::jsonb + ) AS companies, + + COALESCE( + (r.igdb_metadata -> 'game_modes'), + (r.ss_metadata -> 'game_modes'), + (r.flashpoint_metadata -> 'game_modes'), + '[]'::jsonb + ) AS game_modes, + + COALESCE( + CASE + WHEN r.igdb_metadata IS NOT NULL + AND r.igdb_metadata ? 'age_ratings' + AND jsonb_array_length(r.igdb_metadata -> 'age_ratings') > 0 + THEN + jsonb_path_query_array(r.igdb_metadata, '$.age_ratings[*].rating') + ELSE + '[]'::jsonb + END, + CASE + WHEN r.launchbox_metadata IS NOT NULL + AND r.launchbox_metadata ? 'esrb' + AND r.launchbox_metadata ->> 'esrb' IS NOT NULL + AND r.launchbox_metadata ->> 'esrb' != '' + THEN + jsonb_build_array(r.launchbox_metadata ->> 'esrb') + ELSE + '[]'::jsonb + END, + '[]'::jsonb + ) AS age_ratings, + + CASE + WHEN r.igdb_metadata IS NOT NULL AND r.igdb_metadata ? 'first_release_date' AND + r.igdb_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.igdb_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.igdb_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.ss_metadata IS NOT NULL AND r.ss_metadata ? 'first_release_date' AND + r.ss_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.ss_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.ss_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.ra_metadata IS NOT NULL AND r.ra_metadata ? 'first_release_date' AND + r.ra_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.ra_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.ra_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.launchbox_metadata IS NOT NULL AND r.launchbox_metadata ? 'first_release_date' AND + r.launchbox_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.launchbox_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.launchbox_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.flashpoint_metadata IS NOT NULL AND r.flashpoint_metadata ? 'first_release_date' AND + r.flashpoint_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.flashpoint_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.flashpoint_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.gamelist_metadata IS NOT NULL + AND r.gamelist_metadata ? 'first_release_date' + AND r.gamelist_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') + AND r.gamelist_metadata ->> 'first_release_date' ~ '^[0-9]{8}T[0-9]{6}$' + THEN (extract(epoch FROM to_timestamp(r.gamelist_metadata ->> 'first_release_date', 'YYYYMMDD"T"HH24MISS')) * 1000)::bigint + + ELSE NULL + END AS first_release_date, + + CASE + WHEN (igdb_rating IS NOT NULL OR moby_rating IS NOT NULL OR ss_rating IS NOT NULL OR launchbox_rating IS NOT NULL OR gamelist_rating IS NOT NULL) THEN + (COALESCE(igdb_rating, 0) + COALESCE(moby_rating, 0) + COALESCE(ss_rating, 0) + COALESCE(launchbox_rating, 0) + COALESCE(gamelist_rating, 0)) / + (CASE WHEN igdb_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN moby_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN ss_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN launchbox_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN gamelist_rating IS NOT NULL THEN 1 ELSE 0 END) + ELSE NULL + END AS average_rating + FROM ( + SELECT + r.id, + r.igdb_metadata, + r.moby_metadata, + r.ss_metadata, + r.ra_metadata, + r.launchbox_metadata, + r.flashpoint_metadata, + r.gamelist_metadata, + CASE + WHEN r.igdb_metadata IS NOT NULL AND r.igdb_metadata ? 'total_rating' AND + r.igdb_metadata ->> 'total_rating' NOT IN ('null', 'None', '0', '0.0') AND + r.igdb_metadata ->> 'total_rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.igdb_metadata ->> 'total_rating')::float + ELSE NULL + END AS igdb_rating, + CASE + WHEN r.moby_metadata IS NOT NULL AND r.moby_metadata ? 'moby_score' AND + r.moby_metadata ->> 'moby_score' NOT IN ('null', 'None', '0', '0.0') AND + r.moby_metadata ->> 'moby_score' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.moby_metadata ->> 'moby_score')::float * 10 + ELSE NULL + END AS moby_rating, + CASE + WHEN r.ss_metadata IS NOT NULL AND r.ss_metadata ? 'ss_score' AND + r.ss_metadata ->> 'ss_score' NOT IN ('null', 'None', '0', '0.0') AND + r.ss_metadata ->> 'ss_score' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.ss_metadata ->> 'ss_score')::float * 10 + ELSE NULL + END AS ss_rating, + CASE + WHEN r.launchbox_metadata IS NOT NULL AND r.launchbox_metadata ? 'community_rating' AND + r.launchbox_metadata ->> 'community_rating' NOT IN ('null', 'None', '0', '0.0') AND + r.launchbox_metadata ->> 'community_rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.launchbox_metadata ->> 'community_rating')::float * 20 + ELSE NULL + END AS launchbox_rating, + CASE + WHEN r.gamelist_metadata IS NOT NULL AND r.gamelist_metadata ? 'rating' AND + r.gamelist_metadata ->> 'rating' NOT IN ('null', 'None', '0', '0.0') AND + r.gamelist_metadata ->> 'rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.gamelist_metadata ->> 'rating')::float * 100 + ELSE NULL + END AS gamelist_rating + FROM roms r + ) AS r; + """ + ) + ) + else: + connection.execute( + sa.text( + """CREATE OR REPLACE VIEW roms_metadata AS + SELECT + r.id as rom_id, + NOW() AS created_at, + NOW() AS updated_at, + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.genres'), + JSON_EXTRACT(r.moby_metadata, '$.genres'), + JSON_EXTRACT(r.ss_metadata, '$.genres'), + JSON_EXTRACT(r.launchbox_metadata, '$.genres'), + JSON_EXTRACT(r.ra_metadata, '$.genres'), + JSON_EXTRACT(r.flashpoint_metadata, '$.genres'), + JSON_EXTRACT(r.gamelist_metadata, '$.genres'), + JSON_ARRAY() + ) AS genres, + + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.franchises'), + JSON_EXTRACT(r.ss_metadata, '$.franchises'), + JSON_EXTRACT(r.flashpoint_metadata, '$.franchises'), + JSON_EXTRACT(r.gamelist_metadata, '$.franchises'), + JSON_ARRAY() + ) AS franchises, + + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.collections'), + JSON_ARRAY() + ) AS collections, + + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.companies'), + JSON_EXTRACT(r.ss_metadata, '$.companies'), + JSON_EXTRACT(r.ra_metadata, '$.companies'), + JSON_EXTRACT(r.launchbox_metadata, '$.companies'), + JSON_EXTRACT(r.flashpoint_metadata, '$.companies'), + JSON_EXTRACT(r.gamelist_metadata, '$.companies'), + JSON_ARRAY() + ) AS companies, + + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.game_modes'), + JSON_EXTRACT(r.ss_metadata, '$.game_modes'), + JSON_EXTRACT(r.flashpoint_metadata, '$.game_modes'), + JSON_ARRAY() + ) AS game_modes, + + COALESCE( + CASE + WHEN JSON_CONTAINS_PATH(r.igdb_metadata, 'one', '$.age_ratings') + AND JSON_LENGTH(JSON_EXTRACT(r.igdb_metadata, '$.age_ratings')) > 0 + THEN + JSON_EXTRACT(r.igdb_metadata, '$.age_ratings[*].rating') + ELSE + JSON_ARRAY() + END, + CASE + WHEN JSON_CONTAINS_PATH(r.launchbox_metadata, 'one', '$.esrb') + AND JSON_EXTRACT(r.launchbox_metadata, '$.esrb') IS NOT NULL + AND JSON_EXTRACT(r.launchbox_metadata, '$.esrb') != '' + THEN + JSON_ARRAY(JSON_EXTRACT(r.launchbox_metadata, '$.esrb')) + ELSE + JSON_ARRAY() + END, + JSON_ARRAY() + ) AS age_ratings, + + CASE + WHEN JSON_CONTAINS_PATH(r.igdb_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.ss_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.ss_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.ra_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ra_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ra_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.ra_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.launchbox_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.flashpoint_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.gamelist_metadata, 'one', '$.first_release_date') + AND JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') + AND JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')) REGEXP '^[0-9]{8}T[0-9]{6}$' + THEN UNIX_TIMESTAMP( + STR_TO_DATE( + JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')), + '%Y%m%dT%H%i%S' + ) + ) * 1000 + + ELSE NULL + END AS first_release_date, + + CASE + WHEN (igdb_rating IS NOT NULL OR moby_rating IS NOT NULL OR ss_rating IS NOT NULL OR launchbox_rating IS NOT NULL OR gamelist_rating IS NOT NULL) THEN + (COALESCE(igdb_rating, 0) + COALESCE(moby_rating, 0) + COALESCE(ss_rating, 0) + COALESCE(launchbox_rating, 0) + COALESCE(gamelist_rating, 0)) / + (CASE WHEN igdb_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN moby_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN ss_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN launchbox_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN gamelist_rating IS NOT NULL THEN 1 ELSE 0 END) + ELSE NULL + END AS average_rating + FROM ( + SELECT + id, + igdb_metadata, + moby_metadata, + ss_metadata, + ra_metadata, + launchbox_metadata, + flashpoint_metadata, + gamelist_metadata, + CASE + WHEN JSON_CONTAINS_PATH(igdb_metadata, 'one', '$.total_rating') AND + JSON_UNQUOTE(JSON_EXTRACT(igdb_metadata, '$.total_rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(igdb_metadata, '$.total_rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(igdb_metadata, '$.total_rating') AS DECIMAL(10,2)) + ELSE NULL + END AS igdb_rating, + CASE + WHEN JSON_CONTAINS_PATH(moby_metadata, 'one', '$.moby_score') AND + JSON_UNQUOTE(JSON_EXTRACT(moby_metadata, '$.moby_score')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(moby_metadata, '$.moby_score')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(moby_metadata, '$.moby_score') AS DECIMAL(10,2)) * 10 + ELSE NULL + END AS moby_rating, + CASE + WHEN JSON_CONTAINS_PATH(ss_metadata, 'one', '$.ss_score') AND + JSON_UNQUOTE(JSON_EXTRACT(ss_metadata, '$.ss_score')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(ss_metadata, '$.ss_score')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(ss_metadata, '$.ss_score') AS DECIMAL(10,2)) * 10 + ELSE NULL + END AS ss_rating, + CASE + WHEN JSON_CONTAINS_PATH(launchbox_metadata, 'one', '$.community_rating') AND + JSON_UNQUOTE(JSON_EXTRACT(launchbox_metadata, '$.community_rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(launchbox_metadata, '$.community_rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(launchbox_metadata, '$.community_rating') AS DECIMAL(10,2)) * 20 + ELSE NULL + END AS launchbox_rating, + CASE + WHEN JSON_CONTAINS_PATH(gamelist_metadata, 'one', '$.rating') AND + JSON_UNQUOTE(JSON_EXTRACT(gamelist_metadata, '$.rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(gamelist_metadata, '$.rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(gamelist_metadata, '$.rating') AS DECIMAL(10,2)) * 100 + ELSE NULL + END AS gamelist_rating + FROM roms + ) AS r; + """ + ) + ) + + +def downgrade(): + op.execute("DROP VIEW IF EXISTS roms_metadata") diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 219fe42cf..4cfe05402 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -84,6 +84,8 @@ class Config: HIGH_PRIO_STRUCTURE_PATH: str EJS_DEBUG: bool EJS_CACHE_LIMIT: int | None + EJS_DISABLE_AUTO_UNLOAD: bool + EJS_DISABLE_BATCH_BOOTUP: bool EJS_NETPLAY_ENABLED: bool EJS_NETPLAY_ICE_SERVERS: list[NetplayICEServer] EJS_SETTINGS: dict[str, EjsOption] # core_name -> EjsOption @@ -228,6 +230,12 @@ class ConfigManager: EJS_CACHE_LIMIT=pydash.get( self._raw_config, "emulatorjs.cache_limit", None ), + EJS_DISABLE_AUTO_UNLOAD=pydash.get( + self._raw_config, "emulatorjs.disable_auto_unload", False + ), + EJS_DISABLE_BATCH_BOOTUP=pydash.get( + self._raw_config, "emulatorjs.disable_batch_bootup", False + ), EJS_NETPLAY_ENABLED=pydash.get( self._raw_config, "emulatorjs.netplay.enabled", False ), @@ -419,6 +427,18 @@ class ConfigManager: ) sys.exit(3) + if not isinstance(self.config.EJS_DISABLE_AUTO_UNLOAD, bool): + log.critical( + "Invalid config.yml: emulatorjs.disable_auto_unload must be a boolean" + ) + sys.exit(3) + + if not isinstance(self.config.EJS_DISABLE_BATCH_BOOTUP, bool): + log.critical( + "Invalid config.yml: emulatorjs.disable_batch_bootup must be a boolean" + ) + sys.exit(3) + if not isinstance(self.config.EJS_NETPLAY_ICE_SERVERS, list): log.critical( "Invalid config.yml: emulatorjs.netplay.ice_servers must be a list" @@ -533,6 +553,8 @@ class ConfigManager: "emulatorjs": { "debug": self.config.EJS_DEBUG, "cache_limit": self.config.EJS_CACHE_LIMIT, + "disable_auto_unload": self.config.EJS_DISABLE_AUTO_UNLOAD, + "disable_batch_bootup": self.config.EJS_DISABLE_BATCH_BOOTUP, "netplay": { "enabled": self.config.EJS_NETPLAY_ENABLED, "ice_servers": self.config.EJS_NETPLAY_ICE_SERVERS, diff --git a/backend/endpoints/configs.py b/backend/endpoints/configs.py index 350e7c8bf..895c27f7c 100644 --- a/backend/endpoints/configs.py +++ b/backend/endpoints/configs.py @@ -37,6 +37,8 @@ def get_config() -> ConfigResponse: SKIP_HASH_CALCULATION=cfg.SKIP_HASH_CALCULATION, EJS_DEBUG=cfg.EJS_DEBUG, EJS_CACHE_LIMIT=cfg.EJS_CACHE_LIMIT, + EJS_DISABLE_AUTO_UNLOAD=cfg.EJS_DISABLE_AUTO_UNLOAD, + EJS_DISABLE_BATCH_BOOTUP=cfg.EJS_DISABLE_BATCH_BOOTUP, EJS_NETPLAY_ENABLED=cfg.EJS_NETPLAY_ENABLED, EJS_NETPLAY_ICE_SERVERS=cfg.EJS_NETPLAY_ICE_SERVERS, EJS_CONTROLS=cfg.EJS_CONTROLS, diff --git a/backend/endpoints/responses/config.py b/backend/endpoints/responses/config.py index 59f4e6c40..b20baf2b9 100644 --- a/backend/endpoints/responses/config.py +++ b/backend/endpoints/responses/config.py @@ -17,6 +17,8 @@ class ConfigResponse(TypedDict): SKIP_HASH_CALCULATION: bool EJS_DEBUG: bool EJS_CACHE_LIMIT: int | None + EJS_DISABLE_AUTO_UNLOAD: bool + EJS_DISABLE_BATCH_BOOTUP: bool EJS_NETPLAY_ENABLED: bool EJS_NETPLAY_ICE_SERVERS: list[NetplayICEServer] EJS_SETTINGS: dict[str, dict[str, str]] diff --git a/backend/endpoints/search.py b/backend/endpoints/search.py index 3a5509dc7..0023d3983 100644 --- a/backend/endpoints/search.py +++ b/backend/endpoints/search.py @@ -21,7 +21,11 @@ from handler.metadata.launchbox_handler import LaunchboxRom from handler.metadata.moby_handler import MobyGamesRom from handler.metadata.sgdb_handler import SGDBRom from handler.metadata.ss_handler import SSRom -from handler.scan_handler import get_main_platform_igdb_id +from handler.scan_handler import ( + MetadataSource, + get_main_platform_igdb_id, + get_priority_ordered_metadata_sources, +) from logger.formatter import BLUE, CYAN from logger.formatter import highlight as hl from logger.logger import log @@ -79,7 +83,6 @@ async def search_rom( log.info( f"{emoji.EMOJI_MAGNIFYING_GLASS_TILTED_RIGHT} Searching metadata providers..." ) - matched_roms: list = [] log.info(f"Searching by {hl(search_by.lower(), color=CYAN)}:") log.info( @@ -94,10 +97,11 @@ async def search_rom( if search_by.lower() == "id": try: - igdb_rom, moby_rom, ss_rom = await asyncio.gather( + igdb_rom, moby_rom, ss_rom, lb_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(rom, int(search_term)), + meta_launchbox_handler.get_matched_rom_by_id(int(search_term)), ) except ValueError as exc: log.error(f"Search error: invalid ID '{search_term}'") @@ -109,6 +113,7 @@ async def search_rom( 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 [] + launchbox_matched_roms = [lb_rom] if lb_rom else [] elif search_by.lower() == "name": ( igdb_matched_roms, @@ -136,80 +141,56 @@ async def search_rom( merged_dict: dict[str, dict] = {} - for igdb_rom in igdb_matched_roms: - if igdb_rom["igdb_id"]: - igdb_name = meta_igdb_handler.normalize_search_term( - igdb_rom.get("name", ""), - remove_articles=False, - ) - merged_dict[igdb_name] = { - **igdb_rom, - "is_identified": True, - "is_unidentified": False, - "platform_id": rom.platform_id, - "igdb_url_cover": igdb_rom.pop("url_cover", ""), - **merged_dict.get(igdb_name, {}), - } + source_configs = { + MetadataSource.IGDB: ( + igdb_matched_roms, + meta_igdb_handler, + "igdb_id", + "igdb_url_cover", + ), + MetadataSource.MOBY: ( + moby_matched_roms, + meta_moby_handler, + "moby_id", + "moby_url_cover", + ), + MetadataSource.FLASHPOINT: ( + flashpoint_matched_roms, + meta_flashpoint_handler, + "flashpoint_id", + "flashpoint_url_cover", + ), + MetadataSource.LAUNCHBOX: ( + launchbox_matched_roms, + meta_launchbox_handler, + "launchbox_id", + "launchbox_url_cover", + ), + MetadataSource.SS: (ss_matched_roms, meta_ss_handler, "ss_id", "ss_url_cover"), + } - for moby_rom in moby_matched_roms: - if moby_rom["moby_id"]: - moby_name = meta_moby_handler.normalize_search_term( - moby_rom.get("name", ""), - remove_articles=False, - ) - merged_dict[moby_name] = { - **moby_rom, - "is_identified": True, - "is_unidentified": False, - "platform_id": rom.platform_id, - "moby_url_cover": moby_rom.pop("url_cover", ""), - **merged_dict.get(moby_name, {}), - } + ordered_sources = get_priority_ordered_metadata_sources( + metadata_sources=list(source_configs.keys()), priority_type="metadata" + ) - for ss_rom in ss_matched_roms: - if ss_rom["ss_id"]: - ss_name = meta_ss_handler.normalize_search_term( - ss_rom.get("name", ""), - remove_articles=False, - ) - merged_dict[ss_name] = { - **ss_rom, - "is_identified": True, - "is_unidentified": False, - "platform_id": rom.platform_id, - "ss_url_cover": ss_rom.pop("url_cover", ""), - **merged_dict.get(ss_name, {}), - } - - for flashpoint_rom in flashpoint_matched_roms: - if flashpoint_rom["flashpoint_id"]: - flashpoint_name = meta_flashpoint_handler.normalize_search_term( - flashpoint_rom.get("name", ""), - remove_articles=False, - ) - merged_dict[flashpoint_name] = { - **flashpoint_rom, - "is_identified": True, - "is_unidentified": False, - "platform_id": rom.platform_id, - "flashpoint_url_cover": flashpoint_rom.pop("url_cover", ""), - **merged_dict.get(flashpoint_name, {}), - } - - for launchbox_rom in launchbox_matched_roms: - if launchbox_rom["launchbox_id"]: - launchbox_name = meta_launchbox_handler.normalize_search_term( - launchbox_rom.get("name", ""), - remove_articles=False, - ) - merged_dict[launchbox_name] = { - **launchbox_rom, - "is_identified": True, - "is_unidentified": False, - "platform_id": rom.platform_id, - "launchbox_url_cover": launchbox_rom.pop("url_cover", ""), - **merged_dict.get(launchbox_name, {}), - } + for meta_source in ordered_sources: + source_matched_roms, meta_handler, id_key, cover_key = source_configs[ + meta_source + ] + for source_rom in source_matched_roms: # trunk-ignore(mypy/attr-defined) + if source_rom[id_key]: + normalized_name = meta_handler.normalize_search_term( + source_rom.get("name", ""), + remove_articles=False, + ) + merged_dict[normalized_name] = { + **source_rom, + "is_identified": True, + "is_unidentified": False, + "platform_id": rom.platform_id, + cover_key: source_rom.get("url_cover", ""), + **merged_dict.get(normalized_name, {}), + } async def get_sgdb_rom(name: str) -> tuple[str, SGDBRom]: return name, await meta_sgdb_handler.get_details_by_names([name]) @@ -226,7 +207,7 @@ async def search_rom( "sgdb_url_cover": sgdb_rom.get("url_cover", ""), } - matched_roms = list(merged_dict.values()) + matched_roms: list = list(merged_dict.values()) log.info("Results:") for m_rom in matched_roms: diff --git a/backend/handler/metadata/launchbox_handler.py b/backend/handler/metadata/launchbox_handler.py index c8cd5f6eb..8d5418bb1 100644 --- a/backend/handler/metadata/launchbox_handler.py +++ b/backend/handler/metadata/launchbox_handler.py @@ -230,7 +230,9 @@ class LaunchboxHandler(MetadataHandler): name=platform["name"], ) - async def get_rom(self, fs_name: str, platform_slug: str) -> LaunchboxRom: + async def get_rom( + self, fs_name: str, platform_slug: str, keep_tags: bool = False + ) -> LaunchboxRom: from handler.filesystem import fs_rom_handler fallback_rom = LaunchboxRom(launchbox_id=None) @@ -253,10 +255,19 @@ class LaunchboxHandler(MetadataHandler): f"LaunchBox ID {launchbox_id_from_tag} from filename tag not found in LaunchBox" ) - # We replace " - " with ": " to match Launchbox's naming convention - search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name).replace( - " - ", ": " - ) + # `keep_tags` prevents stripping content that is considered a tag, e.g., anything between `()` or `[]`. + # By default, tags are still stripped to keep scan behavior consistent with previous versions. + # If `keep_tags` is True, the full `fs_name` is used for searching. + if not keep_tags: + # We replace " - " with ": " to match Launchbox's naming convention + search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name).replace( + " - ", ": " + ) + else: + search_term = fs_name + + search_term = search_term.lower() + index_entry = await self._get_rom_from_metadata(search_term, platform_slug) if not index_entry: @@ -329,7 +340,7 @@ class LaunchboxHandler(MetadataHandler): if not self.is_enabled(): return [] - rom = await self.get_rom(search_term, platform_slug) + rom = await self.get_rom(search_term, platform_slug, True) return [rom] if rom else [] diff --git a/backend/tasks/scheduled/update_launchbox_metadata.py b/backend/tasks/scheduled/update_launchbox_metadata.py index f87708ef1..a7b0b887d 100644 --- a/backend/tasks/scheduled/update_launchbox_metadata.py +++ b/backend/tasks/scheduled/update_launchbox_metadata.py @@ -126,7 +126,7 @@ class UpdateLaunchboxMetadataTask(RemoteFilePullTask): await pipe.hset( LAUNCHBOX_METADATA_NAME_KEY, mapping={ - f"{name_elem.text}:{platform_elem.text}": json.dumps( + f"{name_elem.text.lower()}:{platform_elem.text}": json.dumps( { child.tag: child.text for child in elem @@ -145,7 +145,7 @@ class UpdateLaunchboxMetadataTask(RemoteFilePullTask): await pipe.hset( LAUNCHBOX_METADATA_ALTERNATE_NAME_KEY, mapping={ - alternate_name_elem.text: json.dumps( + alternate_name_elem.text.lower(): json.dumps( { child.tag: child.text for child in elem diff --git a/backend/tests/config/fixtures/config/config.yml b/backend/tests/config/fixtures/config/config.yml index b13131282..8975f4c94 100644 --- a/backend/tests/config/fixtures/config/config.yml +++ b/backend/tests/config/fixtures/config/config.yml @@ -55,6 +55,8 @@ scan: emulatorjs: debug: true cache_limit: 1000 + disable_auto_unload: true + disable_batch_bootup: true netplay: enabled: true ice_servers: diff --git a/backend/tests/config/test_config_loader.py b/backend/tests/config/test_config_loader.py index 8f92d9432..44bf9f163 100644 --- a/backend/tests/config/test_config_loader.py +++ b/backend/tests/config/test_config_loader.py @@ -21,6 +21,8 @@ def test_config_loader(): assert loader.config.FIRMWARE_FOLDER_NAME == "BIOS" assert loader.config.SKIP_HASH_CALCULATION assert loader.config.EJS_DEBUG + assert loader.config.EJS_DISABLE_AUTO_UNLOAD + assert loader.config.EJS_DISABLE_BATCH_BOOTUP assert loader.config.EJS_CACHE_LIMIT == 1000 assert loader.config.EJS_NETPLAY_ENABLED assert loader.config.EJS_NETPLAY_ICE_SERVERS == [ @@ -69,6 +71,8 @@ def test_empty_config_loader(): assert not loader.config.SKIP_HASH_CALCULATION assert not loader.config.EJS_DEBUG assert loader.config.EJS_CACHE_LIMIT is None + assert not loader.config.EJS_DISABLE_AUTO_UNLOAD + assert not loader.config.EJS_DISABLE_BATCH_BOOTUP assert not loader.config.EJS_NETPLAY_ENABLED assert loader.config.EJS_NETPLAY_ICE_SERVERS == [] assert loader.config.EJS_SETTINGS == {} diff --git a/examples/config.example.yml b/examples/config.example.yml index 0d00a8b47..514029e0a 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -137,6 +137,8 @@ # emulatorjs: # debug: true # Available options will be logged to the browser console # cache_limit: null # Cache limit per ROM (in bytes) +# disable_batch_bootup: false +# disable_auto_unload: false # settings: # parallel_n64: # Use the exact core name # vsync: disabled diff --git a/frontend/src/__generated__/models/ConfigResponse.ts b/frontend/src/__generated__/models/ConfigResponse.ts index fcbc45ec8..4d7d78892 100644 --- a/frontend/src/__generated__/models/ConfigResponse.ts +++ b/frontend/src/__generated__/models/ConfigResponse.ts @@ -18,6 +18,8 @@ export type ConfigResponse = { SKIP_HASH_CALCULATION: boolean; EJS_DEBUG: boolean; EJS_CACHE_LIMIT: (number | null); + EJS_DISABLE_AUTO_UNLOAD: boolean; + EJS_DISABLE_BATCH_BOOTUP: boolean; EJS_NETPLAY_ENABLED: boolean; EJS_NETPLAY_ICE_SERVERS: Array; EJS_SETTINGS: Record>; diff --git a/frontend/src/console/views/Home.vue b/frontend/src/console/views/Home.vue index ababba32e..4d9b26460 100644 --- a/frontend/src/console/views/Home.vue +++ b/frontend/src/console/views/Home.vue @@ -40,7 +40,7 @@ import type { SimpleRom } from "@/stores/roms"; const { t } = useI18n(); const router = useRouter(); const platformsStore = storePlatforms(); -const { allPlatforms, fetchingPlatforms } = storeToRefs(platformsStore); +const { filledPlatforms, fetchingPlatforms } = storeToRefs(platformsStore); const collectionsStore = storeCollections(); const { allCollections, smartCollections, virtualCollections } = storeToRefs(collectionsStore); @@ -105,8 +105,8 @@ const virtualCollectionElementAt = (i: number) => // Spatial navigation const { moveLeft: moveSystemLeft, moveRight: moveSystemRight } = useSpatialNav( platformIndex, - () => allPlatforms.value.length || 1, - () => allPlatforms.value.length, + () => filledPlatforms.value.length || 1, + () => filledPlatforms.value.length, ); const { moveLeft: moveContinuePlayingLeft, @@ -223,7 +223,7 @@ const navigationFunctions = { const before = platformIndex.value; moveSystemLeft(); if (platformIndex.value === before) { - platformIndex.value = Math.max(0, allPlatforms.value.length - 1); + platformIndex.value = Math.max(0, filledPlatforms.value.length - 1); } }, next: () => { @@ -234,10 +234,10 @@ const navigationFunctions = { } }, confirm: () => { - if (!allPlatforms.value[platformIndex.value]) return false; + if (!filledPlatforms.value[platformIndex.value]) return false; router.push({ name: ROUTES.CONSOLE_PLATFORM, - params: { id: allPlatforms.value[platformIndex.value].id }, + params: { id: filledPlatforms.value[platformIndex.value].id }, }); return true; }, @@ -649,7 +649,8 @@ onBeforeMount(async () => { onMounted(async () => { // Restore indices within bounds - if (platformIndex.value >= allPlatforms.value.length) platformIndex.value = 0; + if (platformIndex.value >= filledPlatforms.value.length) + platformIndex.value = 0; if (continuePlayingIndex.value >= continuePlayingRoms.value.length) continuePlayingIndex.value = 0; if (collectionsIndex.value >= allCollections.value.length) @@ -770,7 +771,7 @@ onUnmounted(() => { >
; EJS_VirtualGamepadSettings: Record; + EJS_volume: number; + EJS_paths: Record; + EJS_startButtonName: string; + EJS_softLoad: boolean; + EJS_screenCapture: object; + EJS_externalFiles: Record; + EJS_videoRotation: number; + EJS_fixedSaveInterval: number; + EJS_disableCue: boolean; + EJS_dontExtractRom: boolean; + EJS_dontExtractBIOS: boolean; + EJS_disableDatabases: boolean; + EJS_disableLocalStorage: boolean; + EJS_disableAutoUnload: boolean; + EJS_disableBatchBootup: boolean; EJS_onGameStart: () => void; EJS_onSaveState: (args: { screenshot: ArrayBuffer; @@ -147,6 +163,8 @@ window.EJS_disableAutoLang = true; const { EJS_DEBUG, EJS_CACHE_LIMIT, + EJS_DISABLE_AUTO_UNLOAD, + EJS_DISABLE_BATCH_BOOTUP, EJS_NETPLAY_ICE_SERVERS, EJS_NETPLAY_ENABLED, } = configStore.config; @@ -154,8 +172,10 @@ window.EJS_netplayServer = EJS_NETPLAY_ENABLED ? window.location.host : ""; window.EJS_netplayICEServers = EJS_NETPLAY_ENABLED ? EJS_NETPLAY_ICE_SERVERS : []; -if (EJS_CACHE_LIMIT !== null) window.EJS_CacheLimit = EJS_CACHE_LIMIT; window.EJS_DEBUG_XX = EJS_DEBUG; +window.EJS_disableAutoUnload = EJS_DISABLE_AUTO_UNLOAD; +window.EJS_disableBatchBootup = EJS_DISABLE_BATCH_BOOTUP; +if (EJS_CACHE_LIMIT !== null) window.EJS_CacheLimit = EJS_CACHE_LIMIT; onMounted(() => { window.scrollTo(0, 0);