Merge branch 'master' into romm-821

This commit is contained in:
Georges-Antoine Assi
2025-10-21 21:46:23 -04:00
149 changed files with 3104 additions and 1476 deletions

View File

@@ -20,19 +20,6 @@
</div>
</div>
# Table of Contents
- [Table of Contents](#table-of-contents)
- [Overview](#overview)
- [Features](#features)
- [Preview](#preview)
- [Installation](#installation)
- [Contributing](#contributing)
- [Community](#community)
- [Technical Support](#technical-support)
- [Project Support](#project-support)
- [Our Friends](#our-friends)
# Overview
RomM (ROM Manager) allows you to scan, enrich, browse and play your game collection with a clean and responsive interface. With support for multiple platforms, various naming schemes, and custom tags, RomM is a must-have for anyone who plays on emulators.
@@ -56,38 +43,51 @@ RomM (ROM Manager) allows you to scan, enrich, browse and play your game collect
| :------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------: |
| <img src=".github/resources/screenshots/preview-desktop.webp" alt="desktop preview" /> | <img style="width: 325px; aspect-ratio: auto;" src=".github/resources/screenshots/preview-mobile.webp" alt="mobile preview" /> |
# Installation
## Installation
To start using RomM, check out the [Quick Start Guide][docs-quick-start-guide] in the docs. If you are having issues with RomM, please review the page for [troubleshooting steps][docs-troubleshooting].
# Contributing
## Contributing
To contribute to RomM, please check [Contribution Guide](./CONTRIBUTING.md).
# Community
## Community
Here are a few projects maintained by members of our community. Please note that the RomM team does not regularly review their source code.
- [romm-comm][romm-comm-discord-bot]: Discord Bot by @idio-sync
- [DeckRommSync][deck-romm-sync]: SteamOS downloader and sync by @PeriBluGaming
- [RommBrowser][romm-browser]: An electron client for RomM by @smurflabs
- [RomM Android][romm-android]: An Android app for RomM by @mattsays
### Mobile
- [romm-mobile][romm-mobile]: Android (and soon iOS) app by @mattsays
- [romm-android][romm-android]: Android app by @samwelnella
### Desktop
- [RommBrowser][romm-browser]: Electron client by @smurflabs
- [RetroArch Sync][romm-retroarch-sync]: Sync RetroArch library with RomM by @Covin90
- [RomMate][rommate]: Desktop app for browsing your collection by @brenoprata10
- [romm-client][romm-client]: Desktop client by @chaun14
### Other
- [romm-comm][romm-comm-discord-bot]: Discord bot by @idio-sync
- [DeckRommSync][deck-romm-sync]: SteamOS downloader and syncer by @PeriBluGaming
- [GGRequestz][ggrequestz]: Game discovery and request tool by @XTREEMMAK
Join us on Discord, where you can ask questions, submit ideas, get help, showcase your collection, and discuss RomM with other users.
[![discord-invite-img]][discord-invite]
# Technical Support
## Technical Support
If you have any issues with RomM, please [open an issue](https://github.com/rommapp/romm/issues/new) in this repository.
# Project Support
## Project Support
Consider supporting the development of this project on Open Collective. All funds will be used to cover the costs of hosting, development, and maintenance of RomM.
[![oc-donate-img]][oc-donate]
# Our Friends
## Our Friends
Here are a few projects that we think you might like:
@@ -148,6 +148,11 @@ Here are a few projects that we think you might like:
[romm-comm-discord-bot]: https://github.com/idio-sync/romm-comm
[deck-romm-sync]: https://github.com/PeriBluGaming/DeckRommSync-Standalone
[romm-browser]: https://github.com/smurflabs/RommBrowser/
[romm-android]: https://github.com/mattsays/romm-android
[romm-mobile]: https://github.com/mattsays/romm-mobile
[playnite-app]: https://github.com/rommapp/playnite-plugin
[muos-app]: https://github.com/rommapp/muos-app
[ggrequestz]: https://github.com/XTREEMMAK/ggrequestz
[romm-client]: https://github.com/chaun14/romm-client
[romm-retroarch-sync]: https://github.com/Covin90/romm-retroarch-sync
[rommate]: https://github.com/brenoprata10/rommate
[romm-android]: https://github.com/samwelnella/romm-android

View File

@@ -32,11 +32,19 @@ def upgrade() -> None:
# Run a no-scan in the background on migrate
if not IS_PYTEST_RUN:
high_prio_queue.enqueue(
scan_platforms, [], ScanType.QUICK, [], [], job_timeout=SCAN_TIMEOUT
scan_platforms,
platform_ids=[],
metadata_sources=[],
scan_type=ScanType.QUICK,
job_timeout=SCAN_TIMEOUT,
)
high_prio_queue.enqueue(
scan_platforms, [], ScanType.HASHES, [], [], job_timeout=SCAN_TIMEOUT
scan_platforms,
platform_ids=[],
metadata_sources=[],
scan_type=ScanType.HASHES,
job_timeout=SCAN_TIMEOUT,
)

View File

@@ -176,11 +176,19 @@ def upgrade() -> None:
# Run a no-scan in the background on migrate
if not IS_PYTEST_RUN:
high_prio_queue.enqueue(
scan_platforms, [], ScanType.QUICK, [], [], job_timeout=SCAN_TIMEOUT
scan_platforms,
platform_ids=[],
metadata_sources=[],
scan_type=ScanType.QUICK,
job_timeout=SCAN_TIMEOUT,
)
high_prio_queue.enqueue(
scan_platforms, [], ScanType.HASHES, [], [], job_timeout=SCAN_TIMEOUT
scan_platforms,
platform_ids=[],
metadata_sources=[],
scan_type=ScanType.HASHES,
job_timeout=SCAN_TIMEOUT,
)

View File

@@ -0,0 +1,42 @@
"""empty message
Revision ID: 0055_collection_is_favorite
Revises: 0054_add_platform_metadata_slugs
Create Date: 2025-10-18 13:24:15.119652
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0055_collection_is_favorite"
down_revision = "0054_add_platform_metadata_slugs"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("collections", schema=None) as batch_op:
batch_op.add_column(sa.Column("is_favorite", sa.Boolean(), nullable=False))
# Find favorite collection and set is_favorite to True
from handler.database import db_collection_handler, db_user_handler
users = db_user_handler.get_users()
for user in users:
collection = db_collection_handler.get_collection_by_name("favourites", user.id)
if not collection:
collection = db_collection_handler.get_collection_by_name(
"favorites", user.id
)
if collection:
db_collection_handler.update_collection(
collection.id, {"is_favorite": True}
)
def downgrade() -> None:
with op.batch_alter_table("collections", schema=None) as batch_op:
batch_op.drop_column("is_favorite")

View File

@@ -137,6 +137,7 @@ OIDC_TLS_CACERTFILE: Final = os.environ.get("OIDC_TLS_CACERTFILE", None)
# SCANS
SCAN_TIMEOUT: Final = int(os.environ.get("SCAN_TIMEOUT", 60 * 60 * 4)) # 4 hours
SCAN_WORKERS: Final = max(1, int(os.environ.get("SCAN_WORKERS", "1")))
# TASKS
TASK_TIMEOUT: Final = int(os.environ.get("TASK_TIMEOUT", 60 * 5)) # 5 minutes

View File

@@ -46,6 +46,7 @@ EjsOption = dict[str, str] # option_name -> option_value
class Config:
CONFIG_FILE_MOUNTED: bool
CONFIG_FILE_WRITABLE: bool
EXCLUDED_PLATFORMS: list[str]
EXCLUDED_SINGLE_EXT: list[str]
EXCLUDED_SINGLE_FILES: list[str]
@@ -82,6 +83,7 @@ class ConfigManager:
_self = None
_raw_config: dict = {}
_config_file_mounted: bool = False
_config_file_writable: bool = False
def __new__(cls, *args, **kwargs):
if cls._self is None:
@@ -94,17 +96,19 @@ class ConfigManager:
self.config_file = config_file
try:
with open(self.config_file, "r+") as cf:
# Check if the config file is mounted
with open(self.config_file, "r") as cf:
self._config_file_mounted = True
self._raw_config = yaml.load(cf, Loader=SafeLoader) or {}
# Also check if the config file is writable
self._config_file_writable = os.access(self.config_file, os.W_OK)
except FileNotFoundError:
self._config_file_mounted = False
log.critical(
"Config file not found! Any changes made to the configuration will not persist after the application restarts."
)
except PermissionError:
self._config_file_mounted = False
log.critical(
log.warning(
"Config file not writable! Any changes made to the configuration will not persist after the application restarts."
)
finally:
@@ -159,6 +163,7 @@ class ConfigManager:
self.config = Config(
CONFIG_FILE_MOUNTED=self._config_file_mounted,
CONFIG_FILE_WRITABLE=self._config_file_writable,
EXCLUDED_PLATFORMS=pydash.get(self._raw_config, "exclude.platforms", []),
EXCLUDED_SINGLE_EXT=[
e.lower()
@@ -417,11 +422,10 @@ class ConfigManager:
def get_config(self) -> Config:
try:
with open(self.config_file, "r+") as config_file:
with open(self.config_file, "r") as config_file:
self._raw_config = yaml.load(config_file, Loader=SafeLoader) or {}
except (FileNotFoundError, PermissionError):
log.debug("Config file not found or not writable")
pass
except FileNotFoundError:
log.debug("Config file not found!")
self._parse_config()
self._validate_config()
@@ -429,8 +433,8 @@ class ConfigManager:
return self.config
def _update_config_file(self) -> None:
if not self._config_file_mounted:
log.warning("Config file not mounted, skipping config file update")
if not self._config_file_writable:
log.warning("Config file not writable, skipping config file update")
raise ConfigNotWritableException
self._raw_config = {

View File

@@ -53,6 +53,7 @@ async def add_collection(
"description": data.get("description", ""),
"url_cover": data.get("url_cover", ""),
"is_public": data.get("is_public", False),
"is_favorite": data.get("is_favorite", False),
"user_id": request.user.id,
}
db_collection = db_collection_handler.get_collection_by_name(

View File

@@ -25,6 +25,7 @@ def get_config() -> ConfigResponse:
cfg = cm.get_config()
return ConfigResponse(
CONFIG_FILE_MOUNTED=cfg.CONFIG_FILE_MOUNTED,
CONFIG_FILE_WRITABLE=cfg.CONFIG_FILE_WRITABLE,
EXCLUDED_PLATFORMS=cfg.EXCLUDED_PLATFORMS,
EXCLUDED_SINGLE_EXT=cfg.EXCLUDED_SINGLE_EXT,
EXCLUDED_SINGLE_FILES=cfg.EXCLUDED_SINGLE_FILES,

View File

@@ -12,10 +12,10 @@ class ScanStats(TypedDict):
new_platforms: int
identified_platforms: int
scanned_roms: int
added_roms: int
new_roms: int
identified_roms: int
scanned_firmware: int
added_firmware: int
new_firmware: int
class ScanTaskMeta(TypedDict):

View File

@@ -5,6 +5,7 @@ from config.config_manager import EjsControls
class ConfigResponse(TypedDict):
CONFIG_FILE_MOUNTED: bool
CONFIG_FILE_WRITABLE: bool
EXCLUDED_PLATFORMS: list[str]
EXCLUDED_SINGLE_EXT: list[str]
EXCLUDED_SINGLE_FILES: list[str]

View File

@@ -1,8 +1,10 @@
from typing import Annotated, Any, NotRequired, TypedDict
from typing import Annotated, Any, Final, NotRequired, TypedDict
from pydantic import BaseModel, BeforeValidator, Field
from pydantic import BaseModel, BeforeValidator, Field, field_validator
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
from tasks.scheduled.update_switch_titledb import TITLEDB_REGION_LIST
from utils.database import safe_int
WEBRCADE_SUPPORTED_PLATFORM_SLUGS = frozenset(
(
@@ -64,29 +66,6 @@ WEBRCADE_SLUG_TO_TYPE_MAP = {
# Webrcade feed format
# Source: https://docs.webrcade.com/feeds/format/
def coerce_to_string(value: Any) -> str:
"""Coerce value to string, returning empty string for None."""
return "" if value is None else str(value)
def coerce_to_int(value: Any) -> int:
"""Coerce value to int, returning 0 for None/empty values."""
if value in (None, ""):
return 0
try:
return int(value)
except (ValueError, TypeError):
return 0
# Annotated types for cleaner field definitions
StringField = Annotated[str, BeforeValidator(coerce_to_string)]
IntField = Annotated[int, BeforeValidator(coerce_to_int)]
class WebrcadeFeedItemPropsSchema(TypedDict):
rom: str
@@ -122,6 +101,23 @@ class WebrcadeFeedSchema(TypedDict):
# Tinfoil feed format
# Source: https://blawar.github.io/tinfoil/custom_index/
UNIX_EPOCH_START_DATE: Final = 19700101
def coerce_to_string(value: Any) -> str:
"""Coerce value to string, returning empty string for None."""
return "" if value is None else str(value)
def coerce_to_int(value: Any) -> int:
"""Coerce value to int, returning 0 for None/empty values."""
return safe_int(value, default=0)
# Annotated types for cleaner field definitions
StringField = Annotated[str, BeforeValidator(coerce_to_string)]
IntField = Annotated[int, BeforeValidator(coerce_to_int)]
class TinfoilFeedFileSchema(TypedDict):
url: str
@@ -142,10 +138,24 @@ class TinfoilFeedTitleDBSchema(BaseModel):
publisher: StringField = Field(default="")
size: IntField = Field(default=0, ge=0)
version: IntField = Field(default=0, ge=0)
releaseDate: IntField = Field(default=19700101, ge=19700101)
releaseDate: IntField = Field(
default=UNIX_EPOCH_START_DATE, ge=UNIX_EPOCH_START_DATE
)
rating: IntField = Field(default=0, ge=0, le=100)
rank: IntField = Field(default=0, ge=0)
@field_validator("region")
def validate_region(cls, v: str) -> str:
if v not in TITLEDB_REGION_LIST:
return "US"
return v
@field_validator("releaseDate")
def validate_release_date(cls, v: int) -> int:
if v < UNIX_EPOCH_START_DATE:
return UNIX_EPOCH_START_DATE
return v
class TinfoilFeedSchema(TypedDict):
files: list[TinfoilFeedFileSchema]

View File

@@ -214,7 +214,6 @@ class RomSchema(BaseModel):
platform_id: int
platform_slug: str
platform_fs_slug: str
platform_name: str
platform_custom_name: str | None
platform_display_name: str

View File

@@ -11,7 +11,6 @@ class SearchRomSchema(BaseModel):
sgdb_id: int | None = None
flashpoint_id: str | None = None
launchbox_id: int | None = None
hltb_id: int | None = None
platform_id: int
name: str
slug: str = ""
@@ -22,7 +21,6 @@ class SearchRomSchema(BaseModel):
sgdb_url_cover: str = ""
flashpoint_url_cover: str = ""
launchbox_url_cover: str = ""
hltb_url_cover: str = ""
is_unidentified: bool
is_identified: bool

View File

@@ -1,4 +1,5 @@
import binascii
import json
from base64 import b64encode
from datetime import datetime, timezone
from io import BytesIO
@@ -21,6 +22,7 @@ from fastapi import (
UploadFile,
status,
)
from fastapi.datastructures import FormData
from fastapi.responses import Response
from fastapi_pagination.ext.sqlalchemy import paginate
from fastapi_pagination.limit_offset import LimitOffsetPage, LimitOffsetParams
@@ -53,16 +55,17 @@ from handler.filesystem import fs_resource_handler, fs_rom_handler
from handler.filesystem.base_handler import CoverSize
from handler.metadata import (
meta_flashpoint_handler,
meta_hltb_handler,
meta_igdb_handler,
meta_launchbox_handler,
meta_moby_handler,
meta_ra_handler,
meta_ss_handler,
)
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
from models.rom import Rom
from utils.database import safe_int
from utils.filesystem import sanitize_filename
from utils.hashing import crc32_to_hex
from utils.nginx import FileRedirectResponse, ZipContentLine, ZipResponse
@@ -74,6 +77,18 @@ router = APIRouter(
)
def parse_raw_metadata(data: FormData, form_key: str) -> dict | None:
raw_json = data.get(form_key, None)
if not raw_json or str(raw_json).strip() == "":
return None
try:
return json.loads(str(raw_json))
except json.JSONDecodeError as e:
log.warning(f"Invalid JSON for {form_key}: {e}")
return None
@protected_route(
router.post,
"",
@@ -197,9 +212,9 @@ def get_roms(
bool | None,
Query(description="Whether the rom matched a metadata source."),
] = None,
favourite: Annotated[
favorite: Annotated[
bool | None,
Query(description="Whether the rom is marked as favourite."),
Query(description="Whether the rom is marked as favorite."),
] = None,
duplicate: Annotated[
bool | None,
@@ -289,7 +304,7 @@ def get_roms(
smart_collection_id=smart_collection_id,
search_term=search_term,
matched=matched,
favourite=favourite,
favorite=favorite,
duplicate=duplicate,
playable=playable,
has_ra=has_ra,
@@ -705,6 +720,8 @@ async def update_rom(
"ss_id": None,
"ra_id": None,
"launchbox_id": None,
"hasheous_id": None,
"tgdb_id": None,
"flashpoint_id": None,
"hltb_id": None,
"name": rom.fs_name,
@@ -721,6 +738,7 @@ async def update_rom(
"ss_metadata": {},
"ra_metadata": {},
"launchbox_metadata": {},
"hasheous_metadata": {},
"flashpoint_metadata": {},
"hltb_metadata": {},
"revision": "",
@@ -734,81 +752,99 @@ async def update_rom(
return DetailedRomSchema.from_orm_with_request(rom, request)
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),
"launchbox_id": data.get("launchbox_id", rom.launchbox_id),
"flashpoint_id": data.get("flashpoint_id", rom.flashpoint_id),
"hltb_id": data.get("hltb_id", rom.hltb_id),
"igdb_id": safe_int(data.get("igdb_id")) or rom.igdb_id,
"sgdb_id": safe_int(data.get("sgdb_id")) or rom.sgdb_id,
"moby_id": safe_int(data.get("moby_id")) or rom.moby_id,
"ss_id": safe_int(data.get("ss_id")) or rom.ss_id,
"ra_id": safe_int(data.get("ra_id")) or rom.ra_id,
"launchbox_id": safe_int(data.get("launchbox_id")) or rom.launchbox_id,
"hasheous_id": safe_int(data.get("hasheous_id")) or rom.hasheous_id,
"tgdb_id": safe_int(data.get("tgdb_id")) or rom.tgdb_id,
"flashpoint_id": safe_int(data.get("flashpoint_id")) or rom.flashpoint_id,
"hltb_id": safe_int(data.get("hltb_id")) or rom.hltb_id,
}
if (
cleaned_data.get("hltb_id", "")
and int(cleaned_data.get("hltb_id", "")) != rom.hltb_id
):
hltb_rom = await meta_hltb_handler.get_rom_by_id(cleaned_data["hltb_id"])
cleaned_data.update(hltb_rom)
# Add raw metadata parsing
raw_igdb_metadata = parse_raw_metadata(data, "raw_igdb_metadata")
raw_moby_metadata = parse_raw_metadata(data, "raw_moby_metadata")
raw_ss_metadata = parse_raw_metadata(data, "raw_ss_metadata")
raw_launchbox_metadata = parse_raw_metadata(data, "raw_launchbox_metadata")
raw_hasheous_metadata = parse_raw_metadata(data, "raw_hasheous_metadata")
raw_flashpoint_metadata = parse_raw_metadata(data, "raw_flashpoint_metadata")
raw_hltb_metadata = parse_raw_metadata(data, "raw_hltb_metadata")
if cleaned_data["igdb_id"] and raw_igdb_metadata is not None:
cleaned_data["igdb_metadata"] = raw_igdb_metadata
if cleaned_data["moby_id"] and raw_moby_metadata is not None:
cleaned_data["moby_metadata"] = raw_moby_metadata
if cleaned_data["ss_id"] and raw_ss_metadata is not None:
cleaned_data["ss_metadata"] = raw_ss_metadata
if cleaned_data["launchbox_id"] and raw_launchbox_metadata is not None:
cleaned_data["launchbox_metadata"] = raw_launchbox_metadata
if cleaned_data["hasheous_id"] and raw_hasheous_metadata is not None:
cleaned_data["hasheous_metadata"] = raw_hasheous_metadata
if cleaned_data["flashpoint_id"] and raw_flashpoint_metadata is not None:
cleaned_data["flashpoint_metadata"] = raw_flashpoint_metadata
if cleaned_data["hltb_id"] and raw_hltb_metadata is not None:
cleaned_data["hltb_metadata"] = raw_hltb_metadata
# Fetch metadata from external sources
if (
cleaned_data.get("flashpoint_id", "")
and cleaned_data.get("flashpoint_id", "") != rom.flashpoint_id
cleaned_data["flashpoint_id"]
and cleaned_data["flashpoint_id"] != rom.flashpoint_id
):
flashpoint_rom = await meta_flashpoint_handler.get_rom_by_id(
cleaned_data["flashpoint_id"]
)
cleaned_data.update(flashpoint_rom)
elif rom.flashpoint_id and not cleaned_data["flashpoint_id"]:
cleaned_data.update({"flashpoint_id": None, "flashpoint_metadata": {}})
if (
cleaned_data.get("launchbox_id", "")
and int(cleaned_data.get("launchbox_id", "")) != rom.launchbox_id
cleaned_data["launchbox_id"]
and int(cleaned_data["launchbox_id"]) != rom.launchbox_id
):
launchbox_rom = await meta_launchbox_handler.get_rom_by_id(
cleaned_data["launchbox_id"]
)
cleaned_data.update(launchbox_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})
elif rom.launchbox_id and not cleaned_data["launchbox_id"]:
cleaned_data.update({"launchbox_id": None, "launchbox_metadata": {}})
if (
cleaned_data.get("moby_id", "")
and int(cleaned_data.get("moby_id", "")) != rom.moby_id
):
if cleaned_data["ra_id"] and int(cleaned_data["ra_id"]) != rom.ra_id:
ra_rom = await meta_ra_handler.get_rom_by_id(rom, ra_id=cleaned_data["ra_id"])
cleaned_data.update(ra_rom)
elif rom.ra_id and not cleaned_data["ra_id"]:
cleaned_data.update({"ra_id": None, "ra_metadata": {}})
if cleaned_data["moby_id"] and int(cleaned_data["moby_id"]) != rom.moby_id:
moby_rom = await meta_moby_handler.get_rom_by_id(
int(cleaned_data.get("moby_id", ""))
)
cleaned_data.update(moby_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})
elif rom.moby_id and not cleaned_data["moby_id"]:
cleaned_data.update({"moby_id": None, "moby_metadata": {}})
if (
cleaned_data.get("ss_id", "")
and int(cleaned_data.get("ss_id", "")) != rom.ss_id
):
if cleaned_data["ss_id"] and int(cleaned_data["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})
elif rom.ss_id and not cleaned_data["ss_id"]:
cleaned_data.update({"ss_id": None, "ss_metadata": {}})
if (
cleaned_data.get("igdb_id", "")
and int(cleaned_data.get("igdb_id", "")) != rom.igdb_id
):
if cleaned_data["igdb_id"] and int(cleaned_data["igdb_id"]) != rom.igdb_id:
igdb_rom = await meta_igdb_handler.get_rom_by_id(cleaned_data["igdb_id"])
cleaned_data.update(igdb_rom)
elif rom.igdb_id and not cleaned_data["igdb_id"]:
cleaned_data.update({"igdb_id": None, "igdb_metadata": {}})
if cleaned_data.get("url_screenshots", []):
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})
cleaned_data.update(
{"path_screenshots": path_screenshots, "url_screenshots": []}
)
cleaned_data.update(
{

View File

@@ -9,7 +9,6 @@ from handler.auth.constants import Scope
from handler.database import db_rom_handler
from handler.metadata import (
meta_flashpoint_handler,
meta_hltb_handler,
meta_igdb_handler,
meta_launchbox_handler,
meta_moby_handler,
@@ -17,7 +16,6 @@ from handler.metadata import (
meta_ss_handler,
)
from handler.metadata.flashpoint_handler import FlashpointRom
from handler.metadata.hltb_handler import HLTBRom
from handler.metadata.igdb_handler import IGDBRom
from handler.metadata.launchbox_handler import LaunchboxRom
from handler.metadata.moby_handler import MobyGamesRom
@@ -63,7 +61,6 @@ async def search_rom(
and not meta_moby_handler.is_enabled()
and not meta_flashpoint_handler.is_enabled()
and not meta_launchbox_handler.is_enabled()
and not meta_hltb_handler.is_enabled()
):
log.error("Search error: No metadata providers enabled")
raise HTTPException(
@@ -94,7 +91,6 @@ async def search_rom(
ss_matched_roms: list[SSRom] = []
flashpoint_matched_roms: list[FlashpointRom] = []
launchbox_matched_roms: list[LaunchboxRom] = []
hltb_matched_roms: list[HLTBRom] = []
if search_by.lower() == "id":
try:
@@ -120,7 +116,6 @@ async def search_rom(
ss_matched_roms,
flashpoint_matched_roms,
launchbox_matched_roms,
hltb_matched_roms,
) = await asyncio.gather(
meta_igdb_handler.get_matched_roms_by_name(
search_term, get_main_platform_igdb_id(rom.platform)
@@ -135,7 +130,6 @@ async def search_rom(
meta_launchbox_handler.get_matched_roms_by_name(
search_term, rom.platform.slug
),
meta_hltb_handler.get_matched_roms_by_name(search_term, rom.platform.slug),
)
merged_dict: dict[str, dict] = {}
@@ -215,21 +209,6 @@ async def search_rom(
**merged_dict.get(launchbox_name, {}),
}
for hltb_rom in hltb_matched_roms:
if hltb_rom["hltb_id"]:
hltb_name = meta_hltb_handler.normalize_search_term(
hltb_rom.get("name", ""),
remove_articles=False,
)
merged_dict[hltb_name] = {
**hltb_rom,
"is_identified": True,
"is_unidentified": False,
"platform_id": rom.platform_id,
"hltb_url_cover": hltb_rom.pop("url_cover", ""),
**merged_dict.get(hltb_name, {}),
}
async def get_sgdb_rom(name: str) -> tuple[str, SGDBRom]:
return name, await meta_sgdb_handler.get_details_by_names([name])

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from itertools import batched
from typing import Any, Final
@@ -8,7 +9,7 @@ import socketio # type: ignore
from rq import Worker
from rq.job import Job
from config import DEV_MODE, REDIS_URL, SCAN_TIMEOUT, TASK_RESULT_TTL
from config import DEV_MODE, REDIS_URL, SCAN_TIMEOUT, SCAN_WORKERS, TASK_RESULT_TTL
from endpoints.responses import TaskType
from endpoints.responses.platform import PlatformSchema
from endpoints.responses.rom import SimpleRomSchema
@@ -55,17 +56,33 @@ class ScanStats:
new_platforms: int = 0
identified_platforms: int = 0
scanned_roms: int = 0
added_roms: int = 0
new_roms: int = 0
identified_roms: int = 0
scanned_firmware: int = 0
added_firmware: int = 0
new_firmware: int = 0
def update(self, **kwargs):
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
def __post_init__(self):
# Lock for thread-safe updates
self._lock = asyncio.Lock()
update_job_meta({"scan_stats": self.to_dict()})
async def update(self, socket_manager: socketio.AsyncRedisManager, **kwargs):
async with self._lock:
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
update_job_meta({"scan_stats": self.to_dict()})
await socket_manager.emit("scan:update_stats", self.to_dict())
async def increment(self, socket_manager: socketio.AsyncRedisManager, **kwargs):
async with self._lock:
for key, value in kwargs.items():
if hasattr(self, key):
current_value = getattr(self, key)
setattr(self, key, current_value + value)
update_job_meta({"scan_stats": self.to_dict()})
await socket_manager.emit("scan:update_stats", self.to_dict())
def to_dict(self) -> dict[str, Any]:
return {
@@ -75,10 +92,10 @@ class ScanStats:
"new_platforms": self.new_platforms,
"identified_platforms": self.identified_platforms,
"scanned_roms": self.scanned_roms,
"added_roms": self.added_roms,
"new_roms": self.new_roms,
"identified_roms": self.identified_roms,
"scanned_firmware": self.scanned_firmware,
"added_firmware": self.added_firmware,
"new_firmware": self.new_firmware,
}
@@ -91,10 +108,11 @@ async def _identify_firmware(
platform: Platform,
fs_fw: str,
scan_stats: ScanStats,
) -> ScanStats:
socket_manager: socketio.AsyncRedisManager,
) -> None:
# Break early if the flag is set
if redis_client.get(STOP_SCAN_FLAG):
return scan_stats
return
firmware = db_firmware_handler.get_firmware_by_filename(platform.id, fs_fw)
@@ -113,25 +131,24 @@ async def _identify_firmware(
crc_hash=scanned_firmware.crc_hash,
)
scan_stats.update(
scanned_firmware=scan_stats.scanned_firmware + 1,
added_firmware=scan_stats.added_firmware + (1 if not firmware else 0),
await scan_stats.increment(
socket_manager=socket_manager,
scanned_firmware=1,
new_firmware=1 if not firmware else 0,
)
scanned_firmware.missing_from_fs = False
scanned_firmware.is_verified = is_verified
db_firmware_handler.add_firmware(scanned_firmware)
return scan_stats
def _should_scan_rom(scan_type: ScanType, rom: Rom | None, roms_ids: list[int]) -> bool:
"""Decide if a rom should be scanned or not
Args:
scan_type (str): Type of scan to be performed.
roms_ids (list[int], optional): List of selected roms to be scanned.
metadata_sources (list[str], optional): List of metadata sources to be used
scan_type (ScanType): Type of scan to be performed.
rom (Rom | None): The rom to be scanned.
roms_ids (list[int]): List of selected roms to be scanned.
"""
# This logic is tricky so only touch it if you know what you're doing"""
@@ -165,10 +182,10 @@ async def _identify_rom(
metadata_sources: list[str],
socket_manager: socketio.AsyncRedisManager,
scan_stats: ScanStats,
) -> ScanStats:
) -> None:
# Break early if the flag is set
if redis_client.get(STOP_SCAN_FLAG):
return scan_stats
return
if not _should_scan_rom(scan_type=scan_type, rom=rom, roms_ids=roms_ids):
if rom:
@@ -180,8 +197,7 @@ async def _identify_rom(
if rom.missing_from_fs:
db_rom_handler.update_rom(rom.id, {"missing_from_fs": False})
scan_stats.update(scanned_roms=scan_stats.scanned_roms + 1)
return scan_stats
return
# Update properties that don't require metadata
fs_regions, fs_revisions, fs_languages, fs_other_tags = fs_rom_handler.parse_tags(
@@ -217,7 +233,7 @@ async def _identify_rom(
# Silly checks to make the type checker happy
if not rom:
return scan_stats
return
# Build rom files object before scanning
log.debug(f"Calculating file hashes for {rom.fs_name}...")
@@ -245,11 +261,11 @@ async def _identify_rom(
socket_manager=socket_manager,
)
scan_stats.update(
scanned_roms=scan_stats.scanned_roms + 1,
added_roms=scan_stats.added_roms + (1 if not rom else 0),
identified_roms=scan_stats.identified_roms
+ (1 if scanned_rom.is_identified else 0),
await scan_stats.increment(
socket_manager=socket_manager,
scanned_roms=1,
new_roms=1 if newly_added else 0,
identified_roms=1 if scanned_rom.is_identified else 0,
)
_added_rom = db_rom_handler.add_rom(scanned_rom)
@@ -340,9 +356,6 @@ async def _identify_rom(
exclude={"created_at", "updated_at", "rom_user"}
),
)
await socket_manager.emit("", None)
return scan_stats
async def _identify_platform(
@@ -366,11 +379,11 @@ async def _identify_platform(
if platform:
scanned_platform.id = platform.id
scan_stats.update(
scanned_platforms=scan_stats.scanned_platforms + 1,
new_platforms=scan_stats.new_platforms + (1 if not platform else 0),
identified_platforms=scan_stats.identified_platforms
+ (1 if scanned_platform.is_identified else 0),
await scan_stats.increment(
socket_manager=socket_manager,
scanned_platforms=1,
new_platforms=1 if not platform else 0,
identified_platforms=1 if scanned_platform.is_identified else 0,
)
platform = db_platform_handler.add_platform(scanned_platform)
@@ -378,10 +391,9 @@ async def _identify_platform(
await socket_manager.emit(
"scan:scanning_platform",
PlatformSchema.model_validate(platform).model_dump(
include={"id", "name", "slug", "fs_slug", "is_identified"}
include={"id", "name", "display_name", "slug", "fs_slug", "is_identified"}
),
)
await socket_manager.emit("", None)
# Scanning firmware
try:
@@ -397,7 +409,8 @@ async def _identify_platform(
log.info(f"{hl(str(len(fs_firmware)))} firmware files found")
for fs_fw in fs_firmware:
scan_stats = await _identify_firmware(
await _identify_firmware(
socket_manager=socket_manager,
platform=platform,
fs_fw=fs_fw,
scan_stats=scan_stats,
@@ -417,17 +430,16 @@ async def _identify_platform(
else:
log.info(f"{hl(str(len(fs_roms)))} roms found in the file system")
for fs_roms_batch in batched(fs_roms, 200, strict=False):
rom_by_filename_map = db_rom_handler.get_roms_by_fs_name(
platform_id=platform.id,
fs_names={fs_rom["fs_name"] for fs_rom in fs_roms_batch},
)
# Create semaphore to limit concurrent ROM scanning
scan_semaphore = asyncio.Semaphore(SCAN_WORKERS)
for fs_rom in fs_roms_batch:
scan_stats = await _identify_rom(
async def scan_rom_with_semaphore(fs_rom: FSRom, rom: Rom | None) -> None:
"""Scan a single ROM with semaphore limiting"""
async with scan_semaphore:
await _identify_rom(
platform=platform,
fs_rom=fs_rom,
rom=rom_by_filename_map.get(fs_rom["fs_name"]),
rom=rom,
scan_type=scan_type,
roms_ids=roms_ids,
metadata_sources=metadata_sources,
@@ -435,6 +447,26 @@ async def _identify_platform(
scan_stats=scan_stats,
)
for fs_roms_batch in batched(fs_roms, 200, strict=False):
roms_by_fs_name = db_rom_handler.get_roms_by_fs_name(
platform_id=platform.id,
fs_names={fs_rom["fs_name"] for fs_rom in fs_roms_batch},
)
# Process ROMs concurrently within the batch
scan_tasks = [
scan_rom_with_semaphore(
fs_rom=fs_rom, rom=roms_by_fs_name.get(fs_rom["fs_name"])
)
for fs_rom in fs_roms_batch
]
# Wait for all ROMs in the batch to complete
batched_results = await asyncio.gather(*scan_tasks, return_exceptions=True)
for result, fs_rom in zip(batched_results, fs_roms_batch, strict=False):
if isinstance(result, Exception):
log.error(f"Error scanning ROM {fs_rom['fs_name']}: {result}")
missing_roms = db_rom_handler.mark_missing_roms(
platform.id, [rom["fs_name"] for rom in fs_roms]
)
@@ -457,17 +489,17 @@ async def _identify_platform(
@initialize_context()
async def scan_platforms(
platform_ids: list[int],
metadata_sources: list[str],
scan_type: ScanType = ScanType.QUICK,
roms_ids: list[int] | None = None,
metadata_sources: list[str] | None = None,
) -> ScanStats:
"""Scan all the listed platforms and fetch metadata from different sources
Args:
platform_slugs (list[str]): List of platform slugs to be scanned
scan_type (str): Type of scan to be performed. Defaults to "quick".
roms_ids (list[int], optional): List of selected roms to be scanned. Defaults to [].
metadata_sources (list[str], optional): List of metadata sources to be used. Defaults to all sources.
platform_ids (list[int]): List of platform ids to be scanned
metadata_sources (list[str]): List of metadata sources to be used
scan_type (ScanType): Type of scan to be performed.
roms_ids (list[int], optional): List of selected roms to be scanned.
"""
if not roms_ids:
@@ -476,11 +508,6 @@ async def scan_platforms(
socket_manager = _get_socket_manager()
scan_stats = ScanStats()
if not metadata_sources:
log.error("No metadata sources provided")
await socket_manager.emit("scan:done_ko", "No metadata sources provided")
return scan_stats
try:
fs_platforms: list[str] = await fs_platform_handler.get_platforms()
except FolderStructureNotMatchException as e:
@@ -489,10 +516,15 @@ async def scan_platforms(
return scan_stats
# Precalculate total platforms and ROMs
scan_stats.update(total_platforms=len(fs_platforms))
total_roms = 0
for platform_slug in fs_platforms:
fs_roms = await fs_rom_handler.get_roms(Platform(fs_slug=platform_slug))
scan_stats.update(total_roms=scan_stats.total_roms + len(fs_roms))
total_roms += len(fs_roms)
await scan_stats.update(
socket_manager=socket_manager,
total_platforms=len(fs_platforms),
total_roms=total_roms,
)
async def stop_scan():
log.info(f"{emoji.EMOJI_STOP_SIGN} Scan stopped manually")
@@ -566,17 +598,17 @@ async def scan_handler(_sid: str, options: dict[str, Any]):
if DEV_MODE:
return await scan_platforms(
platform_ids=platform_ids,
metadata_sources=metadata_sources,
scan_type=scan_type,
roms_ids=roms_ids,
metadata_sources=metadata_sources,
)
return high_prio_queue.enqueue(
scan_platforms,
platform_ids,
scan_type,
roms_ids,
metadata_sources,
platform_ids=platform_ids,
metadata_sources=metadata_sources,
scan_type=scan_type,
roms_ids=roms_ids,
job_timeout=SCAN_TIMEOUT, # Timeout (default of 4 hours)
result_ttl=TASK_RESULT_TTL,
meta={

View File

@@ -332,7 +332,7 @@ class OpenIDHandler:
role = Role.VIEWER
if OIDC_CLAIM_ROLES and OIDC_CLAIM_ROLES in userinfo:
roles = userinfo[OIDC_CLAIM_ROLES]
roles = userinfo[OIDC_CLAIM_ROLES] or []
if OIDC_ROLE_ADMIN and OIDC_ROLE_ADMIN in roles:
role = Role.ADMIN
elif OIDC_ROLE_EDITOR and OIDC_ROLE_EDITOR in roles:

View File

@@ -59,6 +59,15 @@ class DBCollectionsHandler(DBBaseHandler):
) -> Collection | None:
return session.scalar(query.filter_by(name=name, user_id=user_id).limit(1))
@begin_session
@with_roms
def get_favorite_collection(
self, user_id: int, query: Query = None, session: Session = None
) -> Collection | None:
return session.scalar(
query.filter_by(is_favorite=True, user_id=user_id).limit(1)
)
@begin_session
@with_roms
def get_collections(
@@ -211,7 +220,7 @@ class DBCollectionsHandler(DBBaseHandler):
virtual_collection_id=criteria.get("virtual_collection_id"),
search_term=criteria.get("search_term"),
matched=criteria.get("matched"),
favourite=criteria.get("favourite"),
favorite=criteria.get("favorite"),
duplicate=criteria.get("duplicate"),
playable=criteria.get("playable"),
has_ra=criteria.get("has_ra"),

View File

@@ -245,27 +245,24 @@ class DBRomsHandler(DBBaseHandler):
predicate = not_(predicate)
return query.filter(predicate)
def filter_by_favourite(
def filter_by_favorite(
self, query: Query, session: Session, value: bool, user_id: int | None
) -> Query:
"""Filter based on whether the rom is in the user's Favourites collection."""
"""Filter based on whether the rom is in the user's favorites collection."""
if not user_id:
return query
from . import db_collection_handler
favourites_collection = db_collection_handler.get_collection_by_name(
"favourites", user_id
)
if favourites_collection:
predicate = Rom.id.in_(favourites_collection.rom_ids)
favorites_collection = db_collection_handler.get_favorite_collection(user_id)
if favorites_collection:
predicate = Rom.id.in_(favorites_collection.rom_ids)
if not value:
predicate = not_(predicate)
return query.filter(predicate)
# If no Favourites collection exists, return the original query if non-favourites
# were requested, or an empty query if favourites were requested.
# If no favorites collection exists, return the original query if non-favorites
# were requested, or an empty query if favorites were requested.
if not value:
return query
return query.filter(false())
@@ -377,7 +374,7 @@ class DBRomsHandler(DBBaseHandler):
smart_collection_id: int | None = None,
search_term: str | None = None,
matched: bool | None = None,
favourite: bool | None = None,
favorite: bool | None = None,
duplicate: bool | None = None,
playable: bool | None = None,
has_ra: bool | None = None,
@@ -419,9 +416,9 @@ class DBRomsHandler(DBBaseHandler):
if matched is not None:
query = self.filter_by_matched(query, value=matched)
if favourite is not None:
query = self.filter_by_favourite(
query, session=session, value=favourite, user_id=user_id
if favorite is not None:
query = self.filter_by_favorite(
query, session=session, value=favorite, user_id=user_id
)
if duplicate is not None:
@@ -651,7 +648,7 @@ class DBRomsHandler(DBBaseHandler):
virtual_collection_id=kwargs.get("virtual_collection_id", None),
search_term=kwargs.get("search_term", None),
matched=kwargs.get("matched", None),
favourite=kwargs.get("favourite", None),
favorite=kwargs.get("favorite", None),
duplicate=kwargs.get("duplicate", None),
playable=kwargs.get("playable", None),
has_ra=kwargs.get("has_ra", None),

View File

@@ -330,16 +330,29 @@ class FSRomsHandler(FSHandler):
):
continue
# Check if this is a top-level file (not in a subdirectory)
is_top_level = f_path.samefile(Path(abs_fs_path, rom.fs_name))
if hashable_platform:
try:
crc_c, rom_crc_c, md5_h, rom_md5_h, sha1_h, rom_sha1_h = (
self._calculate_rom_hashes(
Path(f_path, file_name),
rom_crc_c,
rom_md5_h,
rom_sha1_h,
if is_top_level:
# Include this file in the main ROM hash calculation
crc_c, rom_crc_c, md5_h, rom_md5_h, sha1_h, rom_sha1_h = (
self._calculate_rom_hashes(
Path(f_path, file_name),
rom_crc_c,
rom_md5_h,
rom_sha1_h,
)
)
else:
# Calculate individual file hash only
crc_c, _, md5_h, _, sha1_h, _ = self._calculate_rom_hashes(
Path(f_path, file_name),
0,
hashlib.md5(usedforsecurity=False),
hashlib.sha1(usedforsecurity=False),
)
)
except zlib.error:
crc_c = 0
md5_h = hashlib.md5(usedforsecurity=False)

View File

@@ -1,7 +1,7 @@
from .flashpoint_handler import FlashpointHandler
from .gamelist_handler import GamelistHandler
from .hasheous_handler import HasheousHandler
from .hltb_handler import HowLongToBeatHandler
from .hltb_handler import HLTBHandler
from .igdb_handler import IGDBHandler
from .launchbox_handler import LaunchboxHandler
from .moby_handler import MobyGamesHandler
@@ -21,5 +21,5 @@ meta_launchbox_handler = LaunchboxHandler()
meta_hasheous_handler = HasheousHandler()
meta_tgdb_handler = TGDBHandler()
meta_flashpoint_handler = FlashpointHandler()
meta_hltb_handler = HowLongToBeatHandler()
meta_gamelist_handler = GamelistHandler()
meta_hltb_handler = HLTBHandler()

View File

@@ -14,7 +14,6 @@ from logger.logger import log
from tasks.scheduled.update_switch_titledb import (
SWITCH_PRODUCT_ID_KEY,
SWITCH_TITLEDB_INDEX_KEY,
update_switch_titledb_task,
)
jarowinkler = JaroWinkler()
@@ -190,12 +189,8 @@ class MetadataHandler(abc.ABC):
title_id = match.group(1)
if not (await async_cache.exists(SWITCH_TITLEDB_INDEX_KEY)):
log.warning("Fetching the Switch titleID index file...")
await update_switch_titledb_task.run(force=True)
if not (await async_cache.exists(SWITCH_TITLEDB_INDEX_KEY)):
log.error("Could not fetch the Switch titleID index file")
return search_term, None
log.error("Could not find the Switch titleID index file in cache")
return search_term, None
index_entry = await async_cache.hget(SWITCH_TITLEDB_INDEX_KEY, title_id)
if index_entry:
@@ -215,12 +210,8 @@ class MetadataHandler(abc.ABC):
product_id = "".join(product_id)
if not (await async_cache.exists(SWITCH_PRODUCT_ID_KEY)):
log.warning("Fetching the Switch productID index file...")
await update_switch_titledb_task.run(force=True)
if not (await async_cache.exists(SWITCH_PRODUCT_ID_KEY)):
log.error("Could not fetch the Switch productID index file")
return search_term, None
log.error("Could not find the Switch productID index file in cache")
return search_term, None
index_entry = await async_cache.hget(SWITCH_PRODUCT_ID_KEY, product_id)
if index_entry:

View File

@@ -9,7 +9,7 @@ from fastapi import HTTPException, status
from config import FLASHPOINT_API_ENABLED
from logger.logger import log
from utils import get_version
from utils import get_version, is_valid_uuid
from utils.context import ctx_httpx_client
from .base_handler import MetadataHandler
@@ -243,6 +243,11 @@ class FlashpointHandler(MetadataHandler):
if platform_slug not in FLASHPOINT_PLATFORM_LIST:
return FlashpointRom(flashpoint_id=None)
# Check if the filename is a UUID
fs_name_no_tags = fs_rom_handler.get_file_name_with_no_tags(fs_name)
if is_valid_uuid(fs_name_no_tags):
return await self.get_rom_by_id(flashpoint_id=fs_name_no_tags)
# Normalize the search term
search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name)
search_term = self.normalize_search_term(search_term, remove_punctuation=False)

View File

@@ -167,7 +167,7 @@ def extract_hltb_metadata(game: HLTBGame) -> HLTBMetadata:
GITHUB_FILE_URL = "https://raw.githubusercontent.com/rommapp/romm/refs/heads/master/backend/handler/metadata/fixtures/hltb_api_url"
class HowLongToBeatHandler(MetadataHandler):
class HLTBHandler(MetadataHandler):
"""
Handler for HowLongToBeat, a service that provides game completion times.
"""
@@ -212,14 +212,6 @@ class HowLongToBeatHandler(MetadataHandler):
return True
@staticmethod
def extract_hltb_id_from_filename(fs_name: str) -> int | None:
"""Extract HLTB ID from filename tag like (hltb-12345)."""
match = HLTB_TAG_REGEX.search(fs_name)
if match:
return int(match.group(1))
return None
async def _request(self, url: str, payload: dict) -> dict:
"""
Sends a POST request to HowLongToBeat API.
@@ -386,21 +378,6 @@ class HowLongToBeatHandler(MetadataHandler):
if not HLTB_API_ENABLED:
return HLTBRom(hltb_id=None)
# Check for HLTB ID tag in filename first
hltb_id_from_tag = self.extract_hltb_id_from_filename(fs_name)
if hltb_id_from_tag:
log.debug(f"Found HLTB ID tag in filename: {hltb_id_from_tag}")
rom_by_id = await self.get_rom_by_id(hltb_id_from_tag)
if rom_by_id["hltb_id"]:
log.debug(
f"Successfully matched ROM by HLTB ID tag: {fs_name} -> {hltb_id_from_tag}"
)
return rom_by_id
else:
log.warning(
f"HLTB ID tag found but no match: {fs_name} -> {hltb_id_from_tag}"
)
# We replace " - " with ": " to match HowLongToBeat's naming convention
search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name).replace(
" - ", ": "
@@ -493,29 +470,6 @@ class HowLongToBeatHandler(MetadataHandler):
return roms
async def get_rom_by_id(self, hltb_id: int) -> HLTBRom:
"""
Get ROM information by HowLongToBeat ID.
Note: HLTB doesn't have a direct "get by ID" endpoint,
so this method searches by the game name if we can find it.
:param hltb_id: The HowLongToBeat game ID.
:return: A HLTBRom object.
"""
if not HLTB_API_ENABLED:
return HLTBRom(hltb_id=None)
if not hltb_id:
return HLTBRom(hltb_id=None)
# Unfortunately, HLTB doesn't provide a direct "get by ID" endpoint
# This is a limitation of their API - we would need to search and filter
# In practice, this method might not be very useful for HLTB
log.debug(
f"get_rom_by_id not fully supported for HowLongToBeat (ID: {hltb_id})"
)
return HLTBRom(hltb_id=hltb_id)
async def price_check(
self, hltb_id: int, steam_id: int = 0, itch_id: int = 0
) -> HLTBPriceCheckResponse | None:

View File

@@ -245,10 +245,18 @@ class IGDBHandler(MetadataHandler):
game_type_filter = ""
log.debug("Searching in games endpoint with game_type %s", game_type_filter)
where_filter = f"platforms=[{platform_igdb_id}] {game_type_filter}"
# Special case for ScummVM games
# https://github.com/rommapp/romm/issues/2424
scummvm_platform = self.get_platform(UPS.SCUMMVM)
if scummvm_platform["igdb_id"] == platform_igdb_id:
where_filter = f"keywords=[{platform_igdb_id}] {game_type_filter}"
roms = await self.igdb_service.list_games(
search_term=search_term,
fields=GAMES_FIELDS,
where=f"platforms=[{platform_igdb_id}] {game_type_filter}",
where=where_filter,
limit=self.pagination_limit,
)
@@ -727,7 +735,7 @@ IGDB_PLATFORM_CATEGORIES: dict[int, str] = {
1: "Console",
2: "Arcade",
3: "Platform",
4: "Operative System",
4: "Operating System",
5: "Portable Console",
6: "Computer",
}
@@ -1110,10 +1118,10 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plf7.jpg",
},
UPS.ANALOGUEELECTRONICS: {
"category": "Unknown",
"category": "Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 1,
"id": 100,
"name": "Analogue electronics",
"slug": "analogueelectronics",
@@ -1121,7 +1129,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "",
},
UPS.ANDROID: {
"category": "Operative System",
"category": "Operating System",
"family_name": "",
"family_slug": "",
"generation": -1,
@@ -1179,7 +1187,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 2,
"id": 473,
"name": "Arcadia 2001",
"slug": "arcadia-2001",
@@ -1199,8 +1207,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.ASTROCADE: {
"category": "Console",
"family_name": "",
"family_slug": "",
"family_name": "Bally",
"family_slug": "bally",
"generation": 2,
"id": 91,
"name": "Bally Astrocade",
@@ -1289,7 +1297,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "General Instruments",
"family_slug": "general-instruments",
"generation": -1,
"generation": 1,
"id": 145,
"name": "AY-3-8603",
"slug": "ay-3-8603",
@@ -1300,7 +1308,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "General Instruments",
"family_slug": "general-instruments",
"generation": -1,
"generation": 1,
"id": 146,
"name": "AY-3-8605",
"slug": "ay-3-8605",
@@ -1311,7 +1319,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "General Instruments",
"family_slug": "general-instruments",
"generation": -1,
"generation": 1,
"id": 147,
"name": "AY-3-8606",
"slug": "ay-3-8606",
@@ -1322,7 +1330,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "General Instruments",
"family_slug": "general-instruments",
"generation": -1,
"generation": 1,
"id": 148,
"name": "AY-3-8607",
"slug": "ay-3-8607",
@@ -1333,7 +1341,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Computer",
"family_name": "General Instruments",
"family_slug": "general-instruments",
"generation": -1,
"generation": 1,
"id": 141,
"name": "AY-3-8610",
"slug": "ay-3-8610",
@@ -1344,7 +1352,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "General Instruments",
"family_slug": "general-instruments",
"generation": -1,
"generation": 1,
"id": 144,
"name": "AY-3-8710",
"slug": "ay-3-8710",
@@ -1355,7 +1363,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "General Instruments",
"family_slug": "general-instruments",
"generation": -1,
"generation": 1,
"id": 143,
"name": "AY-3-8760",
"slug": "ay-3-8760",
@@ -1374,7 +1382,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/pl86.jpg",
},
UPS.BLACKBERRY: {
"category": "Operative System",
"category": "Operating System",
"family_name": "",
"family_slug": "",
"generation": -1,
@@ -1388,7 +1396,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 7,
"id": 239,
"name": "Blu-ray Player",
"slug": "blu-ray-player",
@@ -1454,7 +1462,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "Casio",
"family_slug": "casio",
"generation": -1,
"generation": 5,
"id": 380,
"name": "Casio Loopy",
"slug": "casio-loopy",
@@ -1506,10 +1514,10 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plf3.jpg",
},
UPS.DAYDREAM: {
"category": "Unknown",
"family_name": "",
"family_slug": "",
"generation": -1,
"category": "Console",
"family_name": "Google",
"family_slug": "google",
"generation": 8,
"id": 164,
"name": "Daydream",
"slug": "daydream",
@@ -1550,7 +1558,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "",
},
UPS.DOS: {
"category": "Operative System",
"category": "Operating System",
"family_name": "Microsoft",
"family_slug": "microsoft",
"generation": -1,
@@ -1575,7 +1583,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 6,
"id": 238,
"name": "DVD Player",
"slug": "dvd-player",
@@ -1795,7 +1803,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "Samsung",
"family_slug": "samsung",
"generation": -1,
"generation": 8,
"id": 388,
"name": "Gear VR",
"slug": "gear-vr",
@@ -1825,7 +1833,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plnl.jpg",
},
UPS.GT40: {
"category": "Unknown",
"category": "Computer",
"family_name": "DEC",
"family_slug": "dec",
"generation": -1,
@@ -1839,7 +1847,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Portable Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 1,
"id": 411,
"name": "Handheld Electronic LCD",
"slug": "handheld-electronic-lcd",
@@ -1872,7 +1880,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Arcade",
"family_name": "SNK",
"family_slug": "snk",
"generation": -1,
"generation": 5,
"id": 135,
"name": "Hyper Neo Geo 64",
"slug": "hyper-neo-geo-64",
@@ -1891,7 +1899,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plj2.jpg",
},
UPS.IMLAC_PDS1: {
"category": "Unknown",
"category": "Computer",
"family_name": "",
"family_slug": "",
"generation": -1,
@@ -1916,7 +1924,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 9,
"id": 382,
"name": "Intellivision Amico",
"slug": "intellivision-amico",
@@ -1924,7 +1932,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plkp.jpg",
},
UPS.IOS: {
"category": "Operative System",
"category": "Operating System",
"family_name": "Apple",
"family_slug": "apple",
"generation": -1,
@@ -2001,7 +2009,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "",
},
UPS.LINUX: {
"category": "Operative System",
"category": "Operating System",
"family_name": "Linux",
"family_slug": "linux",
"generation": -1,
@@ -2023,7 +2031,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/pl82.jpg",
},
UPS.MAC: {
"category": "Operative System",
"category": "Operating System",
"family_name": "Apple",
"family_slug": "apple",
"generation": -1,
@@ -2048,7 +2056,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "Meta",
"family_slug": "meta",
"generation": -1,
"generation": 9,
"id": 386,
"name": "Meta Quest 2",
"slug": "meta-quest-2",
@@ -2067,7 +2075,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plnb.jpg",
},
UPS.MICROCOMPUTER: {
"category": "Unknown",
"category": "Computer",
"family_name": "",
"family_slug": "",
"generation": -1,
@@ -2092,7 +2100,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Portable Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 7,
"id": 55,
"name": "Legacy Mobile Device",
"slug": "mobile",
@@ -2202,7 +2210,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Arcade",
"family_name": "SNK",
"family_slug": "snk",
"generation": -1,
"generation": 4,
"id": 79,
"name": "Neo Geo MVS",
"slug": "neogeomvs",
@@ -2279,7 +2287,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 6,
"id": 122,
"name": "Nuon",
"slug": "nuon",
@@ -2290,7 +2298,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "Meta",
"family_slug": "meta",
"generation": -1,
"generation": 8,
"id": 387,
"name": "Oculus Go",
"slug": "oculus-go",
@@ -2301,7 +2309,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "Meta",
"family_slug": "meta",
"generation": -1,
"generation": 8,
"id": 384,
"name": "Oculus Quest",
"slug": "oculus-quest",
@@ -2312,7 +2320,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "Meta",
"family_slug": "meta",
"generation": -1,
"generation": 7,
"id": 385,
"name": "Oculus Rift",
"slug": "oculus-rift",
@@ -2320,10 +2328,10 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/pln8.jpg",
},
UPS.OCULUS_VR: {
"category": "Unknown",
"category": "Console",
"family_name": "Meta",
"family_slug": "meta",
"generation": -1,
"generation": 7,
"id": 162,
"name": "Oculus VR",
"slug": "oculus-vr",
@@ -2386,7 +2394,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/pl6k.jpg",
},
UPS.PALM_OS: {
"category": "Operative System",
"category": "Operating System",
"family_name": "",
"family_slug": "",
"generation": -1,
@@ -2463,7 +2471,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plf8.jpg",
},
UPS.PDP_7: {
"category": "Unknown",
"category": "Computer",
"family_name": "DEC",
"family_slug": "dec",
"generation": -1,
@@ -2521,7 +2529,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "Philips",
"family_slug": "philips",
"generation": -1,
"generation": 4,
"id": 117,
"name": "Philips CD-i",
"slug": "philips-cd-i",
@@ -2574,8 +2582,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.POCKETSTATION: {
"category": "Portable Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 5,
"id": 441,
"name": "PocketStation",
@@ -2587,7 +2595,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Portable Console",
"family_name": "Nintendo",
"family_slug": "nintendo",
"generation": -1,
"generation": 6,
"id": 166,
"name": "Pokémon mini",
"slug": "pokemon-mini",
@@ -2598,7 +2606,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 9,
"id": 509,
"name": "Polymega",
"slug": "polymega",
@@ -2607,8 +2615,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.PSX: {
"category": "Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 5,
"id": 7,
"name": "PlayStation",
@@ -2618,8 +2626,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.PS2: {
"category": "Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 6,
"id": 8,
"name": "PlayStation 2",
@@ -2629,8 +2637,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.PS3: {
"category": "Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 7,
"id": 9,
"name": "PlayStation 3",
@@ -2640,8 +2648,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.PS4: {
"category": "Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 8,
"id": 48,
"name": "PlayStation 4",
@@ -2651,8 +2659,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.PS5: {
"category": "Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 9,
"id": 167,
"name": "PlayStation 5",
@@ -2662,8 +2670,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.PSP: {
"category": "Portable Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 7,
"id": 38,
"name": "PlayStation Portable",
@@ -2673,8 +2681,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.PSVITA: {
"category": "Portable Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 8,
"id": 46,
"name": "PlayStation Vita",
@@ -2684,8 +2692,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.PSVR: {
"category": "Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 8,
"id": 165,
"name": "PlayStation VR",
@@ -2695,8 +2703,8 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
},
UPS.PSVR2: {
"category": "Console",
"family_name": "PlayStation",
"family_slug": "playstation",
"family_name": "Sony",
"family_slug": "sony",
"generation": 9,
"id": 390,
"name": "PlayStation VR2",
@@ -2737,6 +2745,18 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url": "https://www.igdb.com/platforms/saturn",
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/hrmqljpwunky1all3v78.jpg",
},
UPS.SCUMMVM: {
"category": "Computer",
"family_name": "",
"family_slug": "",
"generation": -1,
# Note: The ID 50501 is a keyword ID (not a platform ID) in IGDB's system
"id": 50501,
"name": "ScummVM",
"slug": "scummvm",
"url": "https://www.igdb.com/categories/scummvm-compatible",
"url_logo": "",
},
UPS.SDSSIGMA7: {
"category": "Computer",
"family_name": "",
@@ -2914,10 +2934,10 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/pl94.jpg",
},
UPS.STEAM_VR: {
"category": "Unknown",
"family_name": "",
"family_slug": "",
"generation": -1,
"category": "Platform",
"family_name": "Valve",
"family_slug": "valve",
"generation": 8,
"id": 163,
"name": "SteamVR",
"slug": "steam-vr",
@@ -2961,7 +2981,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Portable Console",
"family_name": "Bandai",
"family_slug": "bandai",
"generation": -1,
"generation": 5,
"id": 124,
"name": "SwanCrystal",
"slug": "swancrystal",
@@ -3093,7 +3113,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 9,
"id": 504,
"name": "Uzebox",
"slug": "uzebox",
@@ -3156,7 +3176,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/pl7s.jpg",
},
UPS.VISIONOS: {
"category": "Operative System",
"category": "Operating System",
"family_name": "Apple",
"family_slug": "apple",
"generation": -1,
@@ -3222,7 +3242,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/pl6n.jpg",
},
UPS.WIN: {
"category": "Operative System",
"category": "Operating System",
"family_name": "Microsoft",
"family_slug": "microsoft",
"generation": -1,
@@ -3233,10 +3253,10 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plim.jpg",
},
UPS.WINDOWS_MIXED_REALITY: {
"category": "Unknown",
"category": "Platform",
"family_name": "Microsoft",
"family_slug": "microsoft",
"generation": -1,
"generation": 8,
"id": 161,
"name": "Windows Mixed Reality",
"slug": "windows-mixed-reality",
@@ -3244,7 +3264,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plm4.jpg",
},
UPS.WINDOWS_MOBILE: {
"category": "Operative System",
"category": "Operating System",
"family_name": "Microsoft",
"family_slug": "microsoft",
"generation": -1,
@@ -3255,7 +3275,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"url_logo": "https://images.igdb.com/igdb/image/upload/t_1080p/plkl.jpg",
},
UPS.WINPHONE: {
"category": "Operative System",
"category": "Operating System",
"family_name": "Microsoft",
"family_slug": "microsoft",
"generation": -1,
@@ -3346,7 +3366,7 @@ IGDB_PLATFORM_LIST: dict[UPS, SlugToIGDB] = {
"category": "Portable Console",
"family_name": "",
"family_slug": "",
"generation": -1,
"generation": 5,
"id": 44,
"name": "Tapwave Zodiac",
"slug": "zod",

View File

@@ -142,17 +142,8 @@ class LaunchboxHandler(MetadataHandler):
self, file_name: str, platform_slug: str
) -> dict | None:
if not (await async_cache.exists(LAUNCHBOX_METADATA_NAME_KEY)):
log.info("Fetching the Launchbox Metadata.xml file...")
from tasks.scheduled.update_launchbox_metadata import (
update_launchbox_metadata_task,
)
await update_launchbox_metadata_task.run(force=True)
if not (await async_cache.exists(LAUNCHBOX_METADATA_NAME_KEY)):
log.error("Could not fetch the Launchbox Metadata.xml file")
return None
log.error("Could not find the Launchbox Metadata.xml file in cache")
return None
lb_platform = self.get_platform(platform_slug)
platform_name = lb_platform.get("name", None)

View File

@@ -81,7 +81,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.ACORN_ARCHIMEDES: {
"id": 4944,
"name": "Acorn Archimedes",
"manufacturer": "Acorn Computers",
"manufacturer": "Acorn",
"developer": "Acorn Computers",
"media_medium": None,
"cpu": "Acorn RISC Machine",
@@ -96,7 +96,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.ATOM: {
"id": 5014,
"name": "Acorn Atom",
"manufacturer": "Acorn Computers",
"manufacturer": "Acorn",
"developer": "Acorn Computers",
"media_medium": "100KB 5¼ Floppy, Cassette",
"cpu": "MOS Technology 6502 clocked at 1MHz",
@@ -111,7 +111,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.ACORN_ELECTRON: {
"id": 4954,
"name": "Acorn Electron",
"manufacturer": "Acorn Computers",
"manufacturer": "Acorn",
"developer": "Acorn Computers",
"media_medium": "Cassette tape, floppy disk (optional), ROM cartridge (optional)",
"cpu": "MOS Technology 6502A with 2/1 MHz",
@@ -321,7 +321,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.ATARI800: {
"id": 4943,
"name": "Atari 800",
"manufacturer": "Atari Corporation",
"manufacturer": "Atari",
"developer": "Atari Corporation",
"media_medium": None,
"cpu": "MOS Technology 6502B",
@@ -366,7 +366,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.LYNX: {
"id": 4924,
"name": "Atari Lynx",
"manufacturer": "Atari Corporation",
"manufacturer": "Atari",
"developer": "Epyx / Atari",
"media_medium": None,
"cpu": "MOS Technology 6502",
@@ -381,7 +381,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.ATARI_ST: {
"id": 4937,
"name": "Atari ST",
"manufacturer": "Atari Corporation",
"manufacturer": "Atari",
"developer": "Atari Corporation",
"media_medium": "Floppy",
"cpu": "Motorola 680x0 @ 8 MHz &amp; higher",
@@ -411,7 +411,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.ASTROCADE: {
"id": 4968,
"name": "Bally Astrocade",
"manufacturer": "Bally Manufacturing",
"manufacturer": "Bally",
"developer": "Bally Manufacturing",
"media_medium": None,
"cpu": None,
@@ -426,7 +426,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.BBCMICRO: {
"id": 5013,
"name": "BBC Micro",
"manufacturer": "Acorn Computers",
"manufacturer": "Acorn",
"developer": "BBC",
"media_medium": "Cassette, Floppy, Hard Disk, Laserdisc",
"cpu": "2 MHz MOS Technology 6502/6512",
@@ -501,7 +501,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.C128: {
"id": 4946,
"name": "Commodore 128",
"manufacturer": "Commodore Business Machines",
"manufacturer": "Commodore",
"developer": "Commodore International",
"media_medium": None,
"cpu": "Zilog Z80A @ 4 MHz",
@@ -516,7 +516,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.C16: {
"id": 5006,
"name": "Commodore 16",
"manufacturer": "Commodore Business Machines",
"manufacturer": "Commodore",
"developer": None,
"media_medium": "ROM cartridge, Compact Cassette",
"cpu": "MOS Technology 7501 @ 0.89 MHz / MOS Technology 8501 @ 1.76 MHz",
@@ -531,7 +531,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.C64: {
"id": 40,
"name": "Commodore 64",
"manufacturer": "Commodore International",
"manufacturer": "Commodore",
"developer": "Commodore International",
"media_medium": "Cartridge",
"cpu": "MOS Technology 6510",
@@ -546,7 +546,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.CPET: {
"id": 5008,
"name": "Commodore PET",
"manufacturer": "Commodore International",
"manufacturer": "Commodore",
"developer": None,
"media_medium": "Cassette tape, 5.25-inch floppy, 8-inch floppy, hard disk",
"cpu": "MOS Technology 6502 @ 1 MHz",
@@ -561,7 +561,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.C_PLUS_4: {
"id": 5007,
"name": "Commodore Plus/4",
"manufacturer": "Commodore Business Machines",
"manufacturer": "Commodore",
"developer": None,
"media_medium": None,
"cpu": "MOS Technology 7501 or 8501 @ 1.76 MHz",
@@ -576,7 +576,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.VIC_20: {
"id": 4945,
"name": "Commodore VIC-20",
"manufacturer": "Commodore Business Machines",
"manufacturer": "Commodore",
"developer": "Commodore International",
"media_medium": None,
"cpu": "MOS Technology 6502",
@@ -1416,7 +1416,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.NUON: {
"id": 4935,
"name": "Nuon",
"manufacturer": "Motorola, Samsung, Toshiba",
"manufacturer": "Samsung",
"developer": "VM Labs",
"media_medium": None,
"cpu": None,
@@ -1491,7 +1491,7 @@ TGDB_PLATFORM_LIST: dict[UPS, SlugToTGDBId] = {
UPS.WIN: {
"id": 1,
"name": "PC",
"manufacturer": None,
"manufacturer": "Microsoft",
"developer": "IBM",
"media_medium": None,
"cpu": "x86 Based",

View File

@@ -289,10 +289,6 @@ async def scan_rom(
newly_added: bool,
socket_manager: socketio.AsyncRedisManager | None = None,
) -> Rom:
if not metadata_sources:
log.error("No metadata sources provided")
raise ValueError("No metadata sources provided")
filesize = sum([file.file_size_bytes for file in fs_rom["files"]])
rom_attrs = {
"platform_id": platform.id,

View File

@@ -24,6 +24,7 @@ class Collection(BaseModel):
name: Mapped[str] = mapped_column(String(length=400))
description: Mapped[str | None] = mapped_column(Text)
is_public: Mapped[bool] = mapped_column(default=False)
is_favorite: Mapped[bool] = mapped_column(default=False)
path_cover_l: Mapped[str | None] = mapped_column(Text, default="")
path_cover_s: Mapped[str | None] = mapped_column(Text, default="")
url_cover: Mapped[str | None] = mapped_column(
@@ -89,10 +90,6 @@ class Collection(BaseModel):
if r.path_cover_l
]
@property
def is_favorite(self) -> bool:
return self.name.lower() == "favourites"
def __repr__(self) -> str:
return self.name

View File

@@ -268,10 +268,6 @@ class Rom(BaseModel):
def platform_fs_slug(self) -> str:
return self.platform.fs_slug
@property
def platform_name(self) -> str:
return self.platform.name
@property
def platform_custom_name(self) -> str | None:
return self.platform.custom_name

View File

@@ -0,0 +1 @@
00000000

View File

@@ -61,7 +61,9 @@ class ScanLibraryTask(PeriodicTask):
log.info("Scheduled library scan started...")
scan_stats = await scan_platforms(
[], scan_type=ScanType.UNIDENTIFIED, metadata_sources=metadata_sources
platform_ids=[],
metadata_sources=metadata_sources,
scan_type=ScanType.UNIDENTIFIED,
)
log.info("Scheduled library scan done")

View File

@@ -71,3 +71,57 @@ class UpdateSwitchTitleDBTask(RemoteFilePullTask):
update_switch_titledb_task = UpdateSwitchTitleDBTask()
TITLEDB_REGION_LANG_MAP: Final = {
"BG": ["en"],
"BR": ["en", "pt"],
"CH": ["fr", "de", "it"],
"CY": ["en"],
"EE": ["en"],
"HR": ["en"],
"IE": ["en"],
"LT": ["en"],
"LU": ["fr", "de"],
"LV": ["en"],
"MT": ["en"],
"RO": ["en"],
"SI": ["en"],
"SK": ["en"],
"CO": ["en", "es"],
"AR": ["en", "es"],
"CL": ["en", "es"],
"PE": ["en", "es"],
"KR": ["ko"],
"HK": ["zh"],
"CN": ["zh"],
"NZ": ["en"],
"AT": ["de"],
"BE": ["fr", "nl"],
"CZ": ["en"],
"DK": ["en"],
"ES": ["es"],
"FI": ["en"],
"GR": ["en"],
"HU": ["en"],
"NL": ["nl"],
"NO": ["en"],
"PL": ["en"],
"PT": ["pt"],
"RU": ["ru"],
"ZA": ["en"],
"SE": ["en"],
"MX": ["en", "es"],
"IT": ["it"],
"CA": ["en", "fr"],
"FR": ["fr"],
"DE": ["de"],
"JP": ["ja"],
"AU": ["en"],
"GB": ["en"],
"US": ["en", "es"],
}
TITLEDB_REGION_LIST: Final = list(TITLEDB_REGION_LANG_MAP.keys())
TITLEDB_LANGUAGE_LIST: Final = list(
set(lang for sublist in TITLEDB_REGION_LANG_MAP.values() for lang in sublist)
)

View File

@@ -1,6 +1,7 @@
from unittest.mock import Mock
import pytest
import socketio
from endpoints.sockets.scan import ScanStats, _should_scan_rom
from handler.scan_handler import ScanType
@@ -13,61 +14,62 @@ def test_scan_stats():
assert stats.new_platforms == 0
assert stats.identified_platforms == 0
assert stats.scanned_roms == 0
assert stats.added_roms == 0
assert stats.new_roms == 0
assert stats.identified_roms == 0
assert stats.scanned_firmware == 0
assert stats.added_firmware == 0
assert stats.new_firmware == 0
stats.scanned_platforms += 1
stats.new_platforms += 1
stats.identified_platforms += 1
stats.scanned_roms += 1
stats.added_roms += 1
stats.new_roms += 1
stats.identified_roms += 1
stats.scanned_firmware += 1
stats.added_firmware += 1
stats.new_firmware += 1
assert stats.scanned_platforms == 1
assert stats.new_platforms == 1
assert stats.identified_platforms == 1
assert stats.scanned_roms == 1
assert stats.added_roms == 1
assert stats.new_roms == 1
assert stats.identified_roms == 1
assert stats.scanned_firmware == 1
assert stats.added_firmware == 1
assert stats.new_firmware == 1
def test_merging_scan_stats():
async def test_merging_scan_stats():
stats = ScanStats(
scanned_platforms=1,
new_platforms=2,
identified_platforms=3,
scanned_roms=4,
added_roms=5,
new_roms=5,
identified_roms=6,
scanned_firmware=7,
added_firmware=8,
new_firmware=8,
)
stats.update(
await stats.update(
socket_manager=Mock(spec=socketio.AsyncRedisManager),
scanned_platforms=stats.scanned_platforms + 10,
new_platforms=stats.new_platforms + 11,
identified_platforms=stats.identified_platforms + 12,
scanned_roms=stats.scanned_roms + 13,
added_roms=stats.added_roms + 14,
new_roms=stats.new_roms + 14,
identified_roms=stats.identified_roms + 15,
scanned_firmware=stats.scanned_firmware + 16,
added_firmware=stats.added_firmware + 17,
new_firmware=stats.new_firmware + 17,
)
assert stats.scanned_platforms == 11
assert stats.new_platforms == 13
assert stats.identified_platforms == 15
assert stats.scanned_roms == 17
assert stats.added_roms == 19
assert stats.new_roms == 19
assert stats.identified_roms == 21
assert stats.scanned_firmware == 23
assert stats.added_firmware == 25
assert stats.new_firmware == 25
class TestShouldScanRom:

View File

@@ -1,4 +1,5 @@
from unittest.mock import patch
import json
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import status
@@ -6,7 +7,14 @@ from fastapi.testclient import TestClient
from main import app
from handler.filesystem.roms_handler import FSRomsHandler
from handler.metadata.flashpoint_handler import FlashpointHandler, FlashpointRom
from handler.metadata.igdb_handler import IGDBHandler, IGDBRom
from handler.metadata.launchbox_handler import LaunchboxHandler, LaunchboxRom
from handler.metadata.moby_handler import MobyGamesHandler, MobyGamesRom
from handler.metadata.ra_handler import RAGameRom, RAHandler
from handler.metadata.ss_handler import SSHandler, SSRom
from models.platform import Platform
from models.rom import Rom
@pytest.fixture
@@ -15,7 +23,18 @@ def client():
yield client
def test_get_rom(client, access_token, rom):
MOCK_IGDB_ID = 11111
MOCK_MOBY_ID = 22222
MOCK_SS_ID = 33333
MOCK_RA_ID = 44444
MOCK_LAUNCHBOX_ID = 55555
MOCK_FLASHPOINT_ID = 66666
MOCK_HLTB_ID = 77777
MOCK_SGDB_ID = 88888
MOCK_HASHEOUS_ID = 99999
def test_get_rom(client: TestClient, access_token: str, rom: Rom):
response = client.get(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
@@ -26,7 +45,9 @@ def test_get_rom(client, access_token, rom):
assert body["id"] == rom.id
def test_get_all_roms(client, access_token, rom, platform):
def test_get_all_roms(
client: TestClient, access_token: str, rom: Rom, platform: Platform
):
response = client.get(
"/api/roms",
headers={"Authorization": f"Bearer {access_token}"},
@@ -47,12 +68,18 @@ def test_get_all_roms(client, access_token, rom, platform):
@patch.object(FSRomsHandler, "rename_fs_rom")
@patch.object(IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=None))
def test_update_rom(rename_fs_rom_mock, get_rom_by_id_mock, client, access_token, rom):
def test_update_rom(
rename_fs_rom_mock: AsyncMock,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"igdb_id": "236663",
"igdb_id": str(MOCK_IGDB_ID),
"name": "Metroid Prime Remastered",
"slug": "metroid-prime-remastered",
"fs_name": "Metroid Prime Remastered.zip",
@@ -64,7 +91,7 @@ def test_update_rom(rename_fs_rom_mock, get_rom_by_id_mock, client, access_token
"expansions": "[]",
"dlcs": "[]",
"companies": '[{"id": 203227, "company": {"id": 70, "name": "Nintendo"}}, {"id": 203307, "company": {"id": 766, "name": "Retro Studios"}}]',
"first_release_date": 1675814400,
"first_release_date": "1675814400",
"youtube_video_id": "dQw4w9WgXcQ",
"remasters": "[]",
"remakes": "[]",
@@ -83,7 +110,7 @@ def test_update_rom(rename_fs_rom_mock, get_rom_by_id_mock, client, access_token
assert get_rom_by_id_mock.called
def test_delete_roms(client, access_token, rom):
def test_delete_roms(client: TestClient, access_token: str, rom: Rom):
response = client.post(
"/api/roms/delete",
headers={"Authorization": f"Bearer {access_token}"},
@@ -93,3 +120,652 @@ def test_delete_roms(client, access_token, rom):
body = response.json()
assert body["successful_items"] == 1
class TestUpdateMetadataIDs:
@patch.object(
IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=MOCK_IGDB_ID)
)
def test_update_rom_igdb_id(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating IGDB ID."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"igdb_id": str(MOCK_IGDB_ID)},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["igdb_id"] == MOCK_IGDB_ID
assert get_rom_by_id_mock.called
@patch.object(
MobyGamesHandler,
"get_rom_by_id",
return_value=MobyGamesRom(moby_id=MOCK_MOBY_ID),
)
def test_update_rom_moby_id(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating MobyGames ID."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"moby_id": str(MOCK_MOBY_ID)},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["moby_id"] == MOCK_MOBY_ID
assert get_rom_by_id_mock.called
@patch.object(SSHandler, "get_rom_by_id", return_value=SSRom(ss_id=MOCK_SS_ID))
def test_update_rom_ss_id(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating ScreenScraper ID."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"ss_id": str(MOCK_SS_ID)},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["ss_id"] == MOCK_SS_ID
assert get_rom_by_id_mock.called
@patch.object(RAHandler, "get_rom_by_id", return_value=RAGameRom(ra_id=MOCK_RA_ID))
def test_update_rom_ra_id(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating RetroAchievements ID."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"ra_id": str(MOCK_RA_ID)},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["ra_id"] == MOCK_RA_ID
assert get_rom_by_id_mock.called
@patch.object(
LaunchboxHandler,
"get_rom_by_id",
return_value=LaunchboxRom(launchbox_id=MOCK_LAUNCHBOX_ID),
)
def test_update_rom_launchbox_id(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating LaunchBox ID."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"launchbox_id": str(MOCK_LAUNCHBOX_ID)},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["launchbox_id"] == MOCK_LAUNCHBOX_ID
assert get_rom_by_id_mock.called
@patch.object(
FlashpointHandler,
"get_rom_by_id",
return_value=FlashpointRom(flashpoint_id=str(MOCK_FLASHPOINT_ID)),
)
def test_update_rom_flashpoint_id(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating Flashpoint ID."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"flashpoint_id": str(MOCK_FLASHPOINT_ID)},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["flashpoint_id"] == str(MOCK_FLASHPOINT_ID)
assert get_rom_by_id_mock.called
# These metadata sources are not called when updating roms
def test_update_rom_sgdb_id(self, client: TestClient, access_token: str, rom: Rom):
"""Test updating SteamGridDB ID."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"sgdb_id": str(MOCK_SGDB_ID)},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["sgdb_id"] == MOCK_SGDB_ID
def test_update_rom_hasheous_id(
self, client: TestClient, access_token: str, rom: Rom
):
"""Test updating Hasheous ID."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"hasheous_id": str(MOCK_HASHEOUS_ID)},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["hasheous_id"] == MOCK_HASHEOUS_ID
def test_update_rom_hltb_id(self, client: TestClient, access_token: str, rom: Rom):
"""Test updating HowLongToBeat ID."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"hltb_id": str(MOCK_HLTB_ID)},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["hltb_id"] == MOCK_HLTB_ID
class TestUpdateRawMetadata:
@patch.object(
IGDBHandler,
"get_rom_by_id",
return_value=IGDBRom(igdb_id=MOCK_IGDB_ID),
)
def test_update_raw_igdb_metadata(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating raw IGDB metadata."""
raw_metadata = {
"genres": ["Action"],
"franchises": ["Metroid"],
}
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"igdb_id": str(MOCK_IGDB_ID),
"raw_igdb_metadata": json.dumps(raw_metadata),
},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["igdb_metadata"] is not None
assert body["igdb_metadata"]["genres"] == ["Action"]
assert body["igdb_metadata"]["franchises"] == ["Metroid"]
@patch.object(
MobyGamesHandler,
"get_rom_by_id",
return_value=MobyGamesRom(moby_id=MOCK_MOBY_ID),
)
def test_update_raw_moby_metadata(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating raw MobyGames metadata."""
raw_metadata = {
"genres": ["Action", "Adventure"],
"moby_score": "90",
}
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"moby_id": str(MOCK_MOBY_ID),
"raw_moby_metadata": json.dumps(raw_metadata),
},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["moby_metadata"] is not None
assert body["moby_metadata"]["moby_score"] == "90"
assert body["moby_metadata"]["genres"] == ["Action", "Adventure"]
@patch.object(
SSHandler,
"get_rom_by_id",
return_value=SSRom(ss_id=MOCK_SS_ID),
)
def test_update_raw_ss_metadata(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating raw ScreenScraper metadata."""
raw_metadata = {
"ss_score": "85",
"alternative_names": ["Test SS Game"],
}
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"ss_id": str(MOCK_SS_ID),
"raw_ss_metadata": json.dumps(raw_metadata),
},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["ss_metadata"] is not None
assert body["ss_metadata"]["ss_score"] == "85"
assert body["ss_metadata"]["alternative_names"] == ["Test SS Game"]
@patch.object(
LaunchboxHandler,
"get_rom_by_id",
return_value=LaunchboxRom(launchbox_id=MOCK_LAUNCHBOX_ID),
)
def test_update_raw_launchbox_metadata(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating raw LaunchBox metadata."""
raw_metadata = {
"first_release_date": "1675814400",
"max_players": 4,
"release_type": "Single",
}
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"launchbox_id": str(MOCK_LAUNCHBOX_ID),
"raw_launchbox_metadata": json.dumps(raw_metadata),
},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["launchbox_metadata"] is not None
assert body["launchbox_metadata"]["first_release_date"] == 1675814400
assert body["launchbox_metadata"]["max_players"] == 4
assert body["launchbox_metadata"]["release_type"] == "Single"
def test_update_raw_hasheous_metadata(
self, client: TestClient, access_token: str, rom: Rom
):
"""Test updating raw Hasheous metadata."""
raw_metadata = {
"tosec_match": True,
"mame_arcade_match": False,
"mame_mess_match": True,
}
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"hasheous_id": str(MOCK_HASHEOUS_ID),
"raw_hasheous_metadata": json.dumps(raw_metadata),
},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["hasheous_metadata"] is not None
assert body["hasheous_metadata"]["tosec_match"] is True
assert body["hasheous_metadata"]["mame_arcade_match"] is False
assert body["hasheous_metadata"]["mame_mess_match"] is True
@patch.object(
FlashpointHandler,
"get_rom_by_id",
return_value=FlashpointRom(flashpoint_id=str(MOCK_FLASHPOINT_ID)),
)
def test_update_raw_flashpoint_metadata(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating raw Flashpoint metadata."""
raw_metadata = {
"franchises": ["Metroid"],
"companies": ["Nintendo"],
"source": "Flashpoint",
}
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"flashpoint_id": str(MOCK_FLASHPOINT_ID),
"raw_flashpoint_metadata": json.dumps(raw_metadata),
},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["flashpoint_metadata"] is not None
assert body["flashpoint_metadata"]["franchises"] == ["Metroid"]
assert body["flashpoint_metadata"]["companies"] == ["Nintendo"]
assert body["flashpoint_metadata"]["source"] == "Flashpoint"
def test_update_raw_hltb_metadata(
self,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating raw HowLongToBeat metadata."""
raw_metadata = {
"main_story": 10000,
"main_story_count": 1,
}
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"hltb_id": str(MOCK_HLTB_ID),
"raw_hltb_metadata": json.dumps(raw_metadata),
},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["hltb_metadata"] is not None
assert body["hltb_metadata"]["main_story"] == 10000
assert body["hltb_metadata"]["main_story_count"] == 1
# Tests for combined updates
@patch.object(
IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=MOCK_IGDB_ID)
)
def test_update_rom_metadata_id_and_raw_metadata(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating both metadata ID and raw metadata in the same request."""
raw_igdb_metadata = {
"genres": ["Action"],
"franchises": ["Metroid"],
}
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"igdb_id": str(MOCK_IGDB_ID),
"raw_igdb_metadata": json.dumps(raw_igdb_metadata),
},
)
assert response.status_code == status.HTTP_200_OK
assert get_rom_by_id_mock.called
body = response.json()
assert body["igdb_id"] == MOCK_IGDB_ID
assert body["igdb_metadata"] is not None
assert body["igdb_metadata"]["genres"] == ["Action"]
assert body["igdb_metadata"]["franchises"] == ["Metroid"]
@patch.object(
IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=MOCK_IGDB_ID)
)
@patch.object(
MobyGamesHandler,
"get_rom_by_id",
return_value=MobyGamesRom(moby_id=MOCK_MOBY_ID),
)
@patch.object(SSHandler, "get_rom_by_id", return_value=SSRom(ss_id=MOCK_SS_ID))
def test_update_rom_multiple_metadata_ids(
self,
igdb_get_rom_by_id_mock: AsyncMock,
moby_get_rom_by_id_mock: AsyncMock,
ss_get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating multiple metadata IDs in the same request."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"igdb_id": str(MOCK_IGDB_ID),
"moby_id": str(MOCK_MOBY_ID),
"ss_id": str(MOCK_SS_ID),
},
)
assert response.status_code == status.HTTP_200_OK
assert igdb_get_rom_by_id_mock.called
assert moby_get_rom_by_id_mock.called
assert ss_get_rom_by_id_mock.called
body = response.json()
assert body["igdb_id"] == MOCK_IGDB_ID
assert body["moby_id"] == MOCK_MOBY_ID
assert body["ss_id"] == MOCK_SS_ID
@patch.object(
IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=MOCK_IGDB_ID)
)
@patch.object(
MobyGamesHandler,
"get_rom_by_id",
return_value=MobyGamesRom(moby_id=MOCK_MOBY_ID),
)
@patch.object(SSHandler, "get_rom_by_id", return_value=SSRom(ss_id=MOCK_SS_ID))
def test_update_rom_multiple_raw_metadata(
self,
igdb_get_rom_by_id_mock: AsyncMock,
moby_get_rom_by_id_mock: AsyncMock,
ss_get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test updating multiple raw metadata fields in the same request."""
raw_igdb = {
"genres": ["Action"],
"franchises": ["Metroid"],
}
raw_moby = {
"genres": ["Action", "Adventure"],
"moby_score": "90",
}
raw_ss = {
"ss_score": "85",
"alternative_names": ["Test SS Game"],
}
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={
"igdb_id": str(MOCK_IGDB_ID),
"moby_id": str(MOCK_MOBY_ID),
"ss_id": str(MOCK_SS_ID),
"raw_igdb_metadata": json.dumps(raw_igdb),
"raw_moby_metadata": json.dumps(raw_moby),
"raw_ss_metadata": json.dumps(raw_ss),
},
)
assert response.status_code == status.HTTP_200_OK
assert igdb_get_rom_by_id_mock.called
assert moby_get_rom_by_id_mock.called
assert ss_get_rom_by_id_mock.called
body = response.json()
assert body["igdb_metadata"] is not None
assert body["igdb_metadata"]["genres"] == ["Action"]
assert body["igdb_metadata"]["franchises"] == ["Metroid"]
assert body["moby_metadata"] is not None
assert body["moby_metadata"]["genres"] == ["Action", "Adventure"]
assert body["moby_metadata"]["moby_score"] == "90"
assert body["ss_metadata"] is not None
assert body["ss_metadata"]["ss_score"] == "85"
assert body["ss_metadata"]["alternative_names"] == ["Test SS Game"]
# Tests for invalid JSON handling
def test_update_rom_invalid_json_raw_metadata(
self,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test that invalid JSON in raw metadata is handled gracefully."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"raw_igdb_metadata": "invalid json {["},
)
# Should still succeed, but raw metadata should not be updated
assert response.status_code == status.HTTP_200_OK
# The invalid JSON should be ignored, so igdb_metadata should remain unchanged
body = response.json()
assert body["igdb_metadata"] == {}
def test_update_rom_empty_raw_metadata(
self, client: TestClient, access_token: str, rom: Rom
):
"""Test that empty raw metadata is handled correctly."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"raw_igdb_metadata": ""},
)
assert response.status_code == status.HTTP_200_OK
# Empty string should be ignored, so igdb_metadata should remain unchanged
body = response.json()
assert body["igdb_metadata"] == {}
class TestUnmatchMetadata:
@patch.object(
IGDBHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=MOCK_IGDB_ID)
)
def test_update_rom_unmatch_metadata(
self,
get_rom_by_id_mock: AsyncMock,
client: TestClient,
access_token: str,
rom: Rom,
):
"""Test the unmatch_metadata functionality that clears all metadata."""
# Verify the ROM has existing metadata
initial_response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"igdb_id": str(MOCK_IGDB_ID)},
)
assert initial_response.status_code == status.HTTP_200_OK
assert get_rom_by_id_mock.called
initial_body = initial_response.json()
assert initial_body["igdb_id"] == MOCK_IGDB_ID
assert initial_body["igdb_metadata"] is not None
# Now unmatch all metadata
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
params={"unmatch_metadata": True},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["igdb_id"] is None
assert body["moby_id"] is None
assert body["ss_id"] is None
assert body["ra_id"] is None
assert body["launchbox_id"] is None
assert body["hasheous_id"] is None
assert body["tgdb_id"] is None
assert body["flashpoint_id"] is None
assert body["hltb_id"] is None
assert body["name"] == rom.fs_name
assert body["summary"] == ""
assert body["url_cover"] == ""
assert body["slug"] == ""
assert body["igdb_metadata"] == {}
assert body["moby_metadata"] == {}
assert body["ss_metadata"] == {}
assert body["merged_ra_metadata"] == {} # Special case
assert body["launchbox_metadata"] == {}
assert body["hasheous_metadata"] == {}
assert body["flashpoint_metadata"] == {}
assert body["hltb_metadata"] == {}
def test_update_rom_unmatch_metadata_with_other_data(
self, client: TestClient, access_token: str, rom: Rom
):
"""Test that unmatch_metadata works even when other data is provided."""
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
params={"unmatch_metadata": True},
data={
"igdb_id": str(MOCK_IGDB_ID), # This should be ignored
"name": "Should be ignored", # This should be ignored
"summary": "Should be ignored", # This should be ignored
},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
# All metadata should still be cleared despite other data being provided
assert body["igdb_id"] is None
assert body["name"] == rom.fs_name
assert body["summary"] == ""

View File

@@ -46,6 +46,28 @@ class TestFSRomsHandler:
full_path="n64/roms/Paper Mario (USA).z64",
)
@pytest.fixture
def rom_single_nested(self, platform: Platform):
return Rom(
id=3,
fs_name="Sonic (EU) [T]",
fs_path="n64/roms",
platform=platform,
full_path="n64/roms/Sonic (EU) [T]",
files=[
RomFile(
id=1,
file_name="Sonic (EU) [T].n64",
file_path="n64/roms/Sonic (EU) [T]",
),
RomFile(
id=2,
file_name="Sonic (EU) [T-En].z64",
file_path="n64/roms/Sonic (EU) [T]/translation",
),
],
)
@pytest.fixture
def rom_multi(self, platform: Platform):
return Rom(
@@ -555,3 +577,47 @@ class TestFSRomsHandler:
async with await handler.stream_file("psx/roms/PaRappa the Rapper.zip") as f:
content = await f.read()
assert len(content) > 0
async def test_top_level_files_only_in_main_hash(
self, handler: FSRomsHandler, rom_single_nested
):
"""Test that only top-level files contribute to main ROM hash calculation"""
rom_files, rom_crc, rom_md5, rom_sha1, rom_ra = await handler.get_rom_files(
rom_single_nested
)
# Verify we have multiple files (base game + translation)
assert len(rom_files) == 2
base_game_rom_file = None
translation_rom_file = None
for rom_file in rom_files:
if rom_file.file_name == "Sonic (EU) [T].n64":
base_game_rom_file = rom_file
elif rom_file.file_name == "Sonic (EU) [T-En].z64":
translation_rom_file = rom_file
assert base_game_rom_file is not None, "Base game file not found"
assert translation_rom_file is not None, "Translation file not found"
# Verify file categories
assert base_game_rom_file.category is None
assert translation_rom_file.category == RomFileCategory.TRANSLATION
# The main ROM hash should be different from the translation file hash
# (this verifies that the translation is not included in the main hash)
assert (
rom_md5 == base_game_rom_file.md5_hash
), "Main ROM hash should include base game file"
assert (
rom_md5 != translation_rom_file.md5_hash
), "Main ROM hash should not include translation file"
assert (
rom_sha1 == base_game_rom_file.sha1_hash
), "Main ROM hash should include base game file"
assert (
rom_sha1 != translation_rom_file.sha1_hash
), "Main ROM hash should not include translation file"

View File

@@ -268,54 +268,6 @@ class TestMetadataHandlerMethods:
assert index_entry is not None
assert index_entry["publisher"] == "Nintendo"
@pytest.mark.asyncio
async def test_switch_titledb_format_cache_missing_fetch_success(
self, handler: MetadataHandler
):
"""Test Switch TitleDB format when cache is missing but fetch succeeds."""
with patch.object(
async_cache, "exists", new_callable=AsyncMock
) as mock_exists, patch.object(
async_cache, "hget", new_callable=AsyncMock
) as mock_hget, patch(
"handler.metadata.base_handler.update_switch_titledb_task"
) as mock_task:
# First call returns False (cache missing), second returns True (after fetch)
mock_exists.side_effect = [False, True]
mock_hget.return_value = json.dumps({"name": "Fetched Game"})
mock_task.run = AsyncMock()
match = re.match(SWITCH_TITLEDB_REGEX, "70123456789012")
assert match is not None
result = await handler._switch_titledb_format(match, "original")
mock_task.run.assert_called_once_with(force=True)
assert result[0] == "Fetched Game"
@pytest.mark.asyncio
async def test_switch_titledb_format_cache_missing_fetch_fails(
self, handler: MetadataHandler
):
"""Test Switch TitleDB format when cache is missing and fetch fails."""
with patch.object(
async_cache, "exists", new_callable=AsyncMock
) as mock_exists, patch(
"handler.metadata.base_handler.update_switch_titledb_task"
) as mock_task, patch(
"handler.metadata.base_handler.log"
) as mock_log:
mock_exists.return_value = False # Cache always missing
mock_task.run = AsyncMock()
match = re.match(SWITCH_TITLEDB_REGEX, "70123456789012")
assert match is not None
result = await handler._switch_titledb_format(match, "original")
mock_log.error.assert_called()
assert result == ("original", None)
@pytest.mark.asyncio
async def test_switch_titledb_format_not_found(self, handler: MetadataHandler):
"""Test Switch TitleDB format when title ID not found."""

View File

@@ -43,9 +43,9 @@ class TestScanLibraryTask:
mock_log.info.assert_any_call("Scheduled library scan started...")
mock_scan_platforms.assert_called_once_with(
[],
scan_type=ScanType.UNIDENTIFIED,
platform_ids=[],
metadata_sources=[MetadataSource.RA, MetadataSource.LB],
scan_type=ScanType.UNIDENTIFIED,
)
mock_log.info.assert_any_call("Scheduled library scan done")

View File

@@ -1,3 +1,5 @@
import uuid
from __version__ import __version__
@@ -7,3 +9,12 @@ def get_version() -> str:
return __version__
return "development"
def is_valid_uuid(uuid_str: str) -> bool:
"""Check if a string is a valid UUID."""
try:
uuid.UUID(uuid_str, version=4)
return True
except ValueError:
return False

View File

@@ -7,7 +7,8 @@ from typing import cast
import sentry_sdk
from opentelemetry import trace
from rq.job import Job
from rq import Worker
from rq.job import Job, JobStatus
from config import (
ENABLE_RESCAN_ON_FILESYSTEM_CHANGE,
@@ -32,6 +33,7 @@ from handler.metadata import (
meta_ss_handler,
meta_tgdb_handler,
)
from handler.redis_handler import low_prio_queue, redis_client
from handler.scan_handler import MetadataSource, ScanType
from logger.formatter import CYAN
from logger.formatter import highlight as hl
@@ -67,17 +69,60 @@ VALID_EVENTS = frozenset(
Change = tuple[EventType, str]
def get_pending_scan_jobs() -> list[Job]:
"""Get all pending scan jobs (scheduled, queued, or running) for scan_platforms function.
Returns:
list[Job]: List of pending scan jobs that are not completed or failed
"""
pending_jobs = []
# Get jobs from the scheduler (delayed/scheduled jobs)
scheduled_jobs = tasks_scheduler.get_jobs()
for job in scheduled_jobs:
if (
isinstance(job, Job)
and job.func_name == "endpoints.sockets.scan.scan_platforms"
and job.get_status()
in [JobStatus.SCHEDULED, JobStatus.QUEUED, JobStatus.STARTED]
):
pending_jobs.append(job)
# Get jobs from the queue (immediate jobs)
queue_jobs = low_prio_queue.get_jobs()
for job in queue_jobs:
if (
isinstance(job, Job)
and job.func_name == "endpoints.sockets.scan.scan_platforms"
and job.get_status() in [JobStatus.QUEUED, JobStatus.STARTED]
):
pending_jobs.append(job)
# Get currently running jobs from workers
workers = Worker.all(connection=redis_client)
for worker in workers:
current_job = worker.get_current_job()
if (
current_job
and current_job.func_name == "endpoints.sockets.scan.scan_platforms"
and current_job.get_status() == JobStatus.STARTED
):
pending_jobs.append(current_job)
return pending_jobs
def process_changes(changes: Sequence[Change]) -> None:
if not ENABLE_RESCAN_ON_FILESYSTEM_CHANGE:
return
# Filter for valid events.
# Filter for valid events
changes = [change for change in changes if change[0] in VALID_EVENTS]
if not changes:
return
with tracer.start_as_current_span("process_changes"):
# Find affected platform slugs.
# Find affected platform slugs
fs_slugs: set[str] = set()
changes_platform_directory = False
for change in changes:
@@ -101,7 +146,7 @@ def process_changes(changes: Sequence[Change]) -> None:
log.info("No valid filesystem slugs found in changes, exiting...")
return
# Check whether any metadata source is enabled.
# Check whether any metadata source is enabled
source_mapping: dict[str, bool] = {
MetadataSource.IGDB: meta_igdb_handler.is_enabled(),
MetadataSource.SS: meta_ss_handler.is_enabled(),
@@ -119,31 +164,29 @@ def process_changes(changes: Sequence[Change]) -> None:
log.warning("No metadata sources enabled, skipping rescan")
return
# Get currently scheduled jobs for the scan_platforms function.
already_scheduled_jobs = [
job
for job in tasks_scheduler.get_jobs()
if isinstance(job, Job)
and job.func_name == "endpoints.sockets.scan.scan_platforms"
]
# Get currently pending scan jobs (scheduled, queued, or running)
pending_jobs = get_pending_scan_jobs()
# If a full rescan is already scheduled, skip further processing.
if any(job.args[0] == [] for job in already_scheduled_jobs):
log.info("Full rescan already scheduled")
# If a full rescan is already scheduled, skip further processing
full_rescan_jobs = [
job for job in pending_jobs if job.args and job.args[0] == []
]
if full_rescan_jobs:
log.info(f"Full rescan already scheduled ({len(full_rescan_jobs)} job(s))")
return
time_delta = timedelta(minutes=RESCAN_ON_FILESYSTEM_CHANGE_DELAY)
rescan_in_msg = f"rescanning in {hl(str(RESCAN_ON_FILESYSTEM_CHANGE_DELAY), color=CYAN)} minutes."
# Any change to a platform directory should trigger a full rescan.
# Any change to a platform directory should trigger a full rescan
if changes_platform_directory:
log.info(f"Platform directory changed, {rescan_in_msg}")
tasks_scheduler.enqueue_in(
time_delta,
scan_platforms,
[],
scan_type=ScanType.UNIDENTIFIED,
platform_ids=[],
metadata_sources=metadata_sources,
scan_type=ScanType.UNIDENTIFIED,
timeout=SCAN_TIMEOUT,
result_ttl=TASK_RESULT_TTL,
meta={
@@ -153,25 +196,32 @@ def process_changes(changes: Sequence[Change]) -> None:
)
return
# Otherwise, process each platform slug.
# Otherwise, process each platform slug
for fs_slug in fs_slugs:
# TODO: Query platforms from the database in bulk.
# TODO: Query platforms from the database in bulk
db_platform = db_platform_handler.get_platform_by_fs_slug(fs_slug)
if not db_platform:
continue
# Skip if a scan is already scheduled for this platform.
if any(db_platform.id in job.args[0] for job in already_scheduled_jobs):
log.info(f"Scan already scheduled for {hl(fs_slug)}")
# Skip if a scan is already scheduled for this platform
platform_scan_jobs = [
job
for job in pending_jobs
if job.args and db_platform.id in job.args[0]
]
if platform_scan_jobs:
log.info(
f"Scan already scheduled for {hl(fs_slug)} ({len(platform_scan_jobs)} job(s))"
)
continue
log.info(f"Change detected in {hl(fs_slug)} folder, {rescan_in_msg}")
tasks_scheduler.enqueue_in(
time_delta,
scan_platforms,
[db_platform.id],
scan_type=ScanType.QUICK,
platform_ids=[db_platform.id],
metadata_sources=metadata_sources,
scan_type=ScanType.QUICK,
timeout=SCAN_TIMEOUT,
result_ttl=TASK_RESULT_TTL,
meta={

View File

@@ -14,15 +14,15 @@
# ARGUMENT DECLARATIONS
ARG ALPINE_VERSION=3.22
ARG ALPINE_SHA256=4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
ARG ALPINE_SHA256=4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412
ARG PYTHON_VERSION=3.13
ARG PYTHON_ALPINE_SHA256=9ba6d8cbebf0fb6546ae71f2a1c14f6ffd2fdab83af7fa5669734ef30ad48844
ARG PYTHON_ALPINE_SHA256=e5fa639e49b85986c4481e28faa2564b45aa8021413f31026c3856e5911618b1
ARG NODE_VERSION=20.19
ARG NODE_ALPINE_SHA256=eabac870db94f7342d6c33560d6613f188bbcf4bbe1f4eb47d5e2a08e1a37722
ARG NGINX_VERSION=1.29.1
ARG NGINX_SHA256=42a516af16b852e33b7682d5ef8acbd5d13fe08fecadc7ed98605ba5e3b26ab8
ARG UV_VERSION=0.7.19
ARG UV_SHA256=9ce16aa2fe33496c439996865dc121371bb33fd5fb37500007d48e2078686b0d
ARG NODE_ALPINE_SHA256=96ee26670a085b1a61231a468db85ae7e493ddfbd8c35797bfa0b99b634665fe
ARG NGINX_VERSION=1.29.2
ARG NGINX_SHA256=61e01287e546aac28a3f56839c136b31f590273f3b41187a36f46f6a03bbfe22
ARG UV_VERSION=0.8.24
ARG UV_SHA256=779f3d612539b4696a1b228724cd79b6e8b8604075a9ac7d15378bccf4053373
FROM python:${PYTHON_VERSION}-alpine${ALPINE_VERSION}@sha256:${PYTHON_ALPINE_SHA256} AS python-alias

View File

@@ -117,6 +117,10 @@ WEB_SERVER_MAX_REQUESTS_JITTER=100
WEB_SERVER_WORKER_CONNECTIONS=1000
IPV4_ONLY=false
# Redis Workers
SCAN_TIMEOUT=
SCAN_WORKERS=
# Development only
DEV_MODE=true
DEV_HTTPS=false

View File

@@ -47,7 +47,7 @@
"openapi-typescript-codegen": "^0.29.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.42.0",
"vite": "^6.3.6",
"vite": "^6.4.1",
"vite-plugin-mkcert": "^1.17.8",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-vuetify": "^2.0.4",
@@ -2978,6 +2978,7 @@
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.41.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz",
"integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==",
"cpu": [
"x64"
],
@@ -2985,8 +2986,7 @@
"optional": true,
"os": [
"darwin"
],
"integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg=="
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.41.1",
@@ -8858,10 +8858,11 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@@ -66,7 +66,7 @@
"openapi-typescript-codegen": "^0.29.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.42.0",
"vite": "^6.3.6",
"vite": "^6.4.1",
"vite-plugin-mkcert": "^1.17.8",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-vuetify": "^2.0.4",

View File

@@ -5,6 +5,7 @@
import type { EjsControls } from './EjsControls';
export type ConfigResponse = {
CONFIG_FILE_MOUNTED: boolean;
CONFIG_FILE_WRITABLE: boolean;
EXCLUDED_PLATFORMS: Array<string>;
EXCLUDED_SINGLE_EXT: Array<string>;
EXCLUDED_SINGLE_FILES: Array<string>;

View File

@@ -36,7 +36,6 @@ export type DetailedRomSchema = {
platform_id: number;
platform_slug: string;
platform_fs_slug: string;
platform_name: string;
platform_custom_name: (string | null);
platform_display_name: string;
fs_name: string;

View File

@@ -9,9 +9,9 @@ export type ScanStats = {
new_platforms: number;
identified_platforms: number;
scanned_roms: number;
added_roms: number;
new_roms: number;
identified_roms: number;
scanned_firmware: number;
added_firmware: number;
new_firmware: number;
};

View File

@@ -10,7 +10,6 @@ export type SearchRomSchema = {
sgdb_id?: (number | null);
flashpoint_id?: (string | null);
launchbox_id?: (number | null);
hltb_id?: (number | null);
platform_id: number;
name: string;
slug?: string;
@@ -21,7 +20,6 @@ export type SearchRomSchema = {
sgdb_url_cover?: string;
flashpoint_url_cover?: string;
launchbox_url_cover?: string;
hltb_url_cover?: string;
is_unidentified: boolean;
is_identified: boolean;
};

View File

@@ -30,7 +30,6 @@ export type SimpleRomSchema = {
platform_id: number;
platform_slug: string;
platform_fs_slug: string;
platform_name: string;
platform_custom_name: (string | null);
platform_display_name: string;
fs_name: string;

View File

@@ -20,7 +20,7 @@ const releaseDate = new Date(
});
const platformsStore = storePlatforms();
const { filteredPlatforms } = storeToRefs(platformsStore);
const { allPlatforms } = storeToRefs(platformsStore);
const hashMatches = computed(() => {
return [
@@ -81,7 +81,7 @@ const hashMatches = computed(() => {
>
<MissingFromFSIcon
v-if="
filteredPlatforms.find((p) => p.id === rom.platform_id)
allPlatforms.find((p) => p.id === rom.platform_id)
?.missing_from_fs
"
class="mr-2"
@@ -90,7 +90,7 @@ const hashMatches = computed(() => {
<PlatformIcon
:key="rom.platform_slug"
:slug="rom.platform_slug"
:name="rom.platform_name"
:name="rom.platform_display_name"
:fs-slug="rom.platform_fs_slug"
:size="30"
class="mr-2"

View File

@@ -214,8 +214,8 @@ async function updateCollection() {
<template v-for="field in collectionInfoFields" :key="field.key">
<div>
<v-chip size="small" class="mr-2 px-0" label>
<v-chip label> {{ field.label }} </v-chip
><span class="px-2">{{
<v-chip label>{{ field.label }}</v-chip>
<span class="px-2">{{
currentSmartCollection[
field.key as keyof typeof currentSmartCollection
]?.toString()
@@ -230,6 +230,25 @@ async function updateCollection() {
</v-card-text>
</v-card>
</v-col>
<v-col cols="12">
<v-card class="mt-4 bg-toplayer fill-width" elevation="0">
<v-card-text class="pa-4 d-flex">
<template
v-for="filter in Object.entries(
currentSmartCollection.filter_criteria,
)"
:key="filter[0]"
>
<div>
<v-chip size="small" class="mr-2 px-0" label>
<v-chip label> {{ filter[0] }} </v-chip>
<span class="px-2">{{ filter[1] }}</span>
</v-chip>
</div>
</template>
</v-card-text>
</v-card>
</v-col>
</v-row>
<RSection
v-if="

View File

@@ -122,7 +122,7 @@ async function updatePlatform() {
}
async function scan() {
scanningStore.set(true);
scanningStore.setScanning(true);
if (!socket.connected) socket.connect();

View File

@@ -26,7 +26,7 @@ const {
searchTerm,
filterUnmatched,
filterMatched,
filterFavourites,
filterFavorites,
filterDuplicates,
filterPlayables,
filterRA,
@@ -57,7 +57,7 @@ async function goToRandomGame() {
: null,
filterUnmatched: filterUnmatched.value,
filterMatched: filterMatched.value,
filterFavourites: filterFavourites.value,
filterFavorites: filterFavorites.value,
filterDuplicates: filterDuplicates.value,
filterPlayables: filterPlayables.value,
filterRA: filterRA.value,

View File

@@ -8,7 +8,7 @@ import { useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import SearchTextField from "@/components/Gallery/AppBar/Search/SearchTextField.vue";
import FilterDuplicatesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterDuplicatesBtn.vue";
import FilterFavouritesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterFavouritesBtn.vue";
import FilterFavoritesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue";
import FilterMatchedBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue";
import FilterMissingBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue";
import FilterPlayablesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue";
@@ -46,7 +46,7 @@ const {
activeFilterDrawer,
filterUnmatched,
filterMatched,
filterFavourites,
filterFavorites,
filterDuplicates,
filterPlayables,
filterRA,
@@ -72,7 +72,7 @@ const {
filterLanguages,
} = storeToRefs(galleryFilterStore);
const { filteredRoms } = storeToRefs(romsStore);
const { filteredPlatforms } = storeToRefs(platformsStore);
const { allPlatforms } = storeToRefs(platformsStore);
const emitter = inject<Emitter<Events>>("emitter");
const onFilterChange = debounce(
@@ -86,7 +86,7 @@ const onFilterChange = debounce(
search: searchTerm.value,
filterMatched: filterMatched.value ? "1" : null,
filterUnmatched: filterUnmatched.value ? "1" : null,
filterFavourites: filterFavourites.value ? "1" : null,
filterFavorites: filterFavorites.value ? "1" : null,
filterDuplicates: filterDuplicates.value ? "1" : null,
filterPlayables: filterPlayables.value ? "1" : null,
filterMissing: filterMissing.value ? "1" : null,
@@ -215,7 +215,7 @@ onMounted(async () => {
search: urlSearch,
filterMatched: urlFilteredMatch,
filterUnmatched: urlFilteredUnmatched,
filterFavourites: urlFilteredFavourites,
filterFavorites: urlFilteredFavorites,
filterDuplicates: urlFilteredDuplicates,
filterPlayables: urlFilteredPlayables,
filterMissing: urlFilteredMissing,
@@ -239,8 +239,8 @@ onMounted(async () => {
if (urlFilteredUnmatched !== undefined) {
galleryFilterStore.setFilterUnmatched(true);
}
if (urlFilteredFavourites !== undefined) {
galleryFilterStore.setFilterFavourites(true);
if (urlFilteredFavorites !== undefined) {
galleryFilterStore.setFilterFavorites(true);
}
if (urlFilteredDuplicates !== undefined) {
galleryFilterStore.setFilterDuplicates(true);
@@ -305,7 +305,7 @@ onMounted(async () => {
);
watch(
() => filteredPlatforms.value,
() => allPlatforms.value,
async () => setFilters(),
{ immediate: true }, // Ensure watcher is triggered immediately
);
@@ -336,7 +336,7 @@ onMounted(async () => {
class="mt-2"
:tabindex="activeFilterDrawer ? 0 : -1"
/>
<FilterFavouritesBtn
<FilterFavoritesBtn
class="mt-2"
:tabindex="activeFilterDrawer ? 0 : -1"
/>

View File

@@ -8,10 +8,10 @@ import type { Events } from "@/types/emitter";
const { t } = useI18n();
const galleryFilterStore = storeGalleryFilter();
const { filterFavourites } = storeToRefs(galleryFilterStore);
const { filterFavorites } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
function setFavourites() {
galleryFilterStore.switchFilterFavourites();
function setFavorites() {
galleryFilterStore.switchFilterFavorites();
emitter?.emit("filterRoms", null);
}
</script>
@@ -20,16 +20,16 @@ function setFavourites() {
<v-btn
block
variant="tonal"
:color="filterFavourites ? 'primary' : ''"
@click="setFavourites"
:color="filterFavorites ? 'primary' : ''"
@click="setFavorites"
>
<v-icon :color="filterFavourites ? 'primary' : ''"> mdi-star </v-icon
<v-icon :color="filterFavorites ? 'primary' : ''"> mdi-star </v-icon
><span
class="ml-2"
:class="{
'text-primary': filterFavourites,
'text-primary': filterFavorites,
}"
>{{ t("platform.show-favourites") }}</span
>{{ t("platform.show-favorites") }}</span
>
</v-btn>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import { inject } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { ROUTES } from "@/plugins/router";
import romApi from "@/services/api/rom";
import type { Events } from "@/types/emitter";
withDefaults(
defineProps<{
block?: boolean;
height?: string;
rounded?: boolean;
withTag?: boolean;
}>(),
{
block: false,
height: "",
rounded: false,
withTag: false,
},
);
const { t } = useI18n();
const router = useRouter();
const emitter = inject<Emitter<Events>>("emitter");
async function goToRandomGame() {
try {
const apiParams = {
limit: 1,
offset: 0,
};
// Get the total count first
const { data: romsResponse } = await romApi.getRoms(apiParams);
if (!romsResponse.total || romsResponse.total === 0) {
emitter?.emit("snackbarShow", {
msg: "No games found",
icon: "mdi-information",
color: "info",
timeout: 3000,
});
return;
}
// Get a random offset between 0 and total-1
const randomOffset = Math.floor(Math.random() * romsResponse.total);
const { data: randomRomResponse } = await romApi.getRoms({
...apiParams,
offset: randomOffset,
});
if (randomRomResponse.items.length > 0) {
const randomRom = randomRomResponse.items[0];
router.push({ name: ROUTES.ROM, params: { rom: randomRom.id } });
} else {
emitter?.emit("snackbarShow", {
msg: "Could not find a random game",
icon: "mdi-alert",
color: "warning",
timeout: 3000,
});
}
} catch (error) {
console.error("Error fetching random game:", error);
emitter?.emit("snackbarShow", {
msg: "Error finding random game",
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
}
}
</script>
<template>
<v-btn
icon
:block="block"
variant="flat"
color="background"
:height="height"
:class="{ rounded: rounded }"
class="bg-background d-flex align-center justify-center"
@click="goToRandomGame"
>
<div class="d-flex flex-column align-center">
<v-icon>mdi-shuffle-variant</v-icon>
<v-expand-transition>
<span v-if="withTag" class="text-caption text-center">{{
t("common.random")
}}</span>
</v-expand-transition>
</div>
</v-btn>
</template>

View File

@@ -42,7 +42,7 @@ function scrollToTop() {
});
}
async function onScan() {
scanningStore.set(true);
scanningStore.setScanning(true);
const romCount = romsStore.selectedRoms.length;
emitter?.emit("snackbarShow", {
msg: `Scanning ${romCount} game${romCount > 1 ? "s" : ""}...`,
@@ -68,7 +68,7 @@ function resetSelection() {
emitter?.emit("openFabMenu", false);
}
async function addToFavourites() {
async function addToFavorites() {
if (!favoriteCollection.value) return;
favoriteCollection.value.rom_ids = favoriteCollection.value.rom_ids.concat(
selectedRoms.value.map((r) => r.id),
@@ -77,7 +77,7 @@ async function addToFavourites() {
.updateCollection({ collection: favoriteCollection.value as Collection })
.then(() => {
emitter?.emit("snackbarShow", {
msg: "Roms added to favourites successfully!",
msg: "Roms added to favorites successfully!",
icon: "mdi-check-bold",
color: "green",
timeout: 2000,
@@ -97,19 +97,19 @@ async function addToFavourites() {
});
}
async function removeFromFavourites() {
async function removeFromFavorites() {
if (!favoriteCollection.value) return;
favoriteCollection.value.rom_ids = favoriteCollection.value.rom_ids.filter(
(value) => !selectedRoms.value.map((r) => r.id).includes(value),
);
if (romsStore.currentCollection?.name.toLowerCase() == "favourites") {
if (romsStore.currentCollection?.is_favorite) {
romsStore.remove(selectedRoms.value);
}
await collectionApi
.updateCollection({ collection: favoriteCollection.value as Collection })
.then(({ data }) => {
emitter?.emit("snackbarShow", {
msg: "Roms removed from favourites successfully!",
msg: "Roms removed from favorites successfully!",
icon: "mdi-check-bold",
color: "green",
timeout: 2000,
@@ -191,25 +191,25 @@ async function onDownload() {
/>
<v-btn
key="3"
:title="t('rom.add-to-fav')"
:title="t('rom.add-to-favorites')"
color="toplayer"
elevation="8"
icon="mdi-star"
class="rounded"
:size="35"
rounded="0"
@click="addToFavourites"
@click="addToFavorites"
/>
<v-btn
key="4"
:title="t('rom.remove-from-fav')"
:title="t('rom.remove-from-favorites')"
color="toplayer"
elevation="8"
icon="mdi-star-remove-outline"
class="rounded"
:size="35"
rounded="0"
@click="removeFromFavourites"
@click="removeFromFavorites"
/>
<v-btn
key="5"

View File

@@ -13,10 +13,10 @@ const scanProgress = computed(() => {
total_roms,
scanned_platforms,
scanned_roms,
added_roms,
new_roms,
identified_roms,
scanned_firmware,
added_firmware,
new_firmware,
} = props.scanStats;
return {
@@ -26,10 +26,10 @@ const scanProgress = computed(() => {
),
roms: `${scanned_roms}/${total_roms}`,
romsPercentage: Math.round((scanned_roms / total_roms) * 100),
addedRoms: added_roms,
newRoms: new_roms,
metadataRoms: identified_roms,
scannedFirmware: scanned_firmware,
addedFirmware: added_firmware,
newFirmware: new_firmware,
};
});
</script>
@@ -92,7 +92,7 @@ const scanProgress = computed(() => {
<v-icon icon="mdi-plus-circle" size="20" />
</v-avatar>
<div class="font-weight-bold">
{{ scanProgress.addedRoms }}
{{ scanProgress.newRoms }}
</div>
<div class="text-uppercase">Added</div>
</div>

View File

@@ -63,7 +63,7 @@ const editable = ref(false);
variant="text"
icon="mdi-cog"
@click="editable = !editable"
:disabled="!config.CONFIG_FILE_MOUNTED"
:disabled="!config.CONFIG_FILE_WRITABLE"
/>
</template>
<template #content>

View File

@@ -48,7 +48,7 @@ const editable = ref(false);
variant="text"
icon="mdi-cog"
@click="editable = !editable"
:disabled="!config.CONFIG_FILE_MOUNTED"
:disabled="!config.CONFIG_FILE_WRITABLE"
/>
</template>
<template #content>

View File

@@ -36,10 +36,8 @@ const editable = ref(false);
/>
</template>
<p>
Versions of the same platform. A common example is Capcom Play System
1 is an arcade system. Platform versions will let you setup a custom
platform for RomM to import and tell RomM which platform it needs to
scrape against.
Platform versions allow you to create custom platform entries for
games that belong to the same system but have different versions.
</p>
</v-tooltip>
</template>
@@ -52,7 +50,7 @@ const editable = ref(false);
variant="text"
icon="mdi-cog"
@click="editable = !editable"
:disabled="!config.CONFIG_FILE_MOUNTED"
:disabled="!config.CONFIG_FILE_WRITABLE"
/>
</template>
<template #content>

View File

@@ -12,22 +12,22 @@ const props = defineProps<{
}>();
const { t } = useI18n();
const platformsStore = storePlatforms();
const { filteredPlatforms } = storeToRefs(platformsStore);
const { allPlatforms } = storeToRefs(platformsStore);
const orderBy = ref<"name" | "size" | "count">("name");
const sortedPlatforms = computed(() => {
const platforms = [...filteredPlatforms.value];
if (orderBy.value === "size") {
return platforms.sort(
return allPlatforms.value.sort(
(a, b) => Number(b.fs_size_bytes) - Number(a.fs_size_bytes),
);
}
if (orderBy.value === "count") {
return platforms.sort((a, b) => b.rom_count - a.rom_count);
return allPlatforms.value.sort((a, b) => b.rom_count - a.rom_count);
}
// Default to name
return platforms.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
return allPlatforms.value.sort((a, b) =>
a.display_name.localeCompare(b.display_name, undefined, {
sensitivity: "base",
}),
);
});

View File

@@ -52,7 +52,7 @@ const memoizedCovers = ref({
});
const collectionCoverImage = computed(() =>
props.collection.name?.toLowerCase() == "favourites"
props.collection.is_favorite
? getFavoriteCoverImage(props.collection.name)
: getCollectionCoverImage(props.collection.name),
);

View File

@@ -20,7 +20,11 @@ const { mdAndUp } = useDisplay();
const router = useRouter();
const show = ref(false);
const heartbeat = storeHeartbeat();
const collection = ref<UpdatedCollection>({ name: "" } as UpdatedCollection);
const collection = ref<UpdatedCollection>({
name: "",
path_covers_large: [],
path_covers_small: [],
} as unknown as UpdatedCollection);
const collectionsStore = storeCollections();
const imagePreviewUrl = ref<string | undefined>("");
const removeCover = ref(false);
@@ -34,7 +38,7 @@ emitter?.on("updateUrlCover", (coverUrl) => {
});
const missingCoverImage = computed(() =>
getMissingCoverImage(collection.value.name),
getMissingCoverImage(collection.value.name || ""),
);
function triggerFileInput() {
@@ -84,9 +88,7 @@ async function createCollection() {
timeout: 2000,
});
collectionsStore.addCollection(data);
if (data.name.toLowerCase() == "favourites") {
collectionsStore.setFavoriteCollection(data);
}
if (data.is_favorite) collectionsStore.setFavoriteCollection(data);
emitter?.emit("showLoadingDialog", { loading: false, scrim: false });
router.push({ name: ROUTES.COLLECTION, params: { collection: data.id } });
closeDialog();

View File

@@ -28,7 +28,7 @@ const {
searchTerm,
filterUnmatched,
filterMatched,
filterFavourites,
filterFavorites,
filterDuplicates,
filterPlayables,
filterRA,
@@ -68,7 +68,7 @@ const filterSummary = computed(() => {
filters.push(`Platform: ${selectedPlatform.value.name}`);
if (filterMatched.value) filters.push("Matched only");
if (filterUnmatched.value) filters.push("Unmatched only");
if (filterFavourites.value) filters.push("Favourites");
if (filterFavorites.value) filters.push("Favorites");
if (filterDuplicates.value) filters.push("Duplicates");
if (filterPlayables.value) filters.push("Playable");
if (filterRA.value) filters.push("Has RetroAchievements");
@@ -117,7 +117,7 @@ async function createSmartCollection() {
filterCriteria.platform_id = selectedPlatform.value.id;
if (filterMatched.value) filterCriteria.matched = true;
if (filterUnmatched.value) filterCriteria.matched = false;
if (filterFavourites.value) filterCriteria.favourite = true;
if (filterFavorites.value) filterCriteria.favorite = true;
if (filterDuplicates.value) filterCriteria.duplicate = true;
if (filterPlayables.value) filterCriteria.playable = true;
if (filterRA.value) filterCriteria.has_ra = true;

View File

@@ -36,7 +36,7 @@ async function deleteCollection() {
color: "green",
});
collectionsStore.removeCollection(collection.value as Collection);
if (collection.value?.name.toLowerCase() == "favourites") {
if (collection.value?.is_favorite) {
collectionsStore.setFavoriteCollection(undefined);
}
})

View File

@@ -23,7 +23,7 @@ const { toggleFavorite } = useFavoriteToggle(emitter);
const romsStore = storeRoms();
const scanningStore = storeScanning();
async function switchFromFavourites() {
async function switchFromFavorites() {
await toggleFavorite(props.rom);
}
@@ -56,7 +56,7 @@ async function resetLastPlayed() {
}
async function onScan() {
scanningStore.set(true);
scanningStore.setScanning(true);
emitter?.emit("snackbarShow", {
msg: `Refreshing ${props.rom.name} metadata...`,
icon: "mdi-loading mdi-spin",
@@ -96,10 +96,10 @@ async function onScan() {
</v-list-item>
<v-list-item
class="py-4 pr-5"
@click="emitter?.emit('showEditRomDialog', { ...rom })"
@click="emitter?.emit('showEditRomDialog', rom)"
>
<v-list-item-title class="d-flex">
<v-icon icon="mdi-pencil-box" class="mr-2" />{{ t("rom.edit") }}
<v-icon icon="mdi-pencil-box" class="mr-2" />{{ t("common.edit") }}
</v-list-item-title>
</v-list-item>
<v-list-item class="py-4 pr-5" @click="onScan">
@@ -124,7 +124,7 @@ async function onScan() {
<v-list-item
v-if="auth.scopes.includes('collections.write')"
class="py-4 pr-5"
@click="switchFromFavourites"
@click="switchFromFavorites"
>
<v-list-item-title class="d-flex">
<v-icon
@@ -136,8 +136,8 @@ async function onScan() {
class="mr-2"
/>{{
collectionsStore.isFavorite(rom)
? t("rom.remove-from-fav")
: t("rom.add-to-fav")
? t("rom.remove-from-favorites")
: t("rom.add-to-favorites")
}}
</v-list-item-title>
</v-list-item>

View File

@@ -154,8 +154,7 @@ const largeCover = computed(() => {
props.rom.moby_url_cover ||
props.rom.ss_url_cover ||
props.rom.launchbox_url_cover ||
props.rom.flashpoint_url_cover ||
props.rom.hltb_url_cover
props.rom.flashpoint_url_cover
);
const pathCoverLarge = isWebpEnabled.value
? props.rom.path_cover_large?.replace(EXTENSION_REGEX, ".webp")
@@ -270,8 +269,7 @@ onBeforeUnmount(() => {
!rom.ss_url_cover &&
!rom.sgdb_url_cover &&
!rom.launchbox_url_cover &&
!rom.flashpoint_url_cover &&
!rom.hltb_url_cover)
!rom.flashpoint_url_cover)
"
class="translucent text-white"
:class="
@@ -313,7 +311,7 @@ onBeforeUnmount(() => {
:key="rom.platform_slug"
:size="25"
:slug="rom.platform_slug"
:name="rom.platform_name"
:name="rom.platform_display_name"
:fs-slug="rom.platform_fs_slug"
class="ml-1"
/>

View File

@@ -101,24 +101,5 @@ defineProps<{ rom: SearchRomSchema }>();
</v-avatar>
</template>
</v-tooltip>
<v-tooltip
location="top"
class="tooltip"
transition="fade-transition"
text="HowLongToBeat matched"
open-delay="500"
>
<template #activator="{ props }">
<v-avatar
v-bind="props"
v-if="rom.hltb_id"
class="mr-1 mb-1"
size="28"
rounded="1"
>
<v-img src="/assets/scrappers/hltb.png" />
</v-avatar>
</template>
</v-tooltip>
</v-row>
</template>

View File

@@ -44,9 +44,10 @@ async function deleteRoms() {
.deleteRoms({ roms: roms.value, deleteFromFs: romsToDeleteFromFs.value })
.then((response) => {
emitter?.emit("snackbarShow", {
msg: romsToDeleteFromFs.value
? `${response.data.successful_items} roms deleted from filesystem`
: `${response.data.successful_items} roms deleted from RomM`,
msg:
romsToDeleteFromFs.value.length > 0
? `${response.data.successful_items} roms deleted from the filesystem`
: `${response.data.successful_items} roms deleted from the database`,
icon: "mdi-check-bold",
color: "green",
});

View File

@@ -14,13 +14,15 @@ import storeRoms, { type SimpleRom } from "@/stores/roms";
import storeUpload from "@/stores/upload";
import type { Events } from "@/types/emitter";
import { getMissingCoverImage } from "@/utils/covers";
import MetadataIdSection from "./EditRom/MetadataIdSection.vue";
import MetadataSections from "./EditRom/MetadataSections.vue";
const { t } = useI18n();
const { lgAndUp } = useDisplay();
const heartbeat = storeHeartbeat();
const route = useRoute();
const show = ref(false);
const rom = ref<UpdateRom>();
const rom = ref<UpdateRom | null>(null);
const romsStore = storeRoms();
const imagePreviewUrl = ref<string | undefined>("");
const removeCover = ref(false);
@@ -31,14 +33,17 @@ const uploadStore = storeUpload();
const validForm = ref(false);
const showConfirmDeleteManual = ref(false);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showEditRomDialog", (romToEdit: UpdateRom | undefined) => {
emitter?.on("showEditRomDialog", (romToEdit: SimpleRom) => {
show.value = true;
rom.value = romToEdit;
removeCover.value = false;
});
emitter?.on("updateUrlCover", (url_cover) => {
setArtwork(url_cover);
});
const computedAspectRatio = computed(() => {
const ratio = rom.value?.platform_id
? platfotmsStore.getAspectRatio(rom.value?.platform_id)
@@ -218,10 +223,14 @@ async function updateRom() {
function closeDialog() {
show.value = false;
rom.value = null;
imagePreviewUrl.value = "";
rom.value = undefined;
showConfirmDeleteManual.value = false;
}
function handleRomUpdateFromMetadata(updatedRom: UpdateRom) {
rom.value = updatedRom;
}
</script>
<template>
@@ -269,10 +278,10 @@ function closeDialog() {
>
<v-icon size="large"> mdi-pencil </v-icon>
<v-file-input
hide-details
id="cover-file-input"
v-model="rom.artwork"
accept="image/*"
hide-details
class="file-input"
@change="previewImage"
/>
@@ -292,14 +301,16 @@ function closeDialog() {
</v-col>
<v-col class="pa-4">
<v-text-field
hide-details
v-model="rom.name"
:rules="[(value: string) => !!value || t('common.required')]"
:label="t('common.name')"
variant="outlined"
class="my-2"
@keyup.enter="updateRom"
class="my-4"
/>
<v-text-field
hide-details
v-model="rom.fs_name"
:rules="[(value: string) => !!value || t('common.required')]"
:label="
@@ -308,11 +319,11 @@ function closeDialog() {
: t('rom.filename')
"
variant="outlined"
class="my-2"
@keyup.enter="updateRom"
class="my-4"
>
<template #details>
<v-label class="text-caption text-wrap">
<v-label class="text-caption text-wrap mt-1">
<v-icon size="small" class="text-primary mr-2">
mdi-folder-file-outline
</v-icon>
@@ -323,67 +334,61 @@ function closeDialog() {
</template>
</v-text-field>
<v-textarea
hide-details
v-model="rom.summary"
:label="t('rom.summary')"
variant="outlined"
class="my-2"
class="my-4"
/>
<v-chip
:variant="rom.has_manual ? 'flat' : 'tonal'"
label
size="large"
class="bg-toplayer px-0"
>
<span
class="ml-4 flex items-center"
:class="{
'text-romm-red': !rom.has_manual,
'text-romm-green': rom.has_manual,
}"
<div class="d-flex justify-space-between">
<v-chip
:variant="rom.has_manual ? 'flat' : 'tonal'"
label
class="bg-toplayer px-0"
>
{{ t("rom.manual") }}
<v-icon class="ml-1">
{{ rom.has_manual ? "mdi-check" : "mdi-close" }}
</v-icon>
</span>
<v-btn
class="bg-toplayer ml-3"
icon="mdi-cloud-upload-outline"
rounded="0"
size="small"
@click="triggerFileInput('manual-file-input')"
>
<v-icon size="large"> mdi-cloud-upload-outline </v-icon>
<v-file-input
id="manual-file-input"
v-model="manualFiles"
accept="application/pdf"
hide-details
multiple
class="file-input"
@change="uploadManuals"
<span
class="ml-4 flex items-center"
:class="{
'text-romm-red': !rom.has_manual,
'text-romm-green': rom.has_manual,
}"
>
{{ t("rom.manual") }}
<v-icon class="ml-1">
{{ rom.has_manual ? "mdi-check" : "mdi-close" }}
</v-icon>
</span>
<v-btn
class="bg-toplayer ml-3"
icon="mdi-cloud-upload-outline"
rounded="0"
size="small"
@click="triggerFileInput('manual-file-input')"
>
<v-icon size="large"> mdi-cloud-upload-outline </v-icon>
<v-file-input
id="manual-file-input"
v-model="manualFiles"
accept="application/pdf"
hide-details
multiple
class="file-input"
@change="uploadManuals"
/>
</v-btn>
<v-btn
v-if="rom.has_manual"
size="small"
class="bg-toplayer text-romm-red"
icon="mdi-delete"
rounded="0"
@click="confirmRemoveManual"
/>
</v-btn>
<v-btn
v-if="rom.has_manual"
size="small"
class="bg-toplayer text-romm-red"
icon="mdi-delete"
rounded="0"
@click="confirmRemoveManual"
/>
</v-chip>
<div v-if="rom.has_manual">
<v-label class="text-caption text-wrap">
<v-icon size="small" class="text-primary mr-2">
mdi-folder-file-outline
</v-icon>
<span> /romm/resources/{{ rom.path_manual }} </span>
</v-label>
</div>
<div class="mt-6">
</v-chip>
<v-btn
:disabled="rom.is_unidentified"
class="ml-2"
:class="{
'text-romm-red bg-toplayer': !rom.is_unidentified,
}"
@@ -393,15 +398,33 @@ function closeDialog() {
{{ t("rom.unmatch") }}
</v-btn>
</div>
<div v-if="rom.has_manual">
<v-label class="text-caption text-wrap">
<v-icon size="small" class="text-primary mr-2">
mdi-folder-file-outline
</v-icon>
<span> /romm/resources/{{ rom.path_manual }} </span>
</v-label>
</div>
</v-col>
</v-row>
<v-expansion-panels class="mt-6">
<MetadataIdSection
:rom="rom"
@update:rom="handleRomUpdateFromMetadata"
/>
<MetadataSections
:rom="rom"
@update:rom="handleRomUpdateFromMetadata"
/>
</v-expansion-panels>
</v-form>
</template>
<template #append>
<v-divider />
<v-row class="justify-center pa-2" no-gutters>
<v-btn-group divided density="compact">
<v-btn class="bg-toplayer" @click="closeDialog">
<v-btn class="text-romm-red bg-toplayer" @click="closeDialog">
{{ t("common.cancel") }}
</v-btn>
<v-btn

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import type { UpdateRom } from "@/services/api/rom";
const props = defineProps<{ rom: UpdateRom }>();
const emit = defineEmits<{
"update:rom": [rom: UpdateRom];
}>();
const updateField = (field: keyof UpdateRom, value: string | number | null) => {
emit("update:rom", { ...props.rom, [field]: value });
};
const parseIdValue = (value: string): number | null => {
if (!value || value.trim() === "") return null;
return parseInt(value);
};
</script>
<template>
<v-expansion-panel>
<v-expansion-panel-title class="bg-toplayer">
<v-icon class="mr-2">mdi-database</v-icon>
Metadata IDs
</v-expansion-panel-title>
<v-expansion-panel-text class="mt-4 px-2">
<v-row no-gutters class="my-2">
<v-col cols="12" md="6" xl="4" class="pa-2">
<v-text-field
hide-details
clearable
:model-value="rom.igdb_id?.toString() || null"
label="IGDB ID"
variant="outlined"
@update:model-value="
(value) => updateField('igdb_id', parseIdValue(value))
"
/>
</v-col>
<v-col cols="12" md="6" xl="4" class="pa-2">
<v-text-field
hide-details
clearable
:model-value="rom.moby_id?.toString() || null"
label="MobyGames ID"
variant="outlined"
@update:model-value="
(value) => updateField('moby_id', parseIdValue(value))
"
/>
</v-col>
<v-col cols="12" md="6" xl="4" class="pa-2">
<v-text-field
hide-details
clearable
:model-value="rom.ss_id?.toString() || null"
label="ScreenScraper ID"
variant="outlined"
@update:model-value="
(value) => updateField('ss_id', parseIdValue(value))
"
/>
</v-col>
<v-col cols="12" md="6" xl="4" class="pa-2">
<v-text-field
hide-details
clearable
:model-value="rom.ra_id?.toString() || null"
label="RetroAchievements ID"
variant="outlined"
@update:model-value="
(value) => updateField('ra_id', parseIdValue(value))
"
/>
</v-col>
<v-col cols="12" md="6" xl="4" class="pa-2">
<v-text-field
hide-details
clearable
:model-value="rom.launchbox_id?.toString() || null"
label="LaunchBox ID"
variant="outlined"
@update:model-value="
(value) => updateField('launchbox_id', parseIdValue(value))
"
/>
</v-col>
<v-col cols="12" md="6" xl="4" class="pa-2">
<v-text-field
hide-details
clearable
:model-value="rom.sgdb_id?.toString() || null"
label="SteamGridDB ID"
variant="outlined"
@update:model-value="
(value) => updateField('sgdb_id', parseIdValue(value))
"
/>
</v-col>
<v-col cols="12" md="6" xl="4" class="pa-2">
<v-text-field
hide-details
clearable
:model-value="rom.hasheous_id?.toString() || null"
label="Hasheous ID"
variant="outlined"
@update:model-value="
(value) => updateField('hasheous_id', parseIdValue(value))
"
/>
</v-col>
<v-col cols="12" md="6" xl="4" class="pa-2">
<v-text-field
hide-details
clearable
:model-value="rom.flashpoint_id || null"
label="Flashpoint ID"
variant="outlined"
@update:model-value="
(value) => updateField('flashpoint_id', value || null)
"
/>
</v-col>
<v-col cols="12" md="6" xl="4" class="pa-2">
<v-text-field
hide-details
clearable
:model-value="rom.hltb_id?.toString() || null"
label="HowLongToBeat ID"
variant="outlined"
@update:model-value="
(value) => updateField('hltb_id', parseIdValue(value))
"
/>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</template>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { UpdateRom } from "@/services/api/rom";
import type { SimpleRom } from "@/stores/roms";
import RawMetadataPanel from "./RawMetadataPanel.vue";
defineProps<{ rom: SimpleRom }>();
const emit = defineEmits<{
"update:rom": [rom: UpdateRom];
}>();
const handleRomUpdate = (updatedRom: UpdateRom) => {
emit("update:rom", updatedRom);
};
const metadataConfigs: {
idField: keyof SimpleRom;
metadataField: keyof SimpleRom;
iconSrc: string;
label: string;
}[] = [
{
idField: "igdb_id",
metadataField: "igdb_metadata",
iconSrc: "/assets/scrappers/igdb.png",
label: "IGDB",
},
{
idField: "moby_id",
metadataField: "moby_metadata",
iconSrc: "/assets/scrappers/moby.png",
label: "MobyGames",
},
{
idField: "ss_id",
metadataField: "ss_metadata",
iconSrc: "/assets/scrappers/ss.png",
label: "ScreenScraper",
},
{
idField: "launchbox_id",
metadataField: "launchbox_metadata",
iconSrc: "/assets/scrappers/launchbox.png",
label: "LaunchBox",
},
{
idField: "hasheous_id",
metadataField: "hasheous_metadata",
iconSrc: "/assets/scrappers/hasheous.png",
label: "Hasheous",
},
{
idField: "flashpoint_id",
metadataField: "flashpoint_metadata",
iconSrc: "/assets/scrappers/flashpoint.png",
label: "Flashpoint",
},
{
idField: "hltb_id",
metadataField: "hltb_metadata",
iconSrc: "/assets/scrappers/hltb.png",
label: "HLTB",
},
];
</script>
<template>
<template v-for="config in metadataConfigs" :key="config.idField">
<RawMetadataPanel
v-if="rom[config.idField]"
:rom="rom"
:metadata-field="config.metadataField"
:icon-src="config.iconSrc"
:label="config.label"
@update:rom="handleRomUpdate"
/>
</template>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import { ref, computed, onMounted } from "vue";
import { inject } from "vue";
import type { UpdateRom } from "@/services/api/rom";
import type { Events } from "@/types/emitter";
interface Props {
rom: UpdateRom;
metadataField: keyof UpdateRom;
iconSrc: string;
label: string;
}
const props = defineProps<Props>();
const emitter = inject<Emitter<Events>>("emitter");
const emit = defineEmits<{
"update:rom": [rom: UpdateRom];
}>();
const isEditing = ref(false);
const metadataJson = ref("");
const initializeMetadata = () => {
const metadata = props.rom[props.metadataField];
metadataJson.value = metadata ? JSON.stringify(metadata, null, 2) : "";
isEditing.value = false;
};
onMounted(() => initializeMetadata());
const validateJson = (value: string): boolean | string => {
if (!value || value.trim() === "") return true;
try {
JSON.parse(value);
return true;
} catch (error) {
return "Invalid JSON format";
}
};
const startEdit = () => {
isEditing.value = true;
};
const cancelEdit = () => {
isEditing.value = false;
initializeMetadata();
};
const saveMetadata = () => {
if (!metadataJson.value || metadataJson.value.trim() === "") {
emit("update:rom", {
...props.rom,
raw_metadata: { ...props.rom.raw_metadata, [props.metadataField]: "{}" },
});
isEditing.value = false;
return;
}
try {
JSON.parse(metadataJson.value);
// Update the ROM with raw metadata
const updatedRom = {
...props.rom,
raw_metadata: {
...props.rom.raw_metadata,
[props.metadataField]: metadataJson.value,
},
};
emit("update:rom", updatedRom);
isEditing.value = false;
} catch (error) {
emitter?.emit("snackbarShow", {
msg: "Invalid JSON format",
icon: "mdi-close-circle",
color: "red",
timeout: 3000,
});
}
};
</script>
<template>
<v-expansion-panel v-if="rom[metadataField]">
<v-expansion-panel-title class="bg-toplayer">
<v-avatar size="26" rounded class="mr-2">
<v-img :src="iconSrc" />
</v-avatar>
{{ label }} {{ $t("rom.metadata") }}
</v-expansion-panel-title>
<v-expansion-panel-text class="mt-4 px-2">
<v-textarea
v-model="metadataJson"
:label="`${label} ${$t('rom.metadata')} JSON`"
variant="outlined"
rows="8"
hide-details
:readonly="!isEditing"
:rules="[validateJson]"
/>
<v-btn-group
divided
density="compact"
rounded="0"
class="my-2 d-flex justify-center"
>
<v-btn
v-if="!isEditing"
variant="flat"
class="text-primary bg-toplayer"
@click="startEdit"
>
{{ $t("common.edit") }}
</v-btn>
<template v-else>
<v-btn
variant="flat"
@click="cancelEdit"
class="text-romm-red bg-toplayer"
>
{{ $t("common.cancel") }}
</v-btn>
<v-btn
variant="flat"
@click="saveMetadata"
class="text-romm-green bg-toplayer"
>
{{ $t("common.save") }}
</v-btn>
</template>
</v-btn-group>
</v-expansion-panel-text>
</v-expansion-panel>
</template>

View File

@@ -25,7 +25,6 @@ type MatchedSource = {
| "Screenscraper"
| "Flashpoint"
| "Launchbox"
| "HowLongToBeat"
| "SteamGridDB";
logo_path: string;
};
@@ -56,7 +55,6 @@ const isMobyFiltered = ref(true);
const isSSFiltered = ref(true);
const isFlashpointFiltered = ref(true);
const isLaunchboxFiltered = ref(true);
const isHLTBFiltered = ref(true);
const computedAspectRatio = computed(() => {
const ratio =
platfotmsStore.getAspectRatio(rom.value?.platform_id ?? -1) ||
@@ -101,20 +99,15 @@ function toggleSourceFilter(source: MatchedSource["name"]) {
heartbeat.value.METADATA_SOURCES.LAUNCHBOX_API_ENABLED
) {
isLaunchboxFiltered.value = !isLaunchboxFiltered.value;
} else if (
source == "HowLongToBeat" &&
heartbeat.value.METADATA_SOURCES.HLTB_API_ENABLED
) {
isHLTBFiltered.value = !isHLTBFiltered.value;
}
filteredMatchedRoms.value = matchedRoms.value.filter((rom) => {
if (
(rom.igdb_id && isIGDBFiltered.value) ||
(rom.moby_id && isMobyFiltered.value) ||
(rom.ss_id && isSSFiltered.value) ||
(rom.flashpoint_id && isFlashpointFiltered.value) ||
(rom.launchbox_id && isLaunchboxFiltered.value) ||
(rom.hltb_id && isHLTBFiltered.value)
(rom.launchbox_id && isLaunchboxFiltered.value)
) {
return true;
}
@@ -146,8 +139,7 @@ async function searchRom() {
(rom.moby_id && isMobyFiltered.value) ||
(rom.ss_id && isSSFiltered.value) ||
(rom.flashpoint_id && isFlashpointFiltered.value) ||
(rom.launchbox_id && isLaunchboxFiltered.value) ||
(rom.hltb_id && isHLTBFiltered.value)
(rom.launchbox_id && isLaunchboxFiltered.value)
) {
return true;
}
@@ -219,13 +211,6 @@ function showSources(matchedRom: SearchRomSchema) {
logo_path: "/assets/scrappers/launchbox.png",
});
}
if (matchedRom.hltb_url_cover) {
sources.value.push({
url_cover: matchedRom.hltb_url_cover,
name: "HowLongToBeat",
logo_path: "/assets/scrappers/hltb.png",
});
}
if (sources.value.length == 1) {
selectedCover.value = sources.value[0];
}
@@ -277,7 +262,6 @@ async function updateRom(
moby_id: selectedRom.moby_id || null,
flashpoint_id: selectedRom.flashpoint_id || null,
launchbox_id: selectedRom.launchbox_id || null,
hltb_id: selectedRom.hltb_id || null,
name: selectedRom.name || null,
slug: selectedRom.slug || null,
summary: selectedRom.summary || null,
@@ -288,7 +272,6 @@ async function updateRom(
selectedRom.moby_url_cover ||
selectedRom.flashpoint_url_cover ||
selectedRom.launchbox_url_cover ||
selectedRom.hltb_url_cover ||
null,
};
@@ -500,35 +483,6 @@ onBeforeUnmount(() => {
</v-avatar>
</template>
</v-tooltip>
<v-tooltip
location="top"
class="tooltip"
transition="fade-transition"
:text="
heartbeat.value.METADATA_SOURCES.HLTB_API_ENABLED
? 'Filter HowLongToBeat matches'
: 'HowLongToBeat source is not enabled'
"
open-delay="500"
><template #activator="{ props }">
<v-avatar
@click="toggleSourceFilter('HowLongToBeat')"
v-bind="props"
class="ml-3 cursor-pointer opacity-40"
:class="{
'opacity-100':
isHLTBFiltered &&
heartbeat.value.METADATA_SOURCES.HLTB_API_ENABLED,
'cursor-not-allowed':
!heartbeat.value.METADATA_SOURCES.HLTB_API_ENABLED,
}"
size="30"
rounded="1"
>
<v-img src="/assets/scrappers/hltb.png" />
</v-avatar>
</template>
</v-tooltip>
</template>
<template #toolbar>
<v-row class="align-center" no-gutters>

View File

@@ -123,7 +123,7 @@ async function uploadRoms() {
timeout: 3000,
});
scanningStore.set(true);
scanningStore.setScanning(true);
if (!socket.connected) socket.connect();
setTimeout(() => {

View File

@@ -13,7 +13,7 @@ const auth = storeAuth();
const emitter = inject<Emitter<Events>>("emitter");
const { toggleFavorite } = useFavoriteToggle(emitter);
async function switchFromFavourites() {
async function switchFromFavorites() {
await toggleFavorite(props.rom);
}
</script>
@@ -25,7 +25,7 @@ async function switchFromFavourites() {
rouded="0"
size="small"
variant="text"
@click.stop="switchFromFavourites"
@click.stop="switchFromFavorites"
>
<v-icon color="primary">
{{ collectionsStore.isFavorite(rom) ? "mdi-star" : "mdi-star-outline" }}

View File

@@ -148,273 +148,293 @@ function updateOptions({ sortBy }: { sortBy: SortBy }) {
selectedRomIDs.length < filteredRoms.length
"
:model-value="selectedRomIDs.length === filteredRoms.length"
@click.stop
@click="updateSelectAll"
@click.stop="updateSelectAll"
/>
</template>
<template #item.data-table-select="{ item }">
<v-checkbox-btn
:model-value="selectedRomIDs.includes(item.id)"
@click.stop
@click="updateSelectedRom(item)"
/>
</template>
<template #item.name="{ item }">
<v-list-item :min-width="400" class="px-0 py-2 d-flex game-list-item">
<template #prepend>
<PlatformIcon
v-if="showPlatformIcon"
class="mr-4"
:size="30"
:slug="item.platform_slug"
:fs-slug="item.platform_fs_slug"
<template #item="{ item }">
<router-link
:to="{ name: ROUTES.ROM, params: { rom: item.id } }"
class="game-list-table-row d-table-row"
>
<div class="game-list-table-cell d-table-cell px-4">
<v-checkbox-btn
:model-value="selectedRomIDs.includes(item.id)"
@click.stop="updateSelectedRom(item)"
/>
<RAvatarRom :rom="item" />
</template>
<v-row no-gutters>
<v-col>
{{ item.name }}
<v-icon
v-if="collectionsStore.isFavorite(item)"
size="small"
color="primary"
class="ml-1"
</div>
<div class="game-list-table-cell d-table-cell px-4">
<v-list-item :min-width="400" class="px-0 py-2 d-flex game-list-item">
<template #prepend>
<PlatformIcon
v-if="showPlatformIcon"
class="mr-4"
:size="30"
:slug="item.platform_slug"
:fs-slug="item.platform_fs_slug"
/>
<RAvatarRom :rom="item" />
</template>
<v-row no-gutters>
<v-col>
{{ item.name }}
<v-icon
v-if="collectionsStore.isFavorite(item)"
size="small"
color="primary"
class="ml-1"
>
mdi-star
</v-icon>
</v-col>
</v-row>
<v-row no-gutters>
<v-col class="text-primary">
{{ item.fs_name }}
</v-col>
</v-row>
<template #append>
<v-chip
v-if="item.hasheous_id"
class="bg-romm-green text-white mr-1 px-1 item-chip"
size="x-small"
title="Verified with Hasheous"
>
<v-icon>mdi-check-decagram-outline</v-icon>
</v-chip>
<v-chip
v-if="item.igdb_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="IGDB match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/igdb.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.ss_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="ScreenScraper match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/ss.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.moby_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="MobyGames match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/moby.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.launchbox_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="LaunchBox match"
>
<v-avatar size="20" style="background: #185a7c">
<v-img src="/assets/scrappers/launchbox.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.ra_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="RetroAchievements match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/ra.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.flashpoint_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="Flashpoint match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/flashpoint.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.hltb_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="HowLongToBeat match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/hltb.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.gamelist_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="ES-DE match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/esde.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.siblings.length > 0 && showSiblings"
class="translucent text-white mr-1 px-1 item-chip"
size="x-small"
:title="`${item.siblings.length} sibling(s)`"
>
<v-icon>mdi-card-multiple-outline</v-icon>
</v-chip>
<MissingFromFSIcon
v-if="item.missing_from_fs"
:text="`Missing from filesystem: ${item.fs_path}/${item.fs_name}`"
class="mr-1 px-1 item-chip"
chip
chip-size="x-small"
/>
</template>
</v-list-item>
</div>
<div class="game-list-table-cell d-table-cell px-4">
<span class="text-no-wrap">{{
formatBytes(item.fs_size_bytes)
}}</span>
</div>
<div class="game-list-table-cell d-table-cell px-4">
<span v-if="item.created_at" class="text-no-wrap">{{
new Date(item.created_at).toLocaleDateString("en-US", {
day: "2-digit",
month: "short",
year: "numeric",
})
}}</span>
<span v-else>-</span>
</div>
<div class="game-list-table-cell d-table-cell px-4">
<span v-if="item.metadatum.first_release_date" class="text-no-wrap">{{
new Date(item.metadatum.first_release_date).toLocaleDateString(
"en-US",
{
day: "2-digit",
month: "short",
year: "numeric",
},
)
}}</span>
<span v-else>-</span>
</div>
<div class="game-list-table-cell d-table-cell px-4">
<span v-if="item.metadatum.average_rating" class="text-no-wrap">{{
Intl.NumberFormat("en-US", {
maximumSignificantDigits: 3,
}).format(item.metadatum.average_rating)
}}</span>
<span v-else>-</span>
</div>
<div class="game-list-table-cell d-table-cell px-4">
<div v-if="item.languages.length > 0" class="text-no-wrap">
<span
v-for="language in item.languages.slice(0, 3)"
:key="language"
class="emoji"
:title="`Languages: ${item.languages.join(', ')}`"
:class="{ 'emoji-collection': item.regions.length > 3 }"
>
mdi-star
</v-icon>
</v-col>
</v-row>
<v-row no-gutters>
<v-col class="text-primary">
{{ item.fs_name }}
</v-col>
</v-row>
<template #append>
<v-chip
v-if="item.hasheous_id"
class="bg-romm-green text-white mr-1 px-1 item-chip"
size="x-small"
title="Verified with Hasheous"
>
<v-icon>mdi-check-decagram-outline</v-icon>
</v-chip>
<v-chip
v-if="item.igdb_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="IGDB match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/igdb.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.ss_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="ScreenScraper match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/ss.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.moby_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="MobyGames match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/moby.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.launchbox_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="LaunchBox match"
>
<v-avatar size="20" style="background: #185a7c">
<v-img src="/assets/scrappers/launchbox.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.ra_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="RetroAchievements match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/ra.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.flashpoint_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="Flashpoint match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/flashpoint.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.hltb_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="HowLongToBeat match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/hltb.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.gamelist_id"
class="mr-1 pa-0 item-chip"
size="x-small"
title="ES-DE match"
>
<v-avatar size="20" rounded>
<v-img src="/assets/scrappers/esde.png" />
</v-avatar>
</v-chip>
<v-chip
v-if="item.siblings.length > 0 && showSiblings"
class="translucent text-white mr-1 px-1 item-chip"
size="x-small"
:title="`${item.siblings.length} sibling(s)`"
>
<v-icon>mdi-card-multiple-outline</v-icon>
</v-chip>
<MissingFromFSIcon
v-if="item.missing_from_fs"
:text="`Missing from filesystem: ${item.fs_path}/${item.fs_name}`"
class="mr-1 px-1 item-chip"
chip
chip-size="x-small"
/>
</template>
</v-list-item>
</template>
<template #item.fs_size_bytes="{ item }">
<span class="text-no-wrap">{{ formatBytes(item.fs_size_bytes) }}</span>
</template>
<template #item.created_at="{ item }">
<span v-if="item.created_at" class="text-no-wrap">{{
new Date(item.created_at).toLocaleDateString("en-US", {
day: "2-digit",
month: "short",
year: "numeric",
})
}}</span>
<span v-else>-</span>
</template>
<template #item.first_release_date="{ item }">
<span v-if="item.metadatum.first_release_date" class="text-no-wrap">{{
new Date(item.metadatum.first_release_date).toLocaleDateString(
"en-US",
{
day: "2-digit",
month: "short",
year: "numeric",
},
)
}}</span>
<span v-else>-</span>
</template>
<template #item.average_rating="{ item }">
<span v-if="item.metadatum.average_rating" class="text-no-wrap">{{
Intl.NumberFormat("en-US", {
maximumSignificantDigits: 3,
}).format(item.metadatum.average_rating)
}}</span>
<span v-else>-</span>
</template>
<template #item.languages="{ item }">
<div v-if="item.languages.length > 0" class="text-no-wrap">
<span
v-for="language in item.languages.slice(0, 3)"
:key="language"
class="emoji"
:title="`Languages: ${item.languages.join(', ')}`"
:class="{ 'emoji-collection': item.regions.length > 3 }"
>
{{ languageToEmoji(language) }}
</span>
<span class="reglang-super">
{{
item.languages.length > 3
? `&nbsp;+${item.languages.length - 3}`
: ""
}}
</span>
</div>
<span v-else>-</span>
</template>
<template #item.regions="{ item }">
<div v-if="item.regions.length > 0" class="text-no-wrap">
<span
v-for="region in item.regions.slice(0, 3)"
:key="region"
class="emoji"
:title="`Regions: ${item.regions.join(', ')}`"
:class="{ 'emoji-collection': item.regions.length > 3 }"
>
{{ regionToEmoji(region) }}
</span>
<span class="reglang-super">
{{
item.regions.length > 3 ? `&nbsp;+${item.regions.length - 3}` : ""
}}
</span>
</div>
<span v-else>-</span>
</template>
<template #item.actions="{ item }">
<v-btn-group density="compact">
<v-btn
:disabled="
downloadStore.value.includes(item.id) || item.missing_from_fs
"
download
variant="text"
size="small"
@click.stop="romApi.downloadRom({ rom: item })"
>
<v-icon>mdi-download</v-icon>
</v-btn>
<PlayBtn :rom="item" variant="text" size="small" @click.stop />
<v-menu
v-if="
auth.scopes.includes('roms.write') ||
auth.scopes.includes('roms.user.write') ||
auth.scopes.includes('collections.write')
"
location="bottom"
>
<template #activator="{ props }">
<v-btn v-bind="props" variant="text" size="small">
<v-icon>mdi-dots-vertical</v-icon>
{{ languageToEmoji(language) }}
</span>
<span class="reglang-super">
{{
item.languages.length > 3
? `&nbsp;+${item.languages.length - 3}`
: ""
}}
</span>
</div>
<span v-else>-</span>
</div>
<div class="game-list-table-cell d-table-cell px-4">
<div v-if="item.regions.length > 0" class="text-no-wrap">
<span
v-for="region in item.regions.slice(0, 3)"
:key="region"
class="emoji"
:title="`Regions: ${item.regions.join(', ')}`"
:class="{ 'emoji-collection': item.regions.length > 3 }"
>
{{ regionToEmoji(region) }}
</span>
<span class="reglang-super">
{{
item.regions.length > 3
? `&nbsp;+${item.regions.length - 3}`
: ""
}}
</span>
</div>
<span v-else>-</span>
</div>
<div class="game-list-table-cell d-table-cell px-4">
<v-btn-group density="compact">
<v-btn
:disabled="
downloadStore.value.includes(item.id) || item.missing_from_fs
"
download
variant="text"
size="small"
@click.prevent="romApi.downloadRom({ rom: item })"
>
<v-icon>mdi-download</v-icon>
</v-btn>
</template>
<AdminMenu :rom="item" />
</v-menu>
</v-btn-group>
<PlayBtn :rom="item" variant="text" size="small" @click.prevent />
<v-menu
v-if="
auth.scopes.includes('roms.write') ||
auth.scopes.includes('roms.user.write') ||
auth.scopes.includes('collections.write')
"
location="bottom"
@click.prevent
>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
size="small"
@click.prevent
>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<AdminMenu :rom="item" />
</v-menu>
</v-btn-group>
</div>
</router-link>
</template>
</v-data-table-virtual>
</template>
<style scoped>
.game-list-table-row:hover {
background-color: rgba(var(--v-theme-surface-variant), 0.08);
}
.game-list-table-cell {
vertical-align: middle;
border-bottom: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.reglang-super {
vertical-align: super;
font-size: 75%;
opacity: 75%;
}
.v-data-table {
width: calc(100% - 16px) !important;
}
@media (max-width: 2160px) {
.item-chip {
transform: scale(-1, 1);

View File

@@ -2,6 +2,7 @@
import { useLocalStorage } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { useDisplay } from "vuetify";
import RandomBtn from "@/components/Gallery/AppBar/common/RandomBtn.vue";
import UploadRomDialog from "@/components/common/Game/Dialog/UploadRom.vue";
import CollectionsBtn from "@/components/common/Navigation/CollectionsBtn.vue";
import CollectionsDrawer from "@/components/common/Navigation/CollectionsDrawer.vue";
@@ -107,6 +108,7 @@ function collapse() {
<ConsoleModeBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
<template #append>
<RandomBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
<UploadBtn
:with-tag="!mainBarCollapsed"
rounded

View File

@@ -19,7 +19,7 @@ const platformsStore = storePlatforms();
const { filteredPlatforms, filterText } = storeToRefs(platformsStore);
const { activePlatformsDrawer } = storeToRefs(navigationStore);
const openPanels = ref<number[]>([]);
const groupBy = useLocalStorage<GroupByType | null>(
const groupByRef = useLocalStorage<GroupByType | null>(
"settings.platformsGroupBy",
null,
);
@@ -27,14 +27,14 @@ const groupBy = useLocalStorage<GroupByType | null>(
const tabIndex = computed(() => (activePlatformsDrawer.value ? 0 : -1));
const sortedGroupedPlatforms = computed(() => {
if (!groupBy.value) return null;
if (!groupByRef.value) return null;
const groups: Record<string, Platform[]> = {};
// Group platforms
filteredPlatforms.value.forEach((platform) => {
let key = platform[groupBy.value!] || "Other";
if (groupBy.value === "generation" && key === -1) key = "Other";
let key = platform[groupByRef.value!] || "Other";
if (groupByRef.value === "generation" && key === -1) key = "Other";
if (!groups[key]) groups[key] = [];
groups[key].push(platform);
@@ -46,9 +46,16 @@ const sortedGroupedPlatforms = computed(() => {
([groupName, platforms]) =>
[
groupName,
platforms.sort((a, b) =>
a.display_name.localeCompare(b.display_name),
),
platforms.sort((a, b) => {
// Sort platforms by generation within the same family
if (groupByRef.value === "family_name") {
const aGen = a.generation ?? -1;
const bGen = b.generation ?? -1;
if (aGen > bGen) return 1;
if (aGen < bGen) return -1;
}
return a.display_name.localeCompare(b.display_name);
}),
] as [string, Platform[]],
)
.sort(([a], [b]) => {
@@ -59,10 +66,10 @@ const sortedGroupedPlatforms = computed(() => {
});
const getGroupTitle = (group: string): string => {
if (groupBy.value === "generation" && group !== "Other") {
if (groupByRef.value === "generation" && group !== "Other") {
return `Gen ${group}`;
}
if (groupBy.value === "category" && group === "Portable Console") {
if (groupByRef.value === "category" && group === "Portable Console") {
return "Handheld Console";
}
return group;

View File

@@ -3,6 +3,7 @@ import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject, onBeforeUnmount } from "vue";
import { useI18n } from "vue-i18n";
import type { ScanStats } from "@/__generated__";
import socket from "@/services/socket";
import storeAuth from "@/stores/auth";
import storeNavigation from "@/stores/navigation";
@@ -37,24 +38,24 @@ if (!socket.connected) socket.connect();
socket.on(
"scan:scanning_platform",
({
name,
display_name,
slug,
id,
fs_slug,
is_identified,
}: {
name: string;
display_name: string;
slug: string;
id: number;
fs_slug: string;
is_identified: boolean;
}) => {
scanningStore.set(true);
scanningStore.setScanning(true);
scanningPlatforms.value = scanningPlatforms.value.filter(
(platform) => platform.name !== name,
(platform) => platform.display_name !== display_name,
);
scanningPlatforms.value.push({
name,
display_name,
slug,
id,
fs_slug,
@@ -65,7 +66,7 @@ socket.on(
);
socket.on("scan:scanning_rom", (rom: SimpleRom) => {
scanningStore.set(true);
scanningStore.setScanning(true);
// Remove the ROM from the recent list and add it back to the top
romsStore.removeFromRecent(rom);
@@ -87,7 +88,7 @@ socket.on("scan:scanning_rom", (rom: SimpleRom) => {
// Add the platform if the socket dropped and it's missing
if (!scannedPlatform) {
scanningPlatforms.value.push({
name: rom.platform_name,
display_name: rom.platform_display_name,
slug: rom.platform_slug,
id: rom.platform_id,
fs_slug: rom.platform_fs_slug,
@@ -109,7 +110,7 @@ socket.on("scan:scanning_rom", (rom: SimpleRom) => {
});
socket.on("scan:done", () => {
scanningStore.set(false);
scanningStore.setScanning(false);
socket.disconnect();
emitter?.emit("refreshDrawer", null);
@@ -122,7 +123,7 @@ socket.on("scan:done", () => {
});
socket.on("scan:done_ko", (msg) => {
scanningStore.set(false);
scanningStore.setScanning(false);
emitter?.emit("snackbarShow", {
msg: `Scan failed: ${msg}`,
@@ -132,6 +133,10 @@ socket.on("scan:done_ko", (msg) => {
socket.disconnect();
});
socket.on("scan:update_stats", (stats: ScanStats) => {
scanningStore.setScanStats(stats);
});
onBeforeUnmount(() => {
socket.off("scan:scanning_platform");
socket.off("scan:scanning_rom");

View File

@@ -63,8 +63,8 @@ onMounted(async () => {
max-width="fit-content"
>
<v-card-text class="text-center py-2 px-4">
<span class="text-white text-body-1">New version available:</span>
<span class="text-primary ml-1 text-body-1">{{
<span class="text-body-1">New version available:</span>
<span class="text-primary ml-1 text-body-1 font-weight-medium">{{
GITHUB_VERSION
}}</span>
<v-row class="mt-2 flex justify-center" no-gutters>
@@ -79,8 +79,9 @@ onMounted(async () => {
Dismiss
</v-btn>
<v-btn
variant="tonal"
density="compact"
variant="tonal"
color="primary"
size="small"
@click="openNewVersion"
>

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed } from "vue";
import MissingFromFSIcon from "@/components/common/MissingFromFSIcon.vue";
import PlatformIcon from "@/components/common/Platform/PlatformIcon.vue";
import { ROUTES } from "@/plugins/router";
import type { Platform } from "@/stores/platforms";
import { platformCategoryToIcon } from "@/utils";
withDefaults(
const props = withDefaults(
defineProps<{
platform: Platform;
withLink?: boolean;
@@ -15,6 +17,9 @@ withDefaults(
showRomCount: true,
},
);
const categoryIcon = computed(() =>
platformCategoryToIcon(props.platform.category || ""),
);
</script>
<template>
@@ -40,13 +45,23 @@ withDefaults(
/>
</template>
<v-row no-gutters>
<v-col>
<v-col class="d-flex align-center">
<span class="text-body-1">{{ platform.display_name }}</span>
</v-col>
</v-row>
<v-row no-gutters>
<v-col>
<span class="text-caption text-grey">{{ platform.fs_slug }}</span>
<v-chip size="x-small" label class="text-grey">{{
platform.fs_slug
}}</v-chip>
<v-icon
:icon="categoryIcon"
class="ml-2 text-caption text-grey"
:title="platform.category"
/>
<span v-if="platform.family_name" class="ml-1 text-caption text-grey">{{
platform.family_name
}}</span>
</v-col>
</v-row>
<template v-if="showRomCount" #append>

View File

@@ -19,7 +19,12 @@ export function useFavoriteToggle(emitter?: Emitter<Events>) {
if (favoriteCollection.value) return favoriteCollection.value;
// Create if still missing
const { data } = await collectionApi.createCollection({
collection: { name: "Favourites", rom_ids: [] },
collection: {
name: "Favorites",
rom_ids: [],
is_favorite: true,
is_public: false,
},
});
collectionsStore.addCollection(data);
collectionsStore.setFavoriteCollection(data);
@@ -72,7 +77,7 @@ export function useFavoriteToggle(emitter?: Emitter<Events>) {
const detail = (error as { response?: { data?: { detail?: string } } })
?.response?.data?.detail;
emitter?.emit("snackbarShow", {
msg: detail || "Failed to update favourites",
msg: detail || "Failed to update favorites",
icon: "mdi-close-circle",
color: "red",
});

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useIdle } from "@vueuse/core";
import { onMounted, onUnmounted, provide } from "vue";
import { useIdle, useLocalStorage } from "@vueuse/core";
import { onBeforeMount, onMounted, onUnmounted, provide } from "vue";
import { type RouteLocationNormalized } from "vue-router";
import { useRouter } from "vue-router";
import { useConsoleTheme } from "@/console/composables/useConsoleTheme";
@@ -8,12 +8,28 @@ import { InputBus, InputBusSymbol } from "@/console/input/bus";
import { attachGamepad } from "@/console/input/gamepad";
import { attachKeyboard } from "@/console/input/keyboard";
import { ROUTES } from "@/plugins/router";
import storeCollections from "@/stores/collections";
import storeNavigation from "@/stores/navigation";
import storePlatforms from "@/stores/platforms";
const router = useRouter();
const bus = new InputBus();
const themeStore = useConsoleTheme();
provide(InputBusSymbol, bus);
const navigationStore = storeNavigation();
const platformsStore = storePlatforms();
const collectionsStore = storeCollections();
const showVirtualCollections = useLocalStorage(
"settings.showVirtualCollections",
true,
);
const virtualCollectionTypeRef = useLocalStorage(
"settings.virtualCollectionType",
"collection",
);
// Define route hierarchy for transition direction logic
const routeHierarchy = {
[ROUTES.CONSOLE_HOME]: 0,
@@ -53,6 +69,17 @@ const { idle: mouseIdle } = useIdle(100, {
let detachKeyboard: (() => void) | null = null;
let detachGamepad: (() => void) | null = null;
onBeforeMount(() => {
platformsStore.fetchPlatforms();
collectionsStore.fetchCollections();
collectionsStore.fetchSmartCollections();
if (showVirtualCollections) {
collectionsStore.fetchVirtualCollections(virtualCollectionTypeRef.value);
}
navigationStore.reset();
});
onMounted(() => {
themeStore.initializeTheme();

View File

@@ -46,7 +46,7 @@ const memoizedCovers = ref({
});
const fallbackCollectionCover = computed(() =>
props.collection.name?.toLowerCase() == "favourites"
props.collection.is_favorite
? getFavoriteCoverImage(props.collection.name)
: getCollectionCoverImage(props.collection.name),
);

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, useTemplateRef, watch } from "vue";
import Skeleton from "@/components/common/Game/Card/Skeleton.vue";
import {
recentElementRegistry,
continuePlayingElementRegistry,
gamesListElementRegistry,
} from "@/console/composables/useElementRegistry";
import storeCollections from "@/stores/collections";
@@ -19,8 +19,8 @@ const props = defineProps<{
index: number;
selected?: boolean;
loaded?: boolean;
isRecent?: boolean;
registry?: "recent" | "gamesList";
continuePlaying?: boolean;
registry?: "continuePlaying" | "gamesList";
}>();
const heartbeatStore = storeHeartbeat();
@@ -84,7 +84,10 @@ onMounted(() => {
if (props.registry === "gamesList") {
gamesListElementRegistry.registerElement(props.index, gameCardRef.value);
} else {
recentElementRegistry.registerElement(props.index, gameCardRef.value);
continuePlayingElementRegistry.registerElement(
props.index,
gameCardRef.value,
);
}
});
</script>
@@ -96,7 +99,7 @@ onMounted(() => {
:class="{
'-translate-y-[2px] scale-[1.03] shadow-[0_8px_28px_rgba(0,0,0,0.35),_0_0_0_2px_var(--console-game-card-focus-border),_0_0_16px_var(--console-game-card-focus-border)]':
selected,
'w-[250px] shrink-0': isRecent,
'w-[250px] shrink-0': continuePlaying,
}"
@click="emit('click')"
@focus="emit('focus')"

View File

@@ -25,7 +25,7 @@ export function useElementRegistry() {
// Create a shared registry instance for each section
export const systemElementRegistry = useElementRegistry();
export const recentElementRegistry = useElementRegistry();
export const continuePlayingElementRegistry = useElementRegistry();
export const collectionElementRegistry = useElementRegistry();
export const smartCollectionElementRegistry = useElementRegistry();
export const virtualCollectionElementRegistry = useElementRegistry();

View File

@@ -574,7 +574,7 @@ onUnmounted(() => {
}"
>
{{
rom.platform_name ||
rom.platform_display_name ||
(rom.platform_slug || "RETRO")?.toString().toUpperCase()
}}
</span>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import {
computed,
onMounted,
@@ -6,11 +7,9 @@ import {
ref,
nextTick,
useTemplateRef,
watch,
} from "vue";
import { useRoute, useRouter } from "vue-router";
import type { CollectionSchema } from "@/__generated__/models/CollectionSchema";
import type { SmartCollectionSchema } from "@/__generated__/models/SmartCollectionSchema";
import type { VirtualCollectionSchema } from "@/__generated__/models/VirtualCollectionSchema";
import useFavoriteToggle from "@/composables/useFavoriteToggle";
import BackButton from "@/console/components/BackButton.vue";
import GameCard from "@/console/components/GameCard.vue";
@@ -22,8 +21,8 @@ import { useRovingDom } from "@/console/composables/useRovingDom";
import { useSpatialNav } from "@/console/composables/useSpatialNav";
import type { InputAction } from "@/console/input/actions";
import { ROUTES } from "@/plugins/router";
import collectionApi from "@/services/api/collection";
import storeCollections from "@/stores/collections";
import storeConfig from "@/stores/config";
import storeConsole from "@/stores/console";
import storeGalleryFilter from "@/stores/galleryFilter";
import storePlatforms from "@/stores/platforms";
@@ -32,61 +31,45 @@ import storeRoms, { type SimpleRom } from "@/stores/roms";
const route = useRoute();
const router = useRouter();
const consoleStore = storeConsole();
const platformsStore = storePlatforms();
const collectionsStore = storeCollections();
const galleryFilterStore = storeGalleryFilter();
const platformsStore = storePlatforms();
const { allPlatforms } = storeToRefs(platformsStore);
const collectionsStore = storeCollections();
const { allCollections, smartCollections, virtualCollections } =
storeToRefs(collectionsStore);
const romsStore = storeRoms();
const {
filteredRoms,
allRoms,
fetchingRoms,
currentPlatform,
currentCollection,
currentSmartCollection,
currentVirtualCollection,
} = storeToRefs(romsStore);
const configStore = storeConfig();
const { config } = storeToRefs(configStore);
const { toggleFavorite: toggleFavoriteComposable } = useFavoriteToggle();
const { setSelectedBackgroundArt, clearSelectedBackgroundArt } =
useBackgroundArt();
const isPlatformRoute = route.name === ROUTES.CONSOLE_PLATFORM;
const isCollectionRoute = route.name === ROUTES.CONSOLE_COLLECTION;
const isSmartCollectionRoute = route.name === ROUTES.CONSOLE_SMART_COLLECTION;
const isVirtualCollectionRoute =
route.name === ROUTES.CONSOLE_VIRTUAL_COLLECTION;
const platformId =
isCollectionRoute || isSmartCollectionRoute || isVirtualCollectionRoute
? null
: Number(route.params.id);
const collectionId = isCollectionRoute ? Number(route.params.id) : null;
const smartCollectionId = isSmartCollectionRoute
? Number(route.params.id)
: null;
const virtualCollectionId = isVirtualCollectionRoute
? String(route.params.id)
: null;
const roms = ref<SimpleRom[]>([]);
const collection = ref<CollectionSchema | null>(null);
const smartCollection = ref<SmartCollectionSchema | null>(null);
const virtualCollection = ref<VirtualCollectionSchema | null>(null);
const loading = ref(true);
const error = ref("");
const selectedIndex = ref(0);
const loadedMap = ref<Record<number, boolean>>({});
const inAlphabet = ref(false);
const alphaIndex = ref(0);
const gridRef = useTemplateRef<HTMLDivElement>("game-grid-ref");
// Initialize selection from store
if (platformId != null) {
selectedIndex.value = consoleStore.getPlatformGameIndex(platformId);
} else if (collectionId != null) {
selectedIndex.value = consoleStore.getCollectionGameIndex(collectionId);
} else if (smartCollectionId != null) {
selectedIndex.value = consoleStore.getCollectionGameIndex(smartCollectionId);
} else if (virtualCollectionId != null) {
selectedIndex.value = consoleStore.getCollectionGameIndex(
Number(virtualCollectionId),
);
}
// Generate alphabet letters dynamically based on available games
const letters = computed(() => {
const letterSet = new Set<string>();
roms.value.forEach(({ name }) => {
filteredRoms.value.forEach(({ name }) => {
if (!name) return;
const normalized = normalizeTitle(name);
@@ -111,15 +94,24 @@ const letters = computed(() => {
});
function persistIndex() {
if (platformId != null) {
consoleStore.setPlatformGameIndex(platformId, selectedIndex.value);
} else if (collectionId != null) {
consoleStore.setCollectionGameIndex(collectionId, selectedIndex.value);
} else if (smartCollectionId != null) {
consoleStore.setCollectionGameIndex(smartCollectionId, selectedIndex.value);
} else if (virtualCollectionId != null) {
if (currentPlatform.value != null) {
consoleStore.setPlatformGameIndex(
currentPlatform.value.id,
selectedIndex.value,
);
} else if (currentCollection.value != null) {
consoleStore.setCollectionGameIndex(
Number(virtualCollectionId),
currentCollection.value.id,
selectedIndex.value,
);
} else if (currentSmartCollection.value != null) {
consoleStore.setSmartCollectionGameIndex(
currentSmartCollection.value.id,
selectedIndex.value,
);
} else if (currentVirtualCollection.value != null) {
consoleStore.setVirtualCollectionGameIndex(
currentVirtualCollection.value.id,
selectedIndex.value,
);
}
@@ -132,26 +124,21 @@ function navigateBack() {
const headerTitle = computed(() => {
if (isCollectionRoute) {
return collection.value?.name || "Collection";
return currentCollection.value?.name || "Collection";
}
if (isSmartCollectionRoute) {
return smartCollection.value?.name || "Smart Collection";
return currentSmartCollection.value?.name || "Smart Collection";
}
if (isVirtualCollectionRoute) {
return virtualCollection.value?.name || "Virtual Collection";
return currentVirtualCollection.value?.name || "Virtual Collection";
}
return (
current.value?.platform_name ||
current.value?.platform_slug?.toUpperCase() ||
"Platform"
currentPlatform.value?.display_name ||
currentPlatform.value?.slug.toUpperCase()
);
});
const current = computed(
() => roms.value[selectedIndex.value] || roms.value[0],
);
function getCols(): number {
if (!gridRef.value) return 4;
@@ -176,10 +163,10 @@ const {
moveRight,
moveUp,
moveDown: moveDownBasic,
} = useSpatialNav(selectedIndex, getCols, () => roms.value.length);
} = useSpatialNav(selectedIndex, getCols, () => filteredRoms.value.length);
function handleAction(action: InputAction): boolean {
if (!roms.value.length) return false;
if (!filteredRoms.value.length) return false;
if (inAlphabet.value) {
if (action === "moveLeft") {
inAlphabet.value = false;
@@ -198,7 +185,7 @@ function handleAction(action: InputAction): boolean {
}
if (action === "confirm") {
const L = Array.from(letters.value)[alphaIndex.value];
const idx = roms.value.findIndex((r) => {
const idx = filteredRoms.value.findIndex((r) => {
const normalized = normalizeTitle(r.name || "");
if (L === "#") {
return /^[0-9]/.test(normalized);
@@ -245,7 +232,7 @@ function handleAction(action: InputAction): boolean {
moveDownBasic();
if (selectedIndex.value === before) {
const cols = getCols();
const count = roms.value.length;
const count = filteredRoms.value.length;
const totalRows = Math.ceil(count / cols);
const currentRow = Math.floor(before / cols);
if (totalRows > currentRow + 1) {
@@ -258,11 +245,14 @@ function handleAction(action: InputAction): boolean {
navigateBack();
return true;
case "confirm": {
selectAndOpen(selectedIndex.value, roms.value[selectedIndex.value]);
selectAndOpen(
selectedIndex.value,
filteredRoms.value[selectedIndex.value],
);
return true;
}
case "toggleFavorite": {
const rom = roms.value[selectedIndex.value];
const rom = filteredRoms.value[selectedIndex.value];
if (rom) toggleFavoriteComposable(rom);
return true;
}
@@ -283,10 +273,14 @@ function selectAndOpen(i: number, rom: SimpleRom) {
persistIndex();
const query: Record<string, number | string> = {};
if (platformId != null) query.id = platformId;
if (isCollectionRoute) query.collection = collectionId!;
if (isSmartCollectionRoute) query.smartCollection = smartCollectionId!;
if (isVirtualCollectionRoute) query.virtualCollection = virtualCollectionId!;
if (isPlatformRoute && currentPlatform.value != null)
query.id = currentPlatform.value.id;
if (isCollectionRoute && currentCollection.value != null)
query.collection = currentCollection.value.id;
if (isSmartCollectionRoute && currentSmartCollection.value != null)
query.smartCollection = currentSmartCollection.value.id;
if (isVirtualCollectionRoute && currentVirtualCollection.value != null)
query.virtualCollection = currentVirtualCollection.value.id;
router.push({
name: ROUTES.CONSOLE_ROM,
@@ -296,7 +290,7 @@ function selectAndOpen(i: number, rom: SimpleRom) {
}
function jumpToLetter(L: string) {
const idx = roms.value.findIndex((r) => {
const idx = filteredRoms.value.findIndex((r) => {
const normalized = normalizeTitle(r.name || "");
if (L === "#") {
return /^[0-9]/.test(normalized);
@@ -316,68 +310,150 @@ function normalizeTitle(name: string) {
let off: (() => void) | null = null;
onMounted(async () => {
try {
if (platformId != null) {
const currentPlatform = platformsStore.get(platformId);
if (currentPlatform) romsStore.setCurrentPlatform(currentPlatform);
} else if (collectionId != null) {
const currentCollection = collectionsStore.getCollection(collectionId);
if (currentCollection) romsStore.setCurrentCollection(currentCollection);
} else if (smartCollectionId != null) {
const currentSmartCollection =
collectionsStore.getSmartCollection(smartCollectionId);
if (currentSmartCollection)
romsStore.setCurrentSmartCollection(currentSmartCollection);
} else if (virtualCollectionId != null) {
const currentVirtualCollection =
collectionsStore.getVirtualCollection(virtualCollectionId);
if (currentVirtualCollection)
romsStore.setCurrentVirtualCollection(currentVirtualCollection);
}
function resetGallery() {
romsStore.reset();
galleryFilterStore.resetFilters();
galleryFilterStore.activeFilterDrawer = false;
}
romsStore.setLimit(500);
romsStore.setOrderBy("name");
romsStore.setOrderDir("asc");
romsStore.resetPagination();
async function fetchRoms() {
romsStore.setLimit(500);
romsStore.setOrderBy("name");
romsStore.setOrderDir("asc");
romsStore.resetPagination();
const fetchedRoms = await romsStore.fetchRoms({
galleryFilter: galleryFilterStore,
concat: false,
});
roms.value = fetchedRoms;
const fetchedRoms = await romsStore.fetchRoms({
galleryFilter: galleryFilterStore,
concat: false,
});
if (collectionId != null) {
const { data: col } = await collectionApi.getCollection(collectionId);
collection.value = col ?? null;
} else if (smartCollectionId != null) {
const { data: smartCol } =
await collectionApi.getSmartCollection(smartCollectionId);
smartCollection.value = smartCol ?? null;
} else if (virtualCollectionId != null) {
const { data: virtualCol } =
await collectionApi.getVirtualCollection(virtualCollectionId);
virtualCollection.value = virtualCol ?? null;
}
for (const r of roms.value) {
if (!r.url_cover && !r.path_cover_large && !r.path_cover_small) {
loadedMap.value[r.id] = true;
}
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : "Failed to load roms";
} finally {
loading.value = false;
}
if (selectedIndex.value >= roms.value.length) selectedIndex.value = 0;
if (selectedIndex.value >= fetchedRoms.length) selectedIndex.value = 0;
await nextTick();
cardElementAt(selectedIndex.value)?.scrollIntoView({
block: "center",
inline: "nearest",
behavior: "instant" as ScrollBehavior,
});
}
onMounted(async () => {
const routePlatformId = isPlatformRoute ? Number(route.params.id) : null;
const routeCollectionId = isCollectionRoute ? Number(route.params.id) : null;
const routeSmartCollectionId = isSmartCollectionRoute
? Number(route.params.id)
: null;
const routeVirtualCollectionId = isVirtualCollectionRoute
? String(route.params.id)
: null;
watch(
() => allPlatforms.value,
async (platforms) => {
if (platforms.length > 0) {
const platform = platforms.find(
(platform) => platform.id === routePlatformId,
);
// Check if the current platform is different or no ROMs have been loaded
if (
platform &&
(currentPlatform.value?.id !== routePlatformId ||
allRoms.value.length === 0)
) {
resetGallery();
romsStore.setCurrentPlatform(platform);
selectedIndex.value = consoleStore.getPlatformGameIndex(platform.id);
document.title = platform.display_name;
await fetchRoms();
}
}
},
{ immediate: true }, // Ensure watcher is triggered immediately
);
watch(
() => allCollections.value,
async (collections) => {
if (collections.length > 0) {
const collection = collections.find(
(collection) => collection.id === routeCollectionId,
);
// Check if the current collection is different or no ROMs have been loaded
if (
collection &&
(currentCollection.value?.id !== routeCollectionId ||
allRoms.value.length === 0)
) {
resetGallery();
romsStore.setCurrentCollection(collection);
selectedIndex.value = consoleStore.getCollectionGameIndex(
collection.id,
);
document.title = collection.name;
await fetchRoms();
}
}
},
{ immediate: true }, // Ensure watcher is triggered immediately
);
watch(
() => smartCollections.value,
async (smartCollections) => {
if (smartCollections.length > 0) {
const smartCollection = smartCollections.find(
(smartCollection) => smartCollection.id === routeSmartCollectionId,
);
// Check if the current smartCollection is different or no ROMs have been loaded
if (
smartCollection &&
(currentSmartCollection.value?.id !== routeSmartCollectionId ||
allRoms.value.length === 0)
) {
resetGallery();
romsStore.setCurrentSmartCollection(smartCollection);
selectedIndex.value = consoleStore.getSmartCollectionGameIndex(
smartCollection.id,
);
document.title = smartCollection.name;
await fetchRoms();
}
}
},
{ immediate: true }, // Ensure watcher is triggered immediately
);
watch(
() => virtualCollections.value,
async (virtualCollections) => {
if (virtualCollections.length > 0) {
const virtualCollection = virtualCollections.find(
(virtualCollection) =>
virtualCollection.id === routeVirtualCollectionId,
);
// Check if the current virtualCollection is different or no ROMs have been loaded
if (
virtualCollection &&
(currentVirtualCollection.value?.id !== routeVirtualCollectionId ||
allRoms.value.length === 0)
) {
resetGallery();
romsStore.setCurrentVirtualCollection(virtualCollection);
selectedIndex.value = consoleStore.getVirtualCollectionGameIndex(
virtualCollection.id,
);
document.title = virtualCollection.name;
await fetchRoms();
}
}
},
{ immediate: true }, // Ensure watcher is triggered immediately
);
off = subscribe(handleAction);
});
@@ -411,21 +487,17 @@ function handleItemDeselected() {
:style="{ width: 'calc(100vw - 40px)' }"
>
<div
v-if="loading"
v-if="fetchingRoms"
class="text-center mt-8"
:style="{ color: 'var(--console-loading-text)' }"
>
Loading games
</div>
<div
v-else-if="error"
class="text-center mt-8"
:style="{ color: 'var(--console-error-text)' }"
>
{{ error }}
</div>
<div v-else>
<div v-if="roms.length === 0" class="text-center text-fgDim p-4">
<div
v-if="filteredRoms.length === 0"
class="text-center text-fgDim p-4"
>
No games found.
</div>
<div
@@ -434,7 +506,7 @@ function handleItemDeselected() {
@wheel.prevent
>
<GameCard
v-for="(rom, i) in roms"
v-for="(rom, i) in filteredRoms"
:key="rom.id"
:rom="rom"
:index="i"

View File

@@ -7,12 +7,9 @@ import {
nextTick,
watch,
useTemplateRef,
onBeforeMount,
} from "vue";
import { useRouter } from "vue-router";
import type { CollectionSchema } from "@/__generated__/models/CollectionSchema";
import type { PlatformSchema } from "@/__generated__/models/PlatformSchema";
import type { SmartCollectionSchema } from "@/__generated__/models/SmartCollectionSchema";
import type { VirtualCollectionSchema } from "@/__generated__/models/VirtualCollectionSchema";
import RIsotipo from "@/components/common/RIsotipo.vue";
import useFavoriteToggle from "@/composables/useFavoriteToggle";
import CollectionCard from "@/console/components/CollectionCard.vue";
@@ -23,7 +20,7 @@ import SystemCard from "@/console/components/SystemCard.vue";
import useBackgroundArt from "@/console/composables/useBackgroundArt";
import {
systemElementRegistry,
recentElementRegistry,
continuePlayingElementRegistry,
collectionElementRegistry,
smartCollectionElementRegistry,
virtualCollectionElementRegistry,
@@ -31,24 +28,27 @@ import {
import { useInputScope } from "@/console/composables/useInputScope";
import { useRovingDom } from "@/console/composables/useRovingDom";
import { useSpatialNav } from "@/console/composables/useSpatialNav";
import { isSupportedPlatform } from "@/console/constants/platforms";
import type { InputAction } from "@/console/input/actions";
import { ROUTES } from "@/plugins/router";
import collectionApi from "@/services/api/collection";
import platformApi from "@/services/api/platform";
import storeCollections from "@/stores/collections";
import storeConsole from "@/stores/console";
import storePlatforms from "@/stores/platforms";
import storeRoms from "@/stores/roms";
import type { SimpleRom } from "@/stores/roms";
const router = useRouter();
const platformsStore = storePlatforms();
const { allPlatforms, fetchingPlatforms } = storeToRefs(platformsStore);
const collectionsStore = storeCollections();
const consoleStore = storeConsole();
const { allCollections, smartCollections, virtualCollections } =
storeToRefs(collectionsStore);
const romsStore = storeRoms();
const { continuePlayingRoms } = storeToRefs(romsStore);
const consoleStore = storeConsole();
const {
navigationMode,
platformIndex,
recentIndex,
continuePlayingIndex,
collectionsIndex,
smartCollectionsIndex,
virtualCollectionsIndex,
@@ -59,12 +59,6 @@ const { setSelectedBackgroundArt, clearSelectedBackgroundArt } =
useBackgroundArt();
const { subscribe } = useInputScope();
const platforms = ref<PlatformSchema[]>([]);
const recentRoms = ref<SimpleRom[]>([]);
const collections = ref<CollectionSchema[]>([]);
const smartCollections = ref<SmartCollectionSchema[]>([]);
const virtualCollections = ref<VirtualCollectionSchema[]>([]);
const loadingPlatforms = ref(true);
const errorMessage = ref("");
const showSettings = ref(false);
@@ -73,7 +67,9 @@ const scrollContainerRef = useTemplateRef<HTMLDivElement>(
"scroll-container-ref",
);
const platformsRef = useTemplateRef<HTMLDivElement>("platforms-ref");
const recentRef = useTemplateRef<HTMLDivElement>("recent-ref");
const continuePlayingRef = useTemplateRef<HTMLDivElement>(
"continue-playing-ref",
);
const collectionsRef = useTemplateRef<HTMLDivElement>("collections-ref");
const smartCollectionsRef = useTemplateRef<HTMLDivElement>(
"smart-collections-ref",
@@ -81,7 +77,9 @@ const smartCollectionsRef = useTemplateRef<HTMLDivElement>(
const virtualCollectionsRef = useTemplateRef<HTMLDivElement>(
"virtual-collections-ref",
);
const recentSectionRef = useTemplateRef<HTMLElement>("recent-section-ref");
const continuePlayingSectionRef = useTemplateRef<HTMLElement>(
"continue-playing-section-ref",
);
const collectionsSectionRef = useTemplateRef<HTMLElement>(
"collections-section-ref",
);
@@ -93,7 +91,8 @@ const virtualCollectionsSectionRef = useTemplateRef<HTMLElement>(
);
const systemElementAt = (i: number) => systemElementRegistry.getElement(i);
const recentElementAt = (i: number) => recentElementRegistry.getElement(i);
const continuePlayingElementAt = (i: number) =>
continuePlayingElementRegistry.getElement(i);
const collectionElementAt = (i: number) =>
collectionElementRegistry.getElement(i);
const smartCollectionElementAt = (i: number) =>
@@ -104,19 +103,22 @@ const virtualCollectionElementAt = (i: number) =>
// Spatial navigation
const { moveLeft: moveSystemLeft, moveRight: moveSystemRight } = useSpatialNav(
platformIndex,
() => platforms.value.length || 1,
() => platforms.value.length,
() => allPlatforms.value.length || 1,
() => allPlatforms.value.length,
);
const { moveLeft: moveRecentLeft, moveRight: moveRecentRight } = useSpatialNav(
recentIndex,
() => recentRoms.value.length || 1,
() => recentRoms.value.length,
const {
moveLeft: moveContinuePlayingLeft,
moveRight: moveContinuePlayingRight,
} = useSpatialNav(
continuePlayingIndex,
() => continuePlayingRoms.value.length || 1,
() => continuePlayingRoms.value.length,
);
const { moveLeft: moveCollectionLeft, moveRight: moveCollectionRight } =
useSpatialNav(
collectionsIndex,
() => collections.value.length || 1,
() => collections.value.length,
() => allCollections.value.length || 1,
() => allCollections.value.length,
);
const {
moveLeft: moveSmartCollectionLeft,
@@ -141,7 +143,7 @@ useRovingDom(platformIndex, systemElementAt, {
behavior: "smooth",
scroll: false, // handle scrolling manually
});
useRovingDom(recentIndex, recentElementAt, {
useRovingDom(continuePlayingIndex, continuePlayingElementAt, {
inline: "center",
block: "nearest",
behavior: "smooth",
@@ -176,11 +178,11 @@ watch(platformIndex, (newIdx) => {
}
});
watch(recentIndex, (newIdx) => {
watch(continuePlayingIndex, (newIdx) => {
if (!isVerticalScrolling) {
const el = recentElementAt(newIdx);
if (el && recentRef.value) {
centerInCarousel(recentRef.value, el, "smooth");
const el = continuePlayingElementAt(newIdx);
if (el && continuePlayingRef.value) {
centerInCarousel(continuePlayingRef.value, el, "smooth");
}
}
});
@@ -219,7 +221,7 @@ const navigationFunctions = {
const before = platformIndex.value;
moveSystemLeft();
if (platformIndex.value === before) {
platformIndex.value = Math.max(0, platforms.value.length - 1);
platformIndex.value = Math.max(0, allPlatforms.value.length - 1);
}
},
next: () => {
@@ -230,35 +232,42 @@ const navigationFunctions = {
}
},
confirm: () => {
if (!platforms.value[platformIndex.value]) return false;
if (!allPlatforms.value[platformIndex.value]) return false;
router.push({
name: ROUTES.CONSOLE_PLATFORM,
params: { id: platforms.value[platformIndex.value].id },
params: { id: allPlatforms.value[platformIndex.value].id },
});
return true;
},
},
recent: {
continuePlaying: {
prev: () => {
const before = recentIndex.value;
moveRecentLeft();
if (recentIndex.value === before) {
recentIndex.value = Math.max(0, recentRoms.value.length - 1);
const before = continuePlayingIndex.value;
moveContinuePlayingLeft();
if (continuePlayingIndex.value === before) {
continuePlayingIndex.value = Math.max(
0,
continuePlayingRoms.value.length - 1,
);
}
},
next: () => {
const before = recentIndex.value;
moveRecentRight();
if (recentIndex.value === before) {
recentIndex.value = 0;
const before = continuePlayingIndex.value;
moveContinuePlayingRight();
if (continuePlayingIndex.value === before) {
continuePlayingIndex.value = 0;
}
},
confirm: () => {
if (!recentRoms.value[recentIndex.value]) return false;
if (!continuePlayingRoms.value[continuePlayingIndex.value]) return false;
router.push({
name: ROUTES.CONSOLE_ROM,
params: { rom: recentRoms.value[recentIndex.value].id },
query: { id: recentRoms.value[recentIndex.value].platform_id },
params: {
rom: continuePlayingRoms.value[continuePlayingIndex.value].id,
},
query: {
id: continuePlayingRoms.value[continuePlayingIndex.value].platform_id,
},
});
return true;
},
@@ -268,7 +277,7 @@ const navigationFunctions = {
const before = collectionsIndex.value;
moveCollectionLeft();
if (collectionsIndex.value === before) {
collectionsIndex.value = Math.max(0, collections.value.length - 1);
collectionsIndex.value = Math.max(0, allCollections.value.length - 1);
}
},
next: () => {
@@ -279,10 +288,10 @@ const navigationFunctions = {
}
},
confirm: () => {
if (!collections.value[collectionsIndex.value]) return false;
if (!allCollections.value[collectionsIndex.value]) return false;
router.push({
name: ROUTES.CONSOLE_COLLECTION,
params: { id: collections.value[collectionsIndex.value].id },
params: { id: allCollections.value[collectionsIndex.value].id },
});
return true;
},
@@ -374,8 +383,11 @@ function scrollToCurrentRow() {
case "systems":
scrollContainerRef.value?.scrollTo({ top: 0, behavior });
break;
case "recent":
recentSectionRef.value?.scrollIntoView({ behavior, block: "start" });
case "continuePlaying":
continuePlayingSectionRef.value?.scrollIntoView({
behavior,
block: "start",
});
break;
case "collections":
collectionsSectionRef.value?.scrollIntoView({
@@ -513,23 +525,23 @@ function handleAction(action: InputAction): boolean {
navigationMode.value = "controls";
return true;
}
if (currentMode === "recent") {
if (currentMode === "continuePlaying") {
navigationMode.value = "systems";
scrollToCurrentRow();
return true;
}
if (currentMode === "collections") {
navigationMode.value =
recentRoms.value.length > 0 ? "recent" : "systems";
continuePlayingRoms.value.length > 0 ? "continuePlaying" : "systems";
scrollToCurrentRow();
return true;
}
if (currentMode === "smartCollections") {
navigationMode.value =
collections.value.length > 0
allCollections.value.length > 0
? "collections"
: recentRoms.value.length > 0
? "recent"
: continuePlayingRoms.value.length > 0
? "continuePlaying"
: "systems";
scrollToCurrentRow();
return true;
@@ -538,10 +550,10 @@ function handleAction(action: InputAction): boolean {
navigationMode.value =
smartCollections.value.length > 0
? "smartCollections"
: collections.value.length > 0
: allCollections.value.length > 0
? "collections"
: recentRoms.value.length > 0
? "recent"
: continuePlayingRoms.value.length > 0
? "continuePlaying"
: "systems";
scrollToCurrentRow();
return true;
@@ -551,9 +563,9 @@ function handleAction(action: InputAction): boolean {
case "moveDown":
if (currentMode === "systems") {
navigationMode.value =
recentRoms.value.length > 0
? "recent"
: collections.value.length > 0
continuePlayingRoms.value.length > 0
? "continuePlaying"
: allCollections.value.length > 0
? "collections"
: smartCollections.value.length > 0
? "smartCollections"
@@ -563,9 +575,9 @@ function handleAction(action: InputAction): boolean {
scrollToCurrentRow();
return true;
}
if (currentMode === "recent") {
if (currentMode === "continuePlaying") {
navigationMode.value =
collections.value.length > 0
allCollections.value.length > 0
? "collections"
: smartCollections.value.length > 0
? "smartCollections"
@@ -613,8 +625,13 @@ function handleAction(action: InputAction): boolean {
return true;
case "toggleFavorite":
if (currentMode === "recent" && recentRoms.value[recentIndex.value]) {
toggleFavoriteComposable(recentRoms.value[recentIndex.value]);
if (
currentMode === "continuePlaying" &&
continuePlayingRoms.value[continuePlayingIndex.value]
) {
toggleFavoriteComposable(
continuePlayingRoms.value[continuePlayingIndex.value],
);
return true;
}
return false;
@@ -624,46 +641,16 @@ function handleAction(action: InputAction): boolean {
}
}
onBeforeMount(async () => {
await romsStore.fetchContinuePlayingRoms();
});
onMounted(async () => {
try {
const [
{ data: plats },
recents,
{ data: cols },
{ data: smartCols },
{ data: virtualCols },
] = await Promise.all([
platformApi.getPlatforms(),
romsStore.fetchRecentRoms(),
collectionApi.getCollections(),
collectionApi.getSmartCollections(),
collectionApi.getVirtualCollections({ type: "collection" }),
]);
platforms.value = plats.filter(
(p) => p.rom_count > 0 && isSupportedPlatform(p.slug),
);
recentRoms.value = recents ?? [];
collections.value = cols ?? [];
smartCollections.value = smartCols ?? [];
virtualCollections.value = virtualCols ?? [];
collectionsStore.setCollections(cols ?? []);
collectionsStore.setFavoriteCollection(
cols?.find(
(collection) => collection.name.toLowerCase() === "favourites",
),
);
} catch (err: unknown) {
errorMessage.value = err instanceof Error ? err.message : "Failed to load";
} finally {
loadingPlatforms.value = false;
}
// Restore indices within bounds
if (platformIndex.value >= platforms.value.length) platformIndex.value = 0;
if (recentIndex.value >= recentRoms.value.length) recentIndex.value = 0;
if (collectionsIndex.value >= collections.value.length)
if (platformIndex.value >= allPlatforms.value.length) platformIndex.value = 0;
if (continuePlayingIndex.value >= continuePlayingRoms.value.length)
continuePlayingIndex.value = 0;
if (collectionsIndex.value >= allCollections.value.length)
collectionsIndex.value = 0;
if (smartCollectionsIndex.value >= smartCollections.value.length)
smartCollectionsIndex.value = 0;
@@ -675,7 +662,10 @@ onMounted(async () => {
// Center carousels
centerInCarousel(platformsRef.value, systemElementAt(platformIndex.value));
centerInCarousel(recentRef.value, recentElementAt(recentIndex.value));
centerInCarousel(
continuePlayingRef.value,
continuePlayingElementAt(continuePlayingIndex.value),
);
centerInCarousel(
collectionsRef.value,
collectionElementAt(collectionsIndex.value),
@@ -697,7 +687,7 @@ let off: (() => void) | null = null;
onUnmounted(() => {
consoleStore.setHomeState({
platformIndex: platformIndex.value,
recentIndex: recentIndex.value,
continuePlayingIndex: continuePlayingIndex.value,
collectionsIndex: collectionsIndex.value,
smartCollectionsIndex: smartCollectionsIndex.value,
virtualCollectionsIndex: virtualCollectionsIndex.value,
@@ -727,7 +717,7 @@ onUnmounted(() => {
</div>
<div
v-if="loadingPlatforms"
v-if="fetchingPlatforms"
class="text-center mt-16"
:style="{ color: 'var(--console-loading-text)' }"
>
@@ -778,7 +768,7 @@ onUnmounted(() => {
>
<div class="flex items-center gap-6 h-full px-12 min-w-max">
<SystemCard
v-for="(p, i) in platforms"
v-for="(p, i) in allPlatforms"
:key="p.id"
:platform="p"
:index="i"
@@ -794,8 +784,8 @@ onUnmounted(() => {
</section>
<section
v-if="recentRoms.length > 0"
ref="recent-section-ref"
v-if="continuePlayingRoms.length > 0"
ref="continue-playing-section-ref"
class="pb-8"
>
<h2
@@ -812,7 +802,7 @@ onUnmounted(() => {
border: `1px solid var(--console-home-carousel-button-border)`,
color: 'var(--console-home-carousel-button-text)',
}"
@click="navigationFunctions.recent.prev"
@click="navigationFunctions.continuePlaying.prev"
>
</button>
@@ -823,26 +813,29 @@ onUnmounted(() => {
border: `1px solid var(--console-home-carousel-button-border)`,
color: 'var(--console-home-carousel-button-text)',
}"
@click="navigationFunctions.recent.next"
@click="navigationFunctions.continuePlaying.next"
>
</button>
<div
ref="recent-ref"
ref="continue-playing-ref"
class="w-full h-full overflow-x-auto overflow-y-hidden no-scrollbar [scrollbar-width:none] [-ms-overflow-style:none]"
@wheel.prevent
>
<div class="flex items-center gap-4 h-full px-12 min-w-max">
<GameCard
v-for="(g, i) in recentRoms"
v-for="(g, i) in continuePlayingRoms"
:key="`${g.platform_id}-${g.id}`"
:rom="g"
:index="i"
:is-recent="true"
:selected="navigationMode === 'recent' && i === recentIndex"
:continue-playing="true"
:selected="
navigationMode === 'continuePlaying' &&
i === continuePlayingIndex
"
:loaded="true"
@click="goGame(g)"
@focus="recentIndex = i"
@focus="continuePlayingIndex = i"
@select="handleItemSelected"
@deselect="handleItemDeselected"
/>
@@ -852,7 +845,7 @@ onUnmounted(() => {
</section>
<section
v-if="collections.length > 0"
v-if="allCollections.length > 0"
ref="collections-section-ref"
class="pb-8"
>
@@ -892,7 +885,7 @@ onUnmounted(() => {
>
<div class="flex items-center gap-4 h-full px-12 min-w-max">
<CollectionCard
v-for="(c, i) in collections"
v-for="(c, i) in allCollections"
:key="`collection-${c.id}`"
:collection="c"
:index="i"
@@ -1084,7 +1077,7 @@ onUnmounted(() => {
<NavigationHint
:show-back="false"
:show-toggle-favorite="navigationMode === 'recent'"
:show-toggle-favorite="navigationMode === 'continuePlaying'"
/>
</div>
<SettingsModal v-model="showSettings" />

View File

@@ -24,7 +24,7 @@
"search-platform": "Plattform suchen",
"settings": "Einstellungen",
"show-duplicates": "Zeige Duplikate",
"show-favourites": "Zeige Favoriten",
"show-favorites": "Zeige Favoriten",
"show-firmwares": "Zeige Firmwares/BIOS",
"show-matched": "Zeige zugewiesene",
"show-playables": "Zeige spielbare",

View File

@@ -1,6 +1,6 @@
{
"add-to-collection": "Zu Sammlung hinzufügen",
"add-to-fav": "Zu Favoriten hinzufügen",
"add-to-favorites": "Zu Favoriten hinzufügen",
"adding-to-collection-part1": "Füge",
"adding-to-collection-part2": "ROMs zu Sammlung hinzu",
"additional-content": "Zustätliche Inhalte",
@@ -19,7 +19,6 @@
"details": "Details",
"difficulty": "Schwierigkeitsgrad",
"download": "Herunterladen",
"edit": "Bearbeiten",
"file": "Datei",
"filename": "Dateiname",
"files": "Dateien",
@@ -34,6 +33,8 @@
"main-story": "Hauptgeschichte",
"manual": "Handbuch",
"manual-match": "Manuell zuweisen",
"metadata": "Metadaten",
"metadata-ids": "Metadaten-IDs",
"my-notes": "Meine Notizen",
"no-metadata-source": "Keine Quelle für Metadaten aktiv",
"no-saves-found": "Keine Speicherstände gefunden",
@@ -46,7 +47,7 @@
"regions": "Regionen",
"related-content": "Zugehörige Inhalte",
"remove-from-collection": "Aus Sammlung entfernen",
"remove-from-fav": "Aus Favoriten entfernen",
"remove-from-favorites": "Aus Favoriten entfernen",
"remove-from-playing": "vom Spielen entfernen",
"removing-from-collection-part1": "Entferne",
"removing-from-collection-part2": "ROMs aus Sammlung",

View File

@@ -2,10 +2,10 @@
"abort": "Abbrechen",
"api-key-missing": "Fehlender oder ungültiger API-Key",
"complete-rescan": "Vollständiger Scan",
"complete-rescan-desc": "Kompletter Neu-Scan aller Plattformen und Dateien (am langsamsten)",
"complete-rescan-desc": "Kompletter Neu-Scan ausgewählter Plattformen (am langsamsten)",
"disabled-by-admin": "Vom Administrator deaktiviert",
"hashes": "Hashes neu berechnen",
"hashes-desc": "Berechne Hashes für alle Dateien neu",
"hashes-desc": "Berechne Hashes für ausgewählte Plattformen neu",
"manage-library": "Bibliothek verwalten",
"metadata-sources": "Quellen für Metadaten",
"new-platforms": "Neue Platformen",
@@ -15,11 +15,11 @@
"partial-metadata": "Unvollständige Metadaten",
"partial-metadata-desc": "Scanne Spiele mit unvollständigen Metadaten",
"platforms-scanned-n": "Plattformen: {n} gescannte | Plattformen: {n} gescannt",
"platforms-scanned-with-details": "Plattformen: {n_platforms} gescannt, darunter {n_new_platforms} neue und {n_identified_platforms} identifizierte",
"platforms-scanned-with-details": "Plattformen: {n_scanned_platforms} gescannt aus {n_total_platforms}, darunter {n_new_platforms} neue und {n_identified_platforms} identifizierte",
"quick-scan": "Schneller Scan",
"quick-scan-desc": "Nur neue Dateien scannen",
"roms-scanned-n": "Roms: {n} gescannte | Roms: {n} gescannt",
"roms-scanned-with-details": "Roms: {n_roms} gescannt, dabei {n_added_roms} neue und {n_identified_roms} identifizierte",
"roms-scanned-with-details": "Roms: {n_scanned_roms} gescannt aus {n_total_roms}, darunter {n_new_roms} neue und {n_identified_roms} identifizierte",
"scan": "Scannen",
"scan-options": "Scan-Optionen",
"select-one-source": "Bitte wähle mindestens eine Metadatenquelle, wenn du die Bibliothek mit Cover-Artworks und Metadaten anreichern möchtest",

View File

@@ -25,7 +25,7 @@
"search-platform": "Search platform",
"settings": "Settings",
"show-duplicates": "Show duplicates",
"show-favourites": "Show favourites",
"show-favorites": "Show favourites",
"show-firmwares": "Show firmwares/BIOS",
"show-matched": "Show Matched",
"show-missing": "Show missing",

View File

@@ -1,6 +1,6 @@
{
"add-to-collection": "Add to collection",
"add-to-fav": "Add to favourites",
"add-to-favorites": "Add to favourites",
"adding-to-collection-part1": "Adding",
"adding-to-collection-part2": "ROMs to collection",
"additional-content": "Additional content",
@@ -19,7 +19,6 @@
"details": "Details",
"difficulty": "Difficulty",
"download": "Download",
"edit": "Edit",
"file": "File",
"filename": "File name",
"files": "Files",
@@ -34,6 +33,8 @@
"main-story": "Main Story",
"manual": "Manual",
"manual-match": "Manual match",
"metadata": "Metadata",
"metadata-ids": "Metadata IDs",
"my-notes": "My notes",
"no-metadata-source": "No metadata source enabled",
"no-saves-found": "No saves found",
@@ -46,7 +47,7 @@
"regions": "Regions",
"related-content": "Related content",
"remove-from-collection": "Remove from collection",
"remove-from-fav": "Remove from favourites",
"remove-from-favorites": "Remove from favourites",
"remove-from-playing": "Remove from playing",
"removing-from-collection-part1": "Removing",
"removing-from-collection-part2": "ROMs from collection",

View File

@@ -2,10 +2,10 @@
"abort": "Abort",
"api-key-missing": "API key missing or invalid",
"complete-rescan": "Complete rescan",
"complete-rescan-desc": "Total rescan of all platforms and files (slowest)",
"complete-rescan-desc": "Total rescan of selected platforms (slowest)",
"disabled-by-admin": "Disabled by the administrator",
"hashes": "Recalculate hashes",
"hashes-desc": "Recalculates hashes for all files",
"hashes-desc": "Recalculates hashes for selected platforms",
"manage-library": "Manage library",
"metadata-sources": "Metadata sources",
"new-platforms": "New platforms",
@@ -15,11 +15,11 @@
"partial-metadata": "Partial metadata",
"partial-metadata-desc": "Scan games with partial metadata matches",
"platforms-scanned-n": "Platforms: {n} scanned",
"platforms-scanned-with-details": "Platforms: {n_platforms} scanned, with {n_new_platforms} new and {n_identified_platforms} identified",
"platforms-scanned-with-details": "Platforms: {n_scanned_platforms} scanned out of {n_total_platforms}, with {n_new_platforms} new and {n_identified_platforms} identified",
"quick-scan": "Quick scan",
"quick-scan-desc": "Scan new files only",
"roms-scanned-n": "Roms: {n} scanned",
"roms-scanned-with-details": "Roms: {n_roms} scanned, with {n_added_roms} new and {n_identified_roms} identified",
"roms-scanned-with-details": "Roms: {n_scanned_roms} scanned out of {n_total_roms}, with {n_new_roms} new and {n_identified_roms} identified",
"scan": "Scan",
"scan-options": "Scan options",
"select-one-source": "Please select at least one metadata source to enrich your library with artwork and metadata",

View File

@@ -25,7 +25,7 @@
"search-platform": "Search platform",
"settings": "Settings",
"show-duplicates": "Show duplicates",
"show-favourites": "Show favourites",
"show-favorites": "Show favourites",
"show-firmwares": "Show firmwares/BIOS",
"show-matched": "Show Matched",
"show-missing": "Show missing",

View File

@@ -1,6 +1,6 @@
{
"add-to-collection": "Add to collection",
"add-to-fav": "Add to favourites",
"add-to-favorites": "Add to favourites",
"adding-to-collection-part1": "Adding",
"adding-to-collection-part2": "ROMs to collection",
"additional-content": "Additional content",
@@ -19,7 +19,6 @@
"details": "Details",
"difficulty": "Difficulty",
"download": "Download",
"edit": "Edit",
"file": "File",
"filename": "File name",
"files": "Files",
@@ -34,6 +33,8 @@
"main-story": "Main Story",
"manual": "Manual",
"manual-match": "Manual match",
"metadata": "Metadata",
"metadata-ids": "Metadata IDs",
"my-notes": "My notes",
"no-metadata-source": "No metadata source enabled",
"no-saves-found": "No saves found",
@@ -46,7 +47,7 @@
"regions": "Regions",
"related-content": "Related content",
"remove-from-collection": "Remove from collection",
"remove-from-fav": "Remove from favourites",
"remove-from-favorites": "Remove from favourites",
"remove-from-playing": "Remove from playing",
"removing-from-collection-part1": "Removing",
"removing-from-collection-part2": "ROMs from collection",

Some files were not shown because too many files have changed in this diff Show More