mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 00:27:41 +01:00
Merge branch 'master' into romm-821
This commit is contained in:
53
README.md
53
README.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
42
backend/alembic/versions/0055_collection_is_favorite.py
Normal file
42
backend/alembic/versions/0055_collection_is_favorite.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
00000000
|
||||
@@ -0,0 +1 @@
|
||||
11111111
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"] == ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
frontend/src/__generated__/models/ScanStats.ts
generated
4
frontend/src/__generated__/models/ScanStats.ts
generated
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -122,7 +122,7 @@ async function updatePlatform() {
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
scanningStore.set(true);
|
||||
scanningStore.setScanning(true);
|
||||
|
||||
if (!socket.connected) socket.connect();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
99
frontend/src/components/Gallery/AppBar/common/RandomBtn.vue
Normal file
99
frontend/src/components/Gallery/AppBar/common/RandomBtn.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -123,7 +123,7 @@ async function uploadRoms() {
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
scanningStore.set(true);
|
||||
scanningStore.setScanning(true);
|
||||
|
||||
if (!socket.connected) socket.connect();
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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
|
||||
? ` +${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 ? ` +${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
|
||||
? ` +${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
|
||||
? ` +${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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -574,7 +574,7 @@ onUnmounted(() => {
|
||||
}"
|
||||
>
|
||||
{{
|
||||
rom.platform_name ||
|
||||
rom.platform_display_name ||
|
||||
(rom.platform_slug || "RETRO")?.toString().toUpperCase()
|
||||
}}
|
||||
</span>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user