Merge pull request #1416 from rommapp/feature/screenscraper-integration

feat: Screenscraper integration
This commit is contained in:
Zurdi
2025-02-17 16:00:24 +01:00
committed by GitHub
68 changed files with 1957 additions and 285 deletions

2
.vscode/tasks.json vendored
View File

@@ -35,7 +35,7 @@
{
"label": "Setup testing environment",
"type": "shell",
"command": "export $(cat .env | grep DB_ROOT_PASSWD | xargs) && docker exec -i mariadb mariadb -u root -p$DB_ROOT_PASSWD < backend/romm_test/setup.sql",
"command": "export $(cat .env | grep DB_ROOT_PASSWD | xargs) && docker exec -i romm-db-dev mariadb -u root -p$DB_ROOT_PASSWD < backend/romm_test/setup.sql",
"problemMatcher": []
},
{

View File

@@ -1,6 +1,6 @@
"""Change empty string in users.email to NULL.
Revision ID: 951473b0c581
Revision ID: 0030_user_email_null
Revises: 0029_platforms_custom_name
Create Date: 2025-01-14 01:30:39.696257

View File

@@ -0,0 +1,46 @@
"""empty message
Revision ID: 0035_screenscraper
Revises: 0034_virtual_collections_db_view
Create Date: 2025-01-02 18:58:55.557123
"""
import sqlalchemy as sa
from alembic import op
from utils.database import CustomJSON
# revision identifiers, used by Alembic.
revision = "0035_screenscraper"
down_revision = "0034_virtual_collections_db_view"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.add_column(sa.Column("ss_id", sa.Integer(), nullable=True))
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.add_column(sa.Column("ss_id", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("ss_metadata", CustomJSON(), nullable=True))
batch_op.add_column(sa.Column("url_manual", sa.Text(), nullable=True)),
batch_op.add_column(sa.Column("path_manual", sa.Text(), nullable=True)),
batch_op.create_index("idx_roms_ss_id", ["ss_id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.drop_index("idx_roms_ss_id")
batch_op.drop_column("ss_id")
batch_op.drop_column("ss_metadata")
batch_op.drop_column("url_manual")
batch_op.drop_column("path_manual")
batch_op.drop_index("idx_roms_ss_id")
with op.batch_alter_table("platforms", schema=None) as batch_op:
batch_op.drop_column("ss_id")
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""empty message
Revision ID: 0036_screenscraper_platforms_id
Revises: 0035_screenscraper
Create Date: 2025-01-02 18:58:55.557123
"""
import sqlalchemy as sa
from alembic import op
from handler.metadata.ss_handler import SLUG_TO_SS_ID
# revision identifiers, used by Alembic.
revision = "0036_screenscraper_platforms_id"
down_revision = "0035_screenscraper"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
connection = op.get_bind()
for slug, ss_id in SLUG_TO_SS_ID.items():
connection.execute(
sa.text("UPDATE platforms SET ss_id = :ss_id WHERE slug = :slug"),
{"ss_id": ss_id["id"], "slug": slug},
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -57,6 +57,10 @@ IGDB_CLIENT_SECRET: Final = os.environ.get(
"IGDB_CLIENT_SECRET", os.environ.get("CLIENT_SECRET", "")
)
# SCREENSCRAPER
SCREENSCRAPER_USER: Final = os.environ.get("SCREENSCRAPER_USER", "")
SCREENSCRAPER_PASSWORD: Final = os.environ.get("SCREENSCRAPER_PASSWORD", "")
# STEAMGRIDDB
STEAMGRIDDB_API_KEY: Final = os.environ.get("STEAMGRIDDB_API_KEY", "")

View File

@@ -78,8 +78,8 @@ async def add_collection(
)
else:
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
overwrite=True,
entity=_added_collection,
overwrite=True,
url_cover=_added_collection.url_cover,
)
@@ -247,8 +247,8 @@ async def update_collection(
{"url_cover": data.get("url_cover", collection.url_cover)}
)
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
overwrite=True,
entity=collection,
overwrite=True,
url_cover=data.get("url_cover", ""), # type: ignore
)
cleaned_data.update(

View File

@@ -18,6 +18,7 @@ from handler.filesystem import fs_platform_handler
from handler.metadata.igdb_handler import IGDB_API_ENABLED
from handler.metadata.moby_handler import MOBY_API_ENABLED
from handler.metadata.sgdb_handler import STEAMGRIDDB_API_ENABLED
from handler.metadata.ss_handler import SS_API_ENABLED
from utils import get_version
from utils.router import APIRouter
@@ -40,9 +41,12 @@ def heartbeat() -> HeartbeatResponse:
"SHOW_SETUP_WIZARD": len(db_user_handler.get_admin_users()) == 0,
},
"METADATA_SOURCES": {
"ANY_SOURCE_ENABLED": IGDB_API_ENABLED or MOBY_API_ENABLED,
"ANY_SOURCE_ENABLED": IGDB_API_ENABLED
or MOBY_API_ENABLED
or SS_API_ENABLED,
"IGDB_API_ENABLED": IGDB_API_ENABLED,
"MOBY_API_ENABLED": MOBY_API_ENABLED,
"SS_API_ENABLED": SS_API_ENABLED,
"STEAMGRIDDB_ENABLED": STEAMGRIDDB_API_ENABLED,
},
"FILESYSTEM": {

View File

@@ -25,6 +25,7 @@ class MetadataSourcesDict(TypedDict):
ANY_SOURCE_ENABLED: bool
IGDB_API_ENABLED: bool
MOBY_API_ENABLED: bool
SS_API_ENABLED: bool
STEAMGRIDDB_ENABLED: bool

View File

@@ -17,6 +17,7 @@ class PlatformSchema(BaseModel):
igdb_id: int | None = None
sgdb_id: int | None = None
moby_id: int | None = None
ss_id: int | None = None
category: str | None = None
generation: int | None = None
family_name: str | None = None

View File

@@ -9,6 +9,7 @@ from endpoints.responses.collection import CollectionSchema
from fastapi import Request
from handler.metadata.igdb_handler import IGDBMetadata
from handler.metadata.moby_handler import MobyMetadata
from handler.metadata.ss_handler import SSMetadata
from models.rom import Rom, RomFileCategory, RomUserStatus
from pydantic import computed_field
@@ -26,6 +27,11 @@ RomMobyMetadata = TypedDict( # type: ignore[misc]
dict((k, NotRequired[v]) for k, v in get_type_hints(MobyMetadata).items()),
total=False,
)
RomSSMetadata = TypedDict( # type: ignore[misc]
"RomSSMetadata",
dict((k, NotRequired[v]) for k, v in get_type_hints(SSMetadata).items()),
total=False,
)
def rom_user_schema_factory() -> RomUserSchema:
@@ -120,6 +126,7 @@ class RomSchema(BaseModel):
igdb_id: int | None
sgdb_id: int | None
moby_id: int | None
ss_id: int | None
platform_id: int
platform_slug: str
@@ -152,10 +159,16 @@ class RomSchema(BaseModel):
age_ratings: list[str]
igdb_metadata: RomIGDBMetadata | None
moby_metadata: RomMobyMetadata | None
ss_metadata: RomSSMetadata | None
path_cover_small: str | None
path_cover_large: str | None
url_cover: str | None
has_manual: bool
path_manual: str | None
url_manual: str | None
is_unidentified: bool
revision: str | None

View File

@@ -4,11 +4,13 @@ from .base import BaseModel
class SearchRomSchema(BaseModel):
igdb_id: int | None = None
moby_id: int | None = None
ss_id: int | None = None
slug: str
name: str
summary: str
igdb_url_cover: str = ""
moby_url_cover: str = ""
ss_url_cover: str = ""
platform_id: int

View File

@@ -1,4 +1,5 @@
import binascii
import os
from base64 import b64encode
from datetime import datetime, timezone
from io import BytesIO
@@ -31,7 +32,8 @@ from handler.auth.constants import Scope
from handler.database import db_platform_handler, db_rom_handler
from handler.filesystem import fs_resource_handler, fs_rom_handler
from handler.filesystem.base_handler import CoverSize
from handler.metadata import meta_igdb_handler, meta_moby_handler
from handler.metadata import meta_igdb_handler, meta_moby_handler, meta_ss_handler
from logger.formatter import highlight as hl
from logger.logger import log
from models.rom import Rom, RomFile, RomUser
from PIL import Image
@@ -492,6 +494,7 @@ async def update_rom(
"igdb_id": None,
"sgdb_id": None,
"moby_id": None,
"ss_id": None,
"name": rom.fs_name,
"summary": "",
"url_screenshots": [],
@@ -499,9 +502,11 @@ async def update_rom(
"path_cover_s": "",
"path_cover_l": "",
"url_cover": "",
"url_manual": "",
"slug": "",
"igdb_metadata": {},
"moby_metadata": {},
"ss_metadata": {},
"revision": "",
},
)
@@ -515,6 +520,7 @@ async def update_rom(
cleaned_data: dict[str, Any] = {
"igdb_id": data.get("igdb_id", rom.igdb_id),
"moby_id": data.get("moby_id", rom.moby_id),
"ss_id": data.get("ss_id", rom.ss_id),
}
moby_id = cleaned_data["moby_id"]
@@ -527,9 +533,23 @@ async def update_rom(
)
cleaned_data.update({"path_screenshots": path_screenshots})
igdb_id = cleaned_data["igdb_id"]
if igdb_id and int(igdb_id) != rom.igdb_id:
igdb_rom = await meta_igdb_handler.get_rom_by_id(int(igdb_id))
if (
cleaned_data.get("ss_id", "")
and int(cleaned_data.get("ss_id", "")) != rom.ss_id
):
ss_rom = await meta_ss_handler.get_rom_by_id(cleaned_data["ss_id"])
cleaned_data.update(ss_rom)
path_screenshots = await fs_resource_handler.get_rom_screenshots(
rom=rom,
url_screenshots=cleaned_data.get("url_screenshots", []),
)
cleaned_data.update({"path_screenshots": path_screenshots})
if (
cleaned_data.get("igdb_id", "")
and int(cleaned_data.get("igdb_id", "")) != rom.igdb_id
):
igdb_rom = await meta_igdb_handler.get_rom_by_id(cleaned_data["igdb_id"])
cleaned_data.update(igdb_rom)
path_screenshots = await fs_resource_handler.get_rom_screenshots(
rom=rom,
@@ -613,14 +633,29 @@ async def update_rom(
):
cleaned_data.update({"url_cover": data.get("url_cover", rom.url_cover)})
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
overwrite=True,
entity=rom,
overwrite=True,
url_cover=str(data.get("url_cover") or ""),
)
cleaned_data.update(
{"path_cover_s": path_cover_s, "path_cover_l": path_cover_l}
)
if data.get("url_manual", "") != rom.url_manual or not (
await fs_resource_handler.manual_exists(rom)
):
cleaned_data.update({"url_manual": data.get("url_manual", rom.url_manual)})
url_manual = await fs_resource_handler.get_manual(
rom=rom,
overwrite=True,
url_manual=str(data.get("url_manual") or ""),
)
cleaned_data.update({"url_manual": url_manual})
log.debug(
f"Updating {hl(cleaned_data.get('name', ''))} [{id}] with data {cleaned_data}"
)
db_rom_handler.update_rom(id, cleaned_data)
rom = db_rom_handler.get_rom(id)
if not rom:
@@ -629,6 +664,60 @@ async def update_rom(
return DetailedRomSchema.from_orm_with_request(rom, request)
@protected_route(router.post, "/{id}/manuals", [Scope.ROMS_WRITE])
async def add_rom_manuals(request: Request, id: int):
"""Upload manuals for a rom
Args:
request (Request): Fastapi Request object
Raises:
HTTPException: No files were uploaded
"""
rom = db_rom_handler.get_rom(id)
if not rom:
raise RomNotFoundInDatabaseException(id)
filename = request.headers.get("x-upload-filename")
manuals_path = f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/manual"
file_location = Path(f"{manuals_path}/{rom.id}.pdf")
log.info(f"Uploading {file_location}")
if not os.path.exists(manuals_path):
await Path(manuals_path).mkdir(parents=True, exist_ok=True)
parser = StreamingFormDataParser(headers=request.headers)
parser.register("x-upload-platform", NullTarget())
parser.register(filename, FileTarget(str(file_location)))
async def cleanup_partial_file():
if await file_location.exists():
await file_location.unlink()
try:
async for chunk in request.stream():
parser.data_received(chunk)
except ClientDisconnect:
log.error("Client disconnected during upload")
await cleanup_partial_file()
except Exception as exc:
log.error("Error uploading files", exc_info=exc)
await cleanup_partial_file()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="There was an error uploading the file(s)",
) from exc
path_manual = await fs_resource_handler.get_manual(
rom=rom, overwrite=False, url_manual=None
)
db_rom_handler.update_rom(id, {"path_manual": path_manual})
return Response(status_code=status.HTTP_201_CREATED)
@protected_route(router.post, "/delete", [Scope.ROMS_WRITE])
async def delete_roms(
request: Request,

View File

@@ -1,13 +1,21 @@
import asyncio
import emoji
from decorators.auth import protected_route
from endpoints.responses.search import SearchCoverSchema, SearchRomSchema
from fastapi import HTTPException, Request, status
from handler.auth.constants import Scope
from handler.database import db_rom_handler
from handler.metadata import meta_igdb_handler, meta_moby_handler, meta_sgdb_handler
from handler.metadata import (
meta_igdb_handler,
meta_moby_handler,
meta_sgdb_handler,
meta_ss_handler,
)
from handler.metadata.igdb_handler import IGDB_API_ENABLED, IGDBRom
from handler.metadata.moby_handler import MOBY_API_ENABLED, MobyGamesRom
from handler.metadata.sgdb_handler import STEAMGRIDDB_API_ENABLED
from handler.metadata.ss_handler import SS_API_ENABLED, SSRom
from handler.scan_handler import _get_main_platform_igdb_id
from logger.logger import log
from utils.router import APIRouter
@@ -39,7 +47,7 @@ async def search_rom(
list[SearchRomSchema]: List of matched roms
"""
if not IGDB_API_ENABLED and not MOBY_API_ENABLED:
if not IGDB_API_ENABLED and not SS_API_ENABLED and not MOBY_API_ENABLED:
log.error("Search error: No metadata providers enabled")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -64,11 +72,15 @@ async def search_rom(
igdb_matched_roms: list[IGDBRom] = []
moby_matched_roms: list[MobyGamesRom] = []
ss_matched_roms: list[SSRom] = []
if search_by.lower() == "id":
try:
igdb_rom = await meta_igdb_handler.get_matched_rom_by_id(int(search_term))
moby_rom = await meta_moby_handler.get_matched_rom_by_id(int(search_term))
igdb_rom, moby_rom, ss_rom = await asyncio.gather(
meta_igdb_handler.get_matched_rom_by_id(int(search_term)),
meta_moby_handler.get_matched_rom_by_id(int(search_term)),
meta_ss_handler.get_matched_rom_by_id(int(search_term)),
)
except ValueError as exc:
log.error(f"Search error: invalid ID '{search_term}'")
raise HTTPException(
@@ -78,24 +90,39 @@ async def search_rom(
else:
igdb_matched_roms = [igdb_rom] if igdb_rom else []
moby_matched_roms = [moby_rom] if moby_rom else []
ss_matched_roms = [ss_rom] if ss_rom else []
elif search_by.lower() == "name":
main_platform_igdb_id = await _get_main_platform_igdb_id(rom.platform)
igdb_matched_roms = await meta_igdb_handler.get_matched_roms_by_name(
search_term, main_platform_igdb_id or rom.platform.igdb_id
)
moby_matched_roms = await meta_moby_handler.get_matched_roms_by_name(
search_term, rom.platform.moby_id
igdb_matched_roms, moby_matched_roms, ss_matched_roms = await asyncio.gather(
meta_igdb_handler.get_matched_roms_by_name(
search_term, (await _get_main_platform_igdb_id(rom.platform))
),
meta_moby_handler.get_matched_roms_by_name(
search_term, rom.platform.moby_id
),
meta_ss_handler.get_matched_roms_by_name(search_term, rom.platform.ss_id),
)
merged_dict = {
item["name"]: {**item, "igdb_url_cover": item.pop("url_cover", "")} # type: ignore
for item in igdb_matched_roms
}
for item in moby_matched_roms:
merged_dict[item["name"]] = { # type: ignore
**item,
"moby_url_cover": item.pop("url_cover", ""),
**merged_dict.get(item.get("name", ""), {}),
merged_dict: dict[str, dict] = {}
for igdb_rom in igdb_matched_roms:
merged_dict[igdb_rom["name"]] = {
**igdb_rom,
"igdb_url_cover": igdb_rom.pop("url_cover", ""),
**merged_dict.get(igdb_rom.get("name", ""), {}),
}
for moby_rom in moby_matched_roms:
merged_dict[moby_rom["name"]] = { # type: ignore
**moby_rom,
"moby_url_cover": moby_rom.pop("url_cover", ""),
**merged_dict.get(moby_rom.get("name", ""), {}),
}
for ss_rom in ss_matched_roms:
merged_dict[ss_rom["name"]] = {
**ss_rom,
"ss_url_cover": ss_rom.pop("url_cover", ""),
**merged_dict.get(ss_rom.get("name", ""), {}),
}
matched_roms = [
@@ -106,6 +133,7 @@ async def search_rom(
"summary": "",
"igdb_url_cover": "",
"moby_url_cover": "",
"ss_url_cover": "",
"platform_id": rom.platform_id,
},
**item,

View File

@@ -25,7 +25,13 @@ from handler.filesystem import (
)
from handler.filesystem.roms_handler import FSRom
from handler.redis_handler import high_prio_queue, redis_client
from handler.scan_handler import ScanType, scan_firmware, scan_platform, scan_rom
from handler.scan_handler import (
MetadataSource,
ScanType,
scan_firmware,
scan_platform,
scan_rom,
)
from handler.socket_handler import socket_handler
from logger.formatter import LIGHTYELLOW, RED
from logger.formatter import highlight as hl
@@ -153,7 +159,7 @@ async def scan_platforms(
roms_ids = []
if not metadata_sources:
metadata_sources = ["igdb", "moby"]
metadata_sources = [MetadataSource.IGDB, MetadataSource.MOBY, MetadataSource.SS]
sm = _get_socket_manager()
@@ -485,11 +491,17 @@ async def _identify_rom(
return scan_stats
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
overwrite=True,
entity=_added_rom,
overwrite=True,
url_cover=_added_rom.url_cover,
)
path_manual = await fs_resource_handler.get_manual(
rom=_added_rom,
overwrite=True,
url_manual=_added_rom.url_manual,
)
path_screenshots = await fs_resource_handler.get_rom_screenshots(
rom=_added_rom,
url_screenshots=_added_rom.url_screenshots,
@@ -498,6 +510,7 @@ async def _identify_rom(
_added_rom.path_cover_s = path_cover_s
_added_rom.path_cover_l = path_cover_l
_added_rom.path_screenshots = path_screenshots
_added_rom.path_manual = path_manual
# Update the scanned rom with the cover and screenshots paths and update database
db_rom_handler.update_rom(
_added_rom.id,

View File

@@ -3,7 +3,7 @@ from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from handler.filesystem.roms_handler import FSRomsHandler
from handler.metadata.igdb_handler import IGDBBaseHandler, IGDBRom
from handler.metadata.igdb_handler import IGDBHandler, IGDBRom
from main import app
@@ -38,7 +38,7 @@ def test_get_all_roms(client, access_token, rom, platform):
@patch.object(FSRomsHandler, "rename_fs_rom")
@patch.object(IGDBBaseHandler, "get_rom_by_id", return_value=IGDBRom(igdb_id=None))
@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):
response = client.put(
f"/api/roms/{rom.id}",

View File

@@ -200,3 +200,66 @@ class FSResourcesHandler(FSHandler):
path_screenshots.append(self._get_screenshot_path(rom, str(idx)))
return path_screenshots
@staticmethod
async def manual_exists(rom: Rom) -> bool:
"""Check if rom manual exists in filesystem
Args:
rom: Rom object
Returns
True if manual exists in filesystem else False
"""
async for _ in Path(
f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/manual"
).glob(f"{rom.id}.pdf"):
return True
return False
@staticmethod
async def _store_manual(rom: Rom, url_manual: str):
manual_path = Path(f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/manual")
manual_file = manual_path / Path(f"{rom.id}.pdf")
httpx_client = ctx_httpx_client.get()
try:
async with httpx_client.stream("GET", url_manual, timeout=120) as response:
if response.status_code == 200:
await manual_path.mkdir(parents=True, exist_ok=True)
async with await manual_file.open("wb") as f:
async for chunk in response.aiter_raw():
await f.write(chunk)
except httpx.NetworkError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Unable to fetch cover at {url_manual}: {str(exc)}",
) from exc
except httpx.ProtocolError:
log.warning(f"Failure writing cover {url_manual} to file (ProtocolError)")
@staticmethod
async def _get_manual_path(rom: Rom) -> str:
"""Returns rom manual filesystem path adapted to frontend folder structure
Args:
rom: Rom object
"""
async for matched_file in Path(
f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/manual"
).glob(f"{rom.id}.pdf"):
return str(matched_file.relative_to(RESOURCES_BASE_PATH))
return ""
async def get_manual(
self, rom: Rom | None, overwrite: bool, url_manual: str | None
) -> str:
if not rom:
return ""
manual_exists = await self.manual_exists(rom)
if url_manual and (overwrite or not manual_exists):
await self._store_manual(rom, url_manual)
manual_exists = await self.manual_exists(rom)
path_manual = (await self._get_manual_path(rom)) if manual_exists else ""
return path_manual

View File

@@ -7,7 +7,7 @@ from models.platform import Platform
async def test_get_rom_cover():
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
overwrite=False, entity=None, url_cover=""
entity=None, overwrite=False, url_cover=""
)
assert "" in path_cover_s

View File

@@ -1,7 +1,9 @@
from .igdb_handler import IGDBBaseHandler
from .igdb_handler import IGDBHandler
from .moby_handler import MobyGamesHandler
from .sgdb_handler import SGDBBaseHandler
from .ss_handler import SSHandler
meta_igdb_handler = IGDBBaseHandler()
meta_igdb_handler = IGDBHandler()
meta_moby_handler = MobyGamesHandler()
meta_ss_handler = SSHandler()
meta_sgdb_handler = SGDBBaseHandler()

View File

@@ -202,13 +202,17 @@ class MetadataHandler:
- "client_id"
- "client_secret"
- "api_key"
- "ssid"
- "sspassword"
- "devid"
- "devpassword"
"""
return {
key: (
f"Bearer {values[key].split(' ')[1][:3]}***{values[key].split(' ')[1][-3:]}"
f"Bearer {values[key].split(' ')[1][:2]}***{values[key].split(' ')[1][-2:]}"
if key == "Authorization" and values[key].startswith("Bearer ")
else (
f"{values[key][:3]}***{values[key][-3:]}"
f"{values[key][:2]}***{values[key][-2:]}"
if key
in {
"Client-ID",
@@ -216,6 +220,10 @@ class MetadataHandler:
"client_id",
"client_secret",
"api_key",
"ssid",
"sspassword",
"devid",
"devpassword",
}
# Leave other keys unchanged
else values[key]

View File

@@ -207,7 +207,7 @@ def extract_metadata_from_igdb_rom(rom: dict) -> IGDBMetadata:
)
class IGDBBaseHandler(MetadataHandler):
class IGDBHandler(MetadataHandler):
def __init__(self) -> None:
self.BASE_URL = "https://api.igdb.com/v4"
self.platform_endpoint = f"{self.BASE_URL}/platforms"

View File

@@ -79,8 +79,8 @@ def extract_metadata_from_moby_rom(rom: dict) -> MobyMetadata:
class MobyGamesHandler(MetadataHandler):
def __init__(self) -> None:
self.BASE_URL = "https://api.mobygames.com/v1"
self.platform_url = f"{self.BASE_URL}/platforms"
self.games_url = f"{self.BASE_URL}/games"
self.platform_endpoint = f"{self.BASE_URL}/platforms"
self.games_endpoint = f"{self.BASE_URL}/games"
async def _request(self, url: str, timeout: int = 120) -> dict:
httpx_client = ctx_httpx_client.get()
@@ -148,7 +148,7 @@ class MobyGamesHandler(MetadataHandler):
return None
search_term = uc(search_term)
url = yarl.URL(self.games_url).with_query(
url = yarl.URL(self.games_endpoint).with_query(
platform=[platform_moby_id],
title=quote(search_term, safe="/ "),
)
@@ -283,7 +283,7 @@ class MobyGamesHandler(MetadataHandler):
if not MOBY_API_ENABLED:
return MobyGamesRom(moby_id=None)
url = yarl.URL(self.games_url).with_query(id=moby_id)
url = yarl.URL(self.games_endpoint).with_query(id=moby_id)
roms = (await self._request(str(url))).get("games", [])
res = pydash.get(roms, "[0]", None)
@@ -319,7 +319,7 @@ class MobyGamesHandler(MetadataHandler):
return []
search_term = uc(search_term)
url = yarl.URL(self.games_url).with_query(
url = yarl.URL(self.games_endpoint).with_query(
platform=[platform_moby_id], title=quote(search_term, safe="/ ")
)
matched_roms = (await self._request(str(url))).get("games", [])

View File

@@ -0,0 +1,781 @@
import asyncio
import base64
import http
import re
from datetime import datetime
from typing import Final, NotRequired, TypedDict
from urllib.parse import quote
import httpx
import pydash
import yarl
from config import SCREENSCRAPER_PASSWORD, SCREENSCRAPER_USER
from fastapi import HTTPException, status
from logger.logger import log
from unidecode import unidecode as uc
from utils.context import ctx_httpx_client
from .base_hander import (
PS2_OPL_REGEX,
SONY_SERIAL_REGEX,
SWITCH_PRODUCT_ID_REGEX,
SWITCH_TITLEDB_REGEX,
MetadataHandler,
)
# Used to display the Screenscraper API status in the frontend
SS_API_ENABLED: Final = bool(SCREENSCRAPER_USER) and bool(SCREENSCRAPER_PASSWORD)
SS_DEV_ID: Final = base64.b64decode("enVyZGkxNQ==").decode()
SS_DEV_PASSWORD: Final = base64.b64decode("eFRKd29PRmpPUUc=").decode()
PS1_SS_ID: Final = 57
PS2_SS_ID: Final = 58
PSP_SS_ID: Final = 61
SWITCH_SS_ID: Final = 225
ARCADE_SS_IDS: Final = [
6,
7,
8,
47,
49,
52,
53,
54,
55,
56,
68,
69,
75,
112,
142,
147,
148,
149,
150,
151,
152,
153,
154,
155,
156,
157,
158,
159,
160,
161,
162,
163,
164,
165,
166,
167,
168,
169,
170,
173,
174,
175,
176,
177,
178,
179,
180,
181,
182,
183,
184,
185,
186,
187,
188,
189,
190,
191,
192,
193,
194,
195,
196,
209,
227,
130,
158,
269,
]
class SSPlatform(TypedDict):
slug: str
ss_id: int | None
name: NotRequired[str]
class SSAgeRating(TypedDict):
rating: str
category: str
rating_cover_url: str
class SSMetadata(TypedDict):
ss_score: str
first_release_date: int | None
alternative_names: list[str]
companies: list[str]
franchises: list[str]
game_modes: list[str]
genres: list[str]
class SSRom(TypedDict):
ss_id: int | None
slug: NotRequired[str]
name: NotRequired[str]
summary: NotRequired[str]
url_cover: NotRequired[str]
url_manual: NotRequired[str]
url_screenshots: NotRequired[list[str]]
ss_metadata: NotRequired[SSMetadata]
def extract_metadata_from_ss_rom(rom: dict) -> SSMetadata:
def _normalize_score(score: str) -> str:
"""Normalize the score to be between 0 and 10 because for some reason Screenscraper likes to rate over 20."""
try:
return str(int(score) / 2)
except (ValueError, TypeError):
return ""
def _get_lowest_date(dates: list[str]) -> int | None:
lowest_date = pydash.chain(dates).map("text").sort().head().value()
if lowest_date:
try:
lowest_date = int(
datetime.strptime(lowest_date, "%Y-%m-%d").timestamp()
)
except ValueError:
try:
lowest_date = int(datetime.strptime(lowest_date, "%Y").timestamp())
except ValueError:
lowest_date = None
else:
lowest_date = None
return lowest_date
return SSMetadata(
{
"ss_score": _normalize_score(pydash.get(rom, "note.text", None)),
"alternative_names": pydash.map_(rom.get("noms", []), "text"),
"companies": [
pydash.get(rom, "editeur.text", None),
pydash.get(rom, "developpeur.text", None),
],
"genres": pydash.chain(rom.get("genres", []))
.map("noms")
.flatten()
.filter({"langue": "en"})
.map("text")
.value(),
"first_release_date": _get_lowest_date(rom.get("dates", [])),
"franchises": pydash.chain(rom.get("familles", []))
.map("noms")
.flatten()
.filter({"langue": "en"})
.map("text")
.value()
or pydash.chain(rom.get("familles", []))
.map("noms")
.flatten()
.filter({"langue": "fr"})
.map("text")
.value(),
"game_modes": pydash.chain(rom.get("modes", []))
.map("noms")
.flatten()
.filter({"langue": "en"})
.map("text")
.value()
or pydash.chain(rom.get("modes", []))
.map("noms")
.flatten()
.filter({"langue": "fr"})
.map("text")
.value(),
}
)
class SSHandler(MetadataHandler):
def __init__(self) -> None:
self.BASE_URL = "https://api.screenscraper.fr/api2"
self.search_endpoint = f"{self.BASE_URL}/jeuRecherche.php"
self.platform_endpoint = f"{self.BASE_URL}/systemesListe.php"
self.games_endpoint = f"{self.BASE_URL}/jeuInfos.php"
self.LOGIN_ERROR_CHECK: Final = "Erreur de login"
self.NO_GAME_ERROR: Final = "Erreur : Jeu non trouvée !"
async def _request(self, url: str, timeout: int = 120) -> dict:
httpx_client = ctx_httpx_client.get()
authorized_url = yarl.URL(url).update_query(
ssid=SCREENSCRAPER_USER,
sspassword=SCREENSCRAPER_PASSWORD,
devid=SS_DEV_ID,
devpassword=SS_DEV_PASSWORD,
softname="romm",
output="json",
)
masked_url = authorized_url.with_query(
self._mask_sensitive_values(dict(authorized_url.query))
)
log.debug(
"API request: URL=%s, Timeout=%s",
masked_url,
timeout,
)
try:
res = await httpx_client.get(str(authorized_url), timeout=timeout)
res.raise_for_status()
if self.LOGIN_ERROR_CHECK in res.text:
log.error("Invalid screenscraper credentials")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid screenscraper credentials",
)
elif self.NO_GAME_ERROR in res.text:
return {}
return res.json()
except httpx.NetworkError as exc:
log.critical(
"Connection error: can't connect to Screenscrapper", exc_info=True
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Can't connect to Screenscrapper, check your internet connection",
) from exc
except httpx.HTTPStatusError as err:
if err.response.status_code == http.HTTPStatus.UNAUTHORIZED:
# Sometimes Screenscrapper returns 401 even with a valid API key
log.error(err)
return {}
elif err.response.status_code == http.HTTPStatus.TOO_MANY_REQUESTS:
# Retry after 2 seconds if rate limit hit
await asyncio.sleep(2)
else:
# Log the error and return an empty dict if the request fails with a different code
log.error(err)
return {}
except httpx.TimeoutException:
log.debug(
"Request to URL=%s timed out. Retrying with URL=%s", masked_url, url
)
# Retry the request once if it times out
try:
log.debug(
"API request: URL=%s, Timeout=%s",
url,
timeout,
)
res = await httpx_client.get(url, timeout=timeout)
res.raise_for_status()
if self.LOGIN_ERROR_CHECK in res.text:
log.error("Invalid screenscraper credentials")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid screenscraper credentials",
)
elif self.NO_GAME_ERROR in res.text:
return {}
except (httpx.HTTPStatusError, httpx.TimeoutException) as err:
# Log the error and return an empty dict if the request fails with a different code
log.error(err)
return {}
return res.json()
async def _search_rom(self, search_term: str, platform_ss_id: int) -> dict | None:
if not platform_ss_id:
return None
search_term = uc(search_term)
url = yarl.URL(self.search_endpoint).with_query(
systemeid=[platform_ss_id],
recherche=quote(search_term, safe="/ "),
)
found_roms = (await self._request(str(url))).get("response", {}).get("jeux", [])
# If no roms are return, "jeux" is list with an empty dict that can lead to issues. It needs to be checked.
roms = [] if len(found_roms) == 1 and not found_roms[0] else found_roms
return pydash.get(roms, "[0]", None)
def get_platform(self, slug: str) -> SSPlatform:
platform = SLUG_TO_SS_ID.get(slug, None)
if not platform:
return SSPlatform(ss_id=None, slug=slug)
return SSPlatform(
ss_id=platform["id"],
slug=slug,
name=platform["name"],
)
async def get_rom(self, file_name: str, platform_ss_id: int) -> SSRom:
from handler.filesystem import fs_rom_handler
if not SS_API_ENABLED:
return SSRom(ss_id=None)
if not platform_ss_id:
return SSRom(ss_id=None)
search_term = fs_rom_handler.get_file_name_with_no_tags(file_name)
fallback_rom = SSRom(ss_id=None)
# Support for PS2 OPL filename format
match = PS2_OPL_REGEX.match(file_name)
if platform_ss_id == PS2_SS_ID and match:
search_term = await self._ps2_opl_format(match, search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
# Support for sony serial filename format (PS, PS3, PS3)
match = SONY_SERIAL_REGEX.search(file_name, re.IGNORECASE)
if platform_ss_id == PS1_SS_ID and match:
search_term = await self._ps1_serial_format(match, search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
if platform_ss_id == PS2_SS_ID and match:
search_term = await self._ps2_serial_format(match, search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
if platform_ss_id == PSP_SS_ID and match:
search_term = await self._psp_serial_format(match, search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
# Support for switch titleID filename format
match = SWITCH_TITLEDB_REGEX.search(file_name)
if platform_ss_id == SWITCH_SS_ID and match:
search_term, index_entry = await self._switch_titledb_format(
match, search_term
)
if index_entry:
fallback_rom = SSRom(
ss_id=None,
name=index_entry["name"],
summary=index_entry.get("description", ""),
url_cover=index_entry.get("iconUrl", ""),
url_manual=index_entry.get("iconUrl", ""),
url_screenshots=index_entry.get("screenshots", None) or [],
)
# Support for switch productID filename format
match = SWITCH_PRODUCT_ID_REGEX.search(file_name)
if platform_ss_id == SWITCH_SS_ID and match:
search_term, index_entry = await self._switch_productid_format(
match, search_term
)
if index_entry:
fallback_rom = SSRom(
ss_id=None,
name=index_entry["name"],
summary=index_entry.get("description", ""),
url_cover=index_entry.get("iconUrl", ""),
url_manual=index_entry.get("iconUrl", ""),
url_screenshots=index_entry.get("screenshots", None) or [],
)
# Support for MAME arcade filename format
if platform_ss_id in ARCADE_SS_IDS:
search_term = await self._mame_format(search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
search_term = self.normalize_search_term(search_term)
res = await self._search_rom(search_term, platform_ss_id)
# Some MAME games have two titles split by a slash
if not res and "/" in search_term:
for term in search_term.split("/"):
res = await self._search_rom(term.strip(), platform_ss_id)
if res:
break
if not res or not res.get("id", None):
return fallback_rom
ss_id: int = int(res.get("id", None))
rom = {
"ss_id": ss_id,
"name": pydash.chain(res.get("noms", []))
.filter({"region": "ss"})
.map("text")
.head()
.value(),
"slug": pydash.chain(res.get("noms", []))
.filter({"region": "ss"})
.map("text")
.head()
.value(),
"summary": pydash.chain(res.get("synopsis", []))
.filter({"langue": "en"})
.map("text")
.head()
.value(),
"url_cover": pydash.chain(res.get("medias", []))
.filter({"region": "us", "type": "box-2D", "parent": "jeu"})
.map("url")
.head()
.value()
or "",
"url_manual": pydash.chain(res.get("medias", []))
.filter(
{"region": "us", "type": "manuel", "parent": "jeu", "format": "pdf"}
)
.map("url")
.head()
.value()
or pydash.chain(res.get("medias", []))
.filter(
{"region": "eu", "type": "manuel", "parent": "jeu", "format": "pdf"}
)
.map("url")
.head()
.value()
or "",
"url_screenshots": [],
"ss_metadata": extract_metadata_from_ss_rom(res),
}
return SSRom({k: v for k, v in rom.items() if v}) # type: ignore[misc]
async def get_rom_by_id(self, ss_id: int) -> SSRom:
if not SS_API_ENABLED:
return SSRom(ss_id=None)
url = yarl.URL(self.games_endpoint).with_query(gameid=ss_id)
res = (await self._request(str(url))).get("response", {}).get("jeu", [])
if not res:
return SSRom(ss_id=None)
rom = {
"ss_id": res.get("id"),
"name": pydash.chain(res.get("noms", []))
.filter({"region": "ss"})
.map("text")
.head()
.value(),
"slug": pydash.chain(res.get("noms", []))
.filter({"region": "ss"})
.map("text")
.head()
.value(),
"summary": pydash.chain(res.get("synopsis", []))
.filter({"langue": "en"})
.map("text")
.head()
.value(),
"url_cover": pydash.chain(res.get("medias", []))
.filter({"region": "us", "type": "box-2D", "parent": "jeu"})
.map("url")
.head()
.value()
or "",
"url_manual": pydash.chain(res.get("medias", []))
.filter(
{"region": "us", "type": "manuel", "parent": "jeu", "format": "pdf"}
)
.map("url")
.head()
.value()
or pydash.chain(res.get("medias", []))
.filter(
{"region": "eu", "type": "manuel", "parent": "jeu", "format": "pdf"}
)
.map("url")
.head()
.value()
or "",
"url_screenshots": [],
"ss_metadata": extract_metadata_from_ss_rom(res),
}
return SSRom({k: v for k, v in rom.items() if v}) # type: ignore[misc]
async def get_matched_rom_by_id(self, ss_id: int) -> SSRom | None:
if not SS_API_ENABLED:
return None
rom = await self.get_rom_by_id(ss_id)
return rom if rom.get("ss_id", "") else None
async def get_matched_roms_by_name(
self, search_term: str, platform_ss_id: int
) -> list[SSRom]:
if not SS_API_ENABLED:
return []
if not platform_ss_id:
return []
search_term = uc(search_term)
url = yarl.URL(self.search_endpoint).with_query(
systemeid=[platform_ss_id],
recherche=quote(search_term, safe="/ "),
)
roms = (await self._request(str(url))).get("response", {}).get("jeux", [])
# If no roms are return, "jeux" is list with an empty dict that can lead to issues. It needs to be checked.
matched_roms = [] if len(roms) == 1 and not roms[0] else roms
return [
SSRom( # type: ignore[misc]
{
k: v
for k, v in {
"ss_id": rom.get("id"),
"name": pydash.chain(rom.get("noms", []))
.filter({"region": "ss"})
.map("text")
.head()
.value(),
"slug": pydash.chain(rom.get("noms", []))
.filter({"region": "ss"})
.map("text")
.head()
.value(),
"summary": pydash.chain(rom.get("synopsis", []))
.filter({"langue": "en"})
.map("text")
.head()
.value(),
"url_cover": pydash.chain(rom.get("medias", []))
.filter({"region": "us", "type": "box-2D", "parent": "jeu"})
.map("url")
.head()
.value()
or "",
"url_manual": pydash.chain(rom.get("medias", []))
.filter(
{
"region": "us",
"type": "manuel",
"parent": "jeu",
"format": "pdf",
}
)
.map("url")
.head()
.value()
or pydash.chain(rom.get("medias", []))
.filter(
{
"region": "eu",
"type": "manuel",
"parent": "jeu",
"format": "pdf",
}
)
.map("url")
.head()
.value()
or "",
"url_screenshots": [],
"ss_metadata": extract_metadata_from_ss_rom(rom),
}.items()
if v
and pydash.chain(rom.get("noms", []))
.filter({"region": "ss"})
.map("text")
.head()
.value()
and rom.get("id", None)
}
)
for rom in matched_roms
]
class SlugToSSId(TypedDict):
id: int
name: str
SLUG_TO_SS_ID: dict[str, SlugToSSId] = {
"3do": {"id": 29, "name": "3DO"},
"amiga": {"id": 64, "name": "Amiga"},
"amiga-cd32": {"id": 134, "name": "Amiga CD"},
"cpc": {"id": 60, "name": "CPC"},
"acpc": {"id": 60, "name": "CPC"}, # IGDB
"android": {"id": 63, "name": "Android"},
"apple2": {"id": 86, "name": "Apple II"},
"appleii": {"id": 86, "name": "Apple II"}, # IGDB
"apple2gs": {"id": 217, "name": "Apple IIGS"},
"apple-iigs": {"id": 51, "name": "Apple IIGS"}, # IGDB
"arcadia-2001": {"id": 94, "name": "Arcadia 2001"},
"arduboy": {"id": 263, "name": "Arduboy"},
"atari-2600": {"id": 26, "name": "Atari 2600"},
"atari2600": {"id": 26, "name": "Atari 2600"}, # IGDB
"atari-5200": {"id": 40, "name": "Atari 5200"},
"atari5200": {"id": 40, "name": "Atari 5200"}, # IGDB
"atari-7800": {"id": 41, "name": "Atari 7800"},
"atari7800": {"id": 41, "name": "Atari 7800"}, # IGDB
"atari-8-bit": {"id": 43, "name": "Atari 8bit"},
"atari8bit": {"id": 43, "name": "Atari 8bit"}, # IGDB
"atari-st": {"id": 42, "name": "Atari ST"},
"atom": {"id": 36, "name": "Atom"},
"bbc-micro": {"id": 37, "name": "BBC Micro"},
"bbcmicro": {"id": 37, "name": "BBC Micro"}, # IGDB
"bally-astrocade": {"id": 44, "name": "Astrocade"},
"astrocade": {"id": 44, "name": "Astrocade"}, # IGDB
"cd-i": {"id": 133, "name": "CD-i"},
"philips-cd-i": {"id": 133, "name": "CD-i"}, # IGDB
"cdtv": {"id": 129, "name": "Amiga CDTV"},
"commodore-cdtv": {"id": 129, "name": "Amiga CDTV"}, # IGDB
"camputers-lynx": {"id": 88, "name": "Camputers Lynx"},
"casio-loopy": {"id": 98, "name": "Loopy"},
"casio-pv-1000": {"id": 74, "name": "PV-1000"},
"channel-f": {"id": 80, "name": "Channel F"},
"fairchild-channel-f": {"id": 80, "name": "Channel F"}, # IGDB
"colecoadam": {"id": 89, "name": "Adam"},
"colecovision": {"id": 48, "name": "Colecovision"},
"colour-genie": {"id": 92, "name": "EG2000 Colour Genie"},
"c128": {"id": 66, "name": "Commodore 64"},
"commodore-16-plus4": {"id": 99, "name": "Plus/4"},
"c-plus-4": {"id": 99, "name": "Plus/4"}, # IGDB
"c16": {"id": 99, "name": "Plus/4"}, # IGDB
"c64": {"id": 66, "name": "Commodore 64"},
"pet": {"id": 240, "name": "PET"},
"cpet": {"id": 240, "name": "PET"}, # IGDB
"creativision": {"id": 241, "name": "CreatiVision"},
"dos": {"id": 135, "name": "PC Dos"},
"dragon-3264": {"id": 91, "name": "Dragon 32/64"},
"dragon-32-slash-64": {"id": 91, "name": "Dragon 32/64"}, # IGDB
"dreamcast": {"id": 23, "name": "Dreamcast"},
"dc": {"id": 23, "name": "Dreamcast"}, # IGDB
"electron": {"id": 85, "name": "Electron"},
"acorn-electron": {"id": 85, "name": "Electron"}, # IGDB
"epoch-game-pocket-computer": {"id": 95, "name": "Game Pocket Computer"},
"epoch-super-cassette-vision": {"id": 67, "name": "Super Cassette Vision"},
"exelvision": {"id": 96, "name": "EXL 100"},
"exidy-sorcerer": {"id": 165, "name": "Exidy"},
"fmtowns": {"id": 253, "name": "FM Towns"},
"fm-towns": {"id": 253, "name": "FM Towns"}, # IGDB
"fm-7": {"id": 97, "name": "FM-7"},
"g-and-w": {"id": 52, "name": "Game & Watch"}, # IGDB (Game & Watch)
"gp32": {"id": 101, "name": "GP32"},
"gameboy": {"id": 9, "name": "Game Boy"},
"gb": {"id": 9, "name": "Game Boy"}, # IGDB
"gameboy-advance": {"id": 12, "name": "Game Boy Advance"},
"gba": {"id": 12, "name": "Game Boy Advance"}, # IGDB
"gameboy-color": {"id": 10, "name": "Game Boy Color"},
"gbc": {"id": 10, "name": "Game Boy Color"}, # IGDB
"game-gear": {"id": 21, "name": "Game Gear"},
"gamegear": {"id": 21, "name": "Game Gear"}, # IGDB
"game-com": {"id": 121, "name": "Game.com"},
"game-dot-com": {"id": 121, "name": "Game.com"}, # IGDB
"gamecube": {"id": 13, "name": "GameCube"},
"ngc": {"id": 13, "name": "GameCube"}, # IGDB
"genesis": {"id": 1, "name": "Megadrive"},
"genesis-slash-megadrive": {"id": 1, "name": "Megadrive"},
"intellivision": {"id": 115, "name": "Intellivision"},
"jaguar": {"id": 27, "name": "Jaguar"},
"jupiter-ace": {"id": 126, "name": "Jupiter Ace"},
"linux": {"id": 145, "name": "Linux"},
"lynx": {"id": 28, "name": "Lynx"},
"msx": {"id": 113, "name": "MSX"},
"macintosh": {"id": 146, "name": "Mac OS"},
"mac": {"id": 146, "name": "Mac OS"}, # IGDB
"ngage": {"id": 30, "name": "N-Gage"},
"nes": {"id": 3, "name": "NES"},
"famicom": {"id": 3, "name": "NES"},
"neo-geo": {"id": 142, "name": "Neo-Geo"},
"neogeoaes": {"id": 142, "name": "Neo-Geo"}, # IGDB
"neogeomvs": {"id": 68, "name": "Neo-Geo MVS"}, # IGDB
"neo-geo-cd": {"id": 70, "name": "Neo-Geo CD"},
"neo-geo-pocket": {"id": 25, "name": "Neo-Geo Pocket"},
"neo-geo-pocket-color": {"id": 82, "name": "Neo-Geo Pocket Color"},
"3ds": {"id": 17, "name": "Nintendo 3DS"},
"n64": {"id": 14, "name": "Nintendo 64"},
"nintendo-ds": {"id": 15, "name": "Nintendo DS"},
"nds": {"id": 15, "name": "Nintendo DS"}, # IGDB
"nintendo-dsi": {"id": 15, "name": "Nintendo DS"},
"switch": {"id": 225, "name": "Switch"},
"odyssey-2": {"id": 104, "name": "Videopac G7000"},
"odyssey-2-slash-videopac-g7000": {"id": 104, "name": "Videopac G7000"},
"oric": {"id": 131, "name": "Oric 1 / Atmos"},
"pc88": {"id": 221, "name": "NEC PC-8801"},
"pc-8800-series": {"id": 221, "name": "NEC PC-8801"}, # IGDB
"pc98": {"id": 208, "name": "NEC PC-9801"},
"pc-9800-series": {"id": 208, "name": "NEC PC-9801"}, # IGDB
"pc-fx": {"id": 72, "name": "PC-FX"},
"pico": {"id": 234, "name": "Pico-8"},
"ps-vita": {"id": 62, "name": "PS Vita"},
"psvita": {"id": 62, "name": "PS Vita"}, # IGDB
"psp": {"id": 61, "name": "PSP"},
"palmos": {"id": 219, "name": "Palm OS"},
"palm-os": {"id": 219, "name": "Palm OS"}, # IGDB
"philips-vg-5000": {"id": 261, "name": "Philips VG 5000"},
"playstation": {"id": 57, "name": "Playstation"},
"ps": {"id": 57, "name": "Playstation"}, # IGDB
"ps2": {"id": 58, "name": "Playstation 2"},
"ps3": {"id": 59, "name": "Playstation 3"},
"playstation-4": {"id": 60, "name": "Playstation 4"},
"ps4--1": {"id": 60, "name": "Playstation 4"}, # IGDB
"playstation-5": {"id": 284, "name": "Playstation 5"},
"ps5": {"id": 284, "name": "Playstation 5"}, # IGDB
"pokemon-mini": {"id": 211, "name": "Pokémon mini"},
"sam-coupe": {"id": 213, "name": "MGT SAM Coupé"},
"sega-32x": {"id": 19, "name": "Megadrive 32X"},
"sega32": {"id": 19, "name": "Megadrive 32X"}, # IGDB
"sega-cd": {"id": 20, "name": "Mega-CD"},
"segacd": {"id": 20, "name": "Mega-CD"}, # IGDB
"sega-master-system": {"id": 2, "name": "Master System"},
"sms": {"id": 2, "name": "Master System"}, # IGDB
"sega-pico": {"id": 250, "name": "Sega Pico"},
"sega-saturn": {"id": 22, "name": "Saturn"},
"saturn": {"id": 22, "name": "Saturn"}, # IGDB
"sg-1000": {"id": 109, "name": "SG-1000"},
"snes": {"id": 4, "name": "Super Nintendo"},
"sharp-x1": {"id": 220, "name": "Sharp X1"},
"x1": {"id": 220, "name": "Sharp X1"}, # IGDB
"sharp-x68000": {"id": 79, "name": "Sharp X68000"},
"spectravideo": {"id": 218, "name": "Spectravideo"},
"super-acan": {"id": 100, "name": "Super A'can"},
"supergrafx": {"id": 105, "name": "PC Engine SuperGrafx"},
"supervision": {"id": 207, "name": "Watara Supervision"},
"ti-99": {"id": 205, "name": "TI-99/4A"}, # IGDB
"trs-80-coco": {"id": 144, "name": "TRS-80 Color Computer"},
"trs-80-color-computer": {"id": 144, "name": "TRS-80 Color Computer"}, # IGDB
"taito-x-55": {"id": 112, "name": "Type X"},
"thomson-mo": {"id": 141, "name": "Thomson MO/TO"},
"thomson-mo5": {"id": 141, "name": "Thomson MO/TO"},
"thomson-to": {"id": 141, "name": "Thomson MO/TO"},
"turbografx-cd": {"id": 114, "name": "PC Engine CD-Rom"},
"turbografx-16-slash-pc-engine-cd": {"id": 114, "name": "PC Engine CD-Rom"},
"turbo-grafx": {"id": 31, "name": "PC Engine"},
"turbografx16--1": {"id": 31, "name": "PC Engine"}, # IGDB
"vsmile": {"id": 120, "name": "V.Smile"},
"vic-20": {"id": 73, "name": "Vic-20"},
"vectrex": {"id": 102, "name": "Vectrex"},
"videopac-g7400": {"id": 104, "name": "Videopac G7000"},
"virtual-boy": {"id": 11, "name": "Virtual Boy"},
"virtualboy": {"id": 11, "name": "Virtual Boy"},
"wii": {"id": 18, "name": "Wii"},
"wii-u": {"id": 18, "name": "Wii U"},
"wiiu": {"id": 18, "name": "Wii U"},
"windows": {"id": 3, "name": "Windows"},
"win": {"id": 138, "name": "PC Windows"}, # IGDB
"win3x": {"id": 136, "name": "PC Win3.xx"},
"wonderswan": {"id": 45, "name": "WonderSwan"},
"wonderswan-color": {"id": 46, "name": "WonderSwan Color"},
"xbox": {"id": 32, "name": "Xbox"},
"xbox360": {"id": 33, "name": "Xbox 360"},
"xbox-one": {"id": 34, "name": "Xbox One"},
"xboxone": {"id": 34, "name": "Xbox One"},
"z-machine": {"id": 215, "name": "Z-Machine"},
"zx-spectrum": {"id": 76, "name": "ZX Spectrum"},
"zx81": {"id": 77, "name": "ZX81"},
"sinclair-zx81": {"id": 77, "name": "ZX81"}, # IGDB
}
# Reverse lookup
SS_ID_TO_SLUG = {v["id"]: k for k, v in SLUG_TO_SS_ID.items()}

View File

@@ -7,9 +7,10 @@ from config.config_manager import config_manager as cm
from handler.database import db_platform_handler
from handler.filesystem import fs_asset_handler, fs_firmware_handler, fs_rom_handler
from handler.filesystem.roms_handler import FSRom
from handler.metadata import meta_igdb_handler, meta_moby_handler
from handler.metadata import meta_igdb_handler, meta_moby_handler, meta_ss_handler
from handler.metadata.igdb_handler import IGDBPlatform, IGDBRom
from handler.metadata.moby_handler import MobyGamesPlatform, MobyGamesRom
from handler.metadata.ss_handler import SSPlatform, SSRom
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
@@ -29,6 +30,12 @@ class ScanType(Enum):
HASHES = "hashes"
class MetadataSource:
IGDB = "igdb"
MOBY = "moby"
SS = "ss"
async def _get_main_platform_igdb_id(platform: Platform):
cnfg = cm.get_config()
@@ -64,7 +71,7 @@ async def scan_platform(
log.info(f"· {hl(fs_slug)}")
if metadata_sources is None:
metadata_sources = ["igdb", "moby"]
metadata_sources = [MetadataSource.IGDB, MetadataSource.MOBY, MetadataSource.SS]
platform_attrs: dict[str, Any] = {}
platform_attrs["fs_slug"] = fs_slug
@@ -99,19 +106,30 @@ async def scan_platform(
igdb_platform = (
(await meta_igdb_handler.get_platform(platform_attrs["slug"]))
if "igdb" in metadata_sources
if MetadataSource.IGDB in metadata_sources
else IGDBPlatform(igdb_id=None, slug=platform_attrs["slug"])
)
moby_platform = (
meta_moby_handler.get_platform(platform_attrs["slug"])
if "moby" in metadata_sources
if MetadataSource.MOBY in metadata_sources
else MobyGamesPlatform(moby_id=None, slug=platform_attrs["slug"])
)
ss_platform = (
meta_ss_handler.get_platform(platform_attrs["slug"])
if MetadataSource.SS in metadata_sources
else SSPlatform(ss_id=None, slug=platform_attrs["slug"])
)
platform_attrs["name"] = platform_attrs["slug"].replace("-", " ").title()
platform_attrs.update({**moby_platform, **igdb_platform}) # Reverse order
platform_attrs.update(
{**moby_platform, **ss_platform, **igdb_platform}
) # Reverse order
if platform_attrs["igdb_id"] or platform_attrs["moby_id"]:
if (
platform_attrs["igdb_id"]
or platform_attrs["moby_id"]
or platform_attrs["ss_id"]
):
log.info(
emoji.emojize(
f" Identified as {hl(platform_attrs['name'], color=BLUE)} :video_game:"
@@ -180,7 +198,7 @@ async def scan_rom(
metadata_sources: list[str] | None = None,
) -> Rom:
if not metadata_sources:
metadata_sources = ["igdb", "moby"]
metadata_sources = [MetadataSource.IGDB, MetadataSource.MOBY, MetadataSource.SS]
roms_path = fs_rom_handler.get_roms_fs_structure(platform.fs_slug)
@@ -198,6 +216,7 @@ async def scan_rom(
"platform_id": platform.id,
"name": fs_rom["fs_name"],
"url_cover": "",
"url_manual": "",
"url_screenshots": [],
}
@@ -207,6 +226,7 @@ async def scan_rom(
{
"igdb_id": rom.igdb_id,
"moby_id": rom.moby_id,
"ss_id": rom.ss_id,
"sgdb_id": rom.sgdb_id,
"name": rom.name,
"slug": rom.slug,
@@ -214,6 +234,7 @@ async def scan_rom(
"igdb_metadata": rom.igdb_metadata,
"moby_metadata": rom.moby_metadata,
"url_cover": rom.url_cover,
"url_manual": rom.url_manual,
"path_cover_s": rom.path_cover_s,
"path_cover_l": rom.path_cover_l,
"path_screenshots": rom.path_screenshots,
@@ -253,7 +274,7 @@ async def scan_rom(
async def fetch_igdb_rom():
if (
"igdb" in metadata_sources
MetadataSource.IGDB in metadata_sources
and platform.igdb_id
and (
not rom
@@ -271,7 +292,7 @@ async def scan_rom(
async def fetch_moby_rom():
if (
"moby" in metadata_sources
MetadataSource.MOBY in metadata_sources
and platform.moby_id
and (
not rom
@@ -286,16 +307,37 @@ async def scan_rom(
return MobyGamesRom(moby_id=None)
async def fetch_ss_rom():
if (
MetadataSource.SS in metadata_sources
and platform.ss_id
and (
not rom
or scan_type == ScanType.COMPLETE
or (scan_type == ScanType.PARTIAL and not rom.ss_id)
or (scan_type == ScanType.UNIDENTIFIED and not rom.ss_id)
)
):
return await meta_ss_handler.get_rom(
rom_attrs["fs_name"], platform_ss_id=platform.ss_id
)
return SSRom(ss_id=None)
# Run both metadata fetches concurrently
igdb_handler_rom, moby_handler_rom = await asyncio.gather(
fetch_igdb_rom(), fetch_moby_rom()
igdb_handler_rom, moby_handler_rom, ss_handler_rom = await asyncio.gather(
fetch_igdb_rom(), fetch_moby_rom(), fetch_ss_rom()
)
# Reversed to prioritize IGDB
rom_attrs.update({**moby_handler_rom, **igdb_handler_rom})
rom_attrs.update({**moby_handler_rom, **ss_handler_rom, **igdb_handler_rom})
# If not found in IGDB or MobyGames
if not igdb_handler_rom.get("igdb_id") and not moby_handler_rom.get("moby_id"):
# If not found in IGDB, MobyGames and Screenscraper
if (
not igdb_handler_rom.get("igdb_id")
and not moby_handler_rom.get("moby_id")
and not ss_handler_rom.get("ss_id")
):
log.warning(
emoji.emojize(
f"\t Rom {rom_attrs['fs_name']} not identified :cross_mark:"

View File

@@ -21,6 +21,7 @@ class Platform(BaseModel):
igdb_id: Mapped[int | None]
sgdb_id: Mapped[int | None]
moby_id: Mapped[int | None]
ss_id: Mapped[int | None]
slug: Mapped[str] = mapped_column(String(length=100))
fs_slug: Mapped[str] = mapped_column(String(length=100))
name: Mapped[str] = mapped_column(String(length=400))

View File

@@ -71,10 +71,12 @@ class Rom(BaseModel):
igdb_id: Mapped[int | None]
sgdb_id: Mapped[int | None]
moby_id: Mapped[int | None]
ss_id: Mapped[int | None]
__table_args__ = (
Index("idx_roms_igdb_id", "igdb_id"),
Index("idx_roms_moby_id", "moby_id"),
Index("idx_roms_ss_id", "ss_id"),
)
fs_name: Mapped[str] = mapped_column(String(length=450))
@@ -92,6 +94,9 @@ class Rom(BaseModel):
moby_metadata: Mapped[dict[str, Any] | None] = mapped_column(
CustomJSON(), default=dict
)
ss_metadata: Mapped[dict[str, Any] | None] = mapped_column(
CustomJSON(), default=dict
)
path_cover_s: Mapped[str | None] = mapped_column(Text, default="")
path_cover_l: Mapped[str | None] = mapped_column(Text, default="")
@@ -99,6 +104,11 @@ class Rom(BaseModel):
Text, default="", doc="URL to cover image stored in IGDB"
)
path_manual: Mapped[str | None] = mapped_column(Text, default="")
url_manual: Mapped[str | None] = mapped_column(
Text, default="", doc="URL to manual stored in ScreenScraper"
)
revision: Mapped[str | None] = mapped_column(String(length=100))
regions: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[])
languages: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[])
@@ -160,6 +170,10 @@ class Rom(BaseModel):
def full_path(self) -> str:
return f"{self.fs_path}/{self.fs_name}"
@cached_property
def has_manual(self) -> bool:
return bool(self.path_manual)
@cached_property
def merged_screenshots(self) -> list[str]:
screenshots = [s.download_path for s in self.screenshots]
@@ -213,6 +227,7 @@ class Rom(BaseModel):
return (
(self.igdb_metadata or {}).get("alternative_names", None)
or (self.moby_metadata or {}).get("alternate_titles", None)
or (self.ss_metadata or {}).get("alternative_names", None)
or []
)
@@ -220,6 +235,8 @@ class Rom(BaseModel):
def first_release_date(self) -> int | None:
if self.igdb_metadata:
return safe_int(self.igdb_metadata.get("first_release_date") or 0) * 1000
elif self.ss_metadata:
return safe_int(self.ss_metadata.get("first_release_date") or 0) * 1000
return None
@@ -235,8 +252,13 @@ class Rom(BaseModel):
if self.moby_metadata
else 0.0
)
ss_rating = (
safe_float(self.ss_metadata.get("ss_score") or 0)
if self.ss_metadata
else 0.0
)
ratings = [r for r in [igdb_rating, moby_rating * 10] if r != 0.0]
ratings = [r for r in [igdb_rating, moby_rating * 10, ss_rating] if r != 0.0]
return sum(ratings) / len([r for r in ratings if r]) if any(ratings) else None
@@ -245,6 +267,7 @@ class Rom(BaseModel):
return (
(self.igdb_metadata or {}).get("genres", None)
or (self.moby_metadata or {}).get("genres", None)
or (self.ss_metadata or {}).get("genres", None)
or []
)
@@ -252,6 +275,8 @@ class Rom(BaseModel):
def franchises(self) -> list[str]:
if self.igdb_metadata:
return self.igdb_metadata.get("franchises", [])
elif self.ss_metadata:
return self.ss_metadata.get("franchises", [])
return []
@property
@@ -264,12 +289,16 @@ class Rom(BaseModel):
def companies(self) -> list[str]:
if self.igdb_metadata:
return self.igdb_metadata.get("companies", [])
elif self.ss_metadata:
return self.ss_metadata.get("companies", [])
return []
@property
def game_modes(self) -> list[str]:
if self.igdb_metadata:
return self.igdb_metadata.get("game_modes", [])
elif self.ss_metadata:
return self.ss_metadata.get("game_modes", [])
return []
@property

View File

@@ -3,7 +3,8 @@ DEV_MODE=true
KIOSK_MODE=false
# Gunicorn (optional)
GUNICORN_WORKERS=4 # (2 × CPU cores) + 1
# Workers -> (2 × CPU cores) + 1
GUNICORN_WORKERS=4
# IGDB credentials
IGDB_CLIENT_ID=
@@ -12,6 +13,10 @@ IGDB_CLIENT_SECRET=
# Mobygames
MOBYGAMES_API_KEY=
# Screenscraper
SCREENSCRAPER_USER=
SCREENSCRAPER_PASSWORD=
# SteamGridDB
STEAMGRIDDB_API_KEY=

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="1024" height="1024" viewBox="0 0 270.933 270.933"><defs><clipPath id="a" clipPathUnits="userSpaceOnUse"><ellipse cx="511.875" cy="512.551" rx="255.946" ry="255.984" style="fill:none"/></clipPath><clipPath id="b" clipPathUnits="userSpaceOnUse"><ellipse cx="135.457" cy="135.471" rx="135.436" ry="135.456" style="fill:none"/></clipPath><clipPath id="c" clipPathUnits="userSpaceOnUse"><ellipse cx="511.875" cy="512.551" rx="255.946" ry="255.984" style="fill:none"/></clipPath></defs><path d="M255.928 256.564H767.82V768.54H255.928Z" class="background-light" clip-path="url(#a)" style="fill:#f0f0f0;fill-opacity:1;stroke-width:0" transform="translate(-135.407 -135.75)scale(.52916)"/><path d="m714.366 256.564-458.44 458.44v53.536H767.82V256.564Z" class="background-dark" clip-path="url(#a)" style="fill:#999;fill-opacity:1;stroke-width:0" transform="translate(-135.407 -135.75)scale(.52916)"/><g clip-path="url(#b)" style="display:inline"><path d="M601.91 768.54c-2.735-.066-43.37-47.717-83.736-79.261-2.784-2.176-6.65-5.2-8.593-6.721-3.886-3.045-10.784-7.06-11.479-7.461-1.19-.687-2.393-1.272-6.665-3.15-4.524-1.99-13.567-4.542-18.772-5.298-8.498-1.236-33.84-1.639-96.251-1.582l-120.486.11c-.016-82.184-.01-236.036-.013-305.108l88.831.232c39.029.101 40.847-.189 47.144-3.008 13.443-6.019 68.906-76.53 83.114-82.973 7.254-3.29 16.603-4.702 23.918-3.202 1.581.324 6.626 1.157 11.212 1.852 10.895 1.65 18.218 2.754 35.434 7.376 28.676 7.697 56.638 19.386 58.66 20.31 11.937 5.453 25.411 12.472 29.43 16.271 1.59 1.504 3.282 3.542 4.083 4.852 3.524 5.765 11.034 19.845 11.756 22.333 9.175 22.007 17.356 44.381 25.733 66.7 3.979 11.415 8.336 22.57 12.276 33.945 3.358 9.785 10.614 30.128 11.045 30.968 5.604 15.717 10.419 31.655 15.204 47.643 2.989 9.93 6.674 22.862 10.253 36.626 5.39 18.118 10.909 36.205 14.226 54.889.718 3.96 3.264 19.802 3.445 21.053.437 3.026 3.277 25.867 3.851 31.073 2.626 23.81 2.15 68.545-17.678 101.54-.1.167-2.65.031-2.707.032-9.042.065-31.907-.03-39.664-.043-15.94-.029-83.501.005-83.508.005z" class="logo-secondary" style="fill:#333;fill-opacity:1;stroke-width:0;stroke-dasharray:none" transform="translate(-135.407 -135.75)scale(.52916)"/><path d="M115.894 9.256c-7 3.173-37.415 41.18-43.992 44.125-3.331 1.492-4.294 1.645-24.947 1.591l-46.94-.189c0 36.55-.002 117.963.006 161.45l26.456-.023L204.26 38.36c-.75-1.387-1.84-3.095-2.388-3.99-.424-.694-1.18-1.633-2.021-2.428-2.126-2.01-9.256-5.725-15.573-8.61-10.144-4.633-20.204-7.68-31.041-10.748-9.11-2.446-17.249-3.626-18.752-3.903-2.413-.444-5.031-.84-5.932-.98-.641-.098-6.525-1.225-12.658 1.555z" class="logo-primary" style="fill:#666;fill-opacity:1;stroke-width:0"/></g><g clip-path="url(#c)" style="display:inline" transform="translate(-135.407 -135.75)scale(.52916)"><circle cx="530.532" cy="381.817" r="31.529" class="dot-light" style="fill:#f0f0f0;fill-opacity:1;stroke-width:0;stroke-linejoin:bevel;stroke-dasharray:none"/><circle cx="471.565" cy="440.348" r="31.529" class="dot-light" style="fill:#f0f0f0;fill-opacity:1;stroke-width:0;stroke-linejoin:bevel;stroke-dasharray:none"/><circle cx="590.601" cy="439.252" r="31.529" class="dot-dark" style="fill:#999;fill-opacity:1;stroke-width:0;stroke-linejoin:bevel;stroke-dasharray:none"/><circle cx="530.441" cy="499.948" r="31.529" class="dot-dark" style="fill:#999;fill-opacity:1;stroke-width:0;stroke-linejoin:bevel;stroke-dasharray:none"/></g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1" viewBox="0 0 50 24"><path fill="#305b79" d="M0 0h41v18H0z"/><path fill="#4787b4" d="M3 2h41v18H3z"/><path fill="#5fb4f0" d="M6 4h41v18H6z"/><path fill="#3a6e92" d="M9 6h41v18H9z"/></svg>

Before

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -24,6 +24,7 @@
"vue": "^3.4.27",
"vue-i18n": "^11.1.1",
"vue-router": "^4.3.2",
"vue3-pdf-app": "^1.0.3",
"vuetify": "^3.7.9"
},
"devDependencies": {
@@ -8303,6 +8304,14 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vue3-pdf-app": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/vue3-pdf-app/-/vue3-pdf-app-1.0.3.tgz",
"integrity": "sha512-qegWTIF4wYKiocZ3KreB70wRXhqSdXWbdERDyyKzT7d5PbjKbS9tD6vaKkCqh3PzTM84NyKPYrQ3iuwJb60YPQ==",
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vuetify": {
"version": "3.7.11",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.11.tgz",

View File

@@ -42,6 +42,7 @@
"vue": "^3.4.27",
"vue-i18n": "^11.1.1",
"vue-router": "^4.3.2",
"vue3-pdf-app": "^1.0.3",
"vuetify": "^3.7.9"
},
"devDependencies": {

View File

@@ -36,6 +36,7 @@ export type { RomFileSchema } from './models/RomFileSchema';
export type { RomIGDBMetadata } from './models/RomIGDBMetadata';
export type { RomMobyMetadata } from './models/RomMobyMetadata';
export type { RomSchema } from './models/RomSchema';
export type { RomSSMetadata } from './models/RomSSMetadata';
export type { RomUserSchema } from './models/RomUserSchema';
export type { RomUserStatus } from './models/RomUserStatus';
export type { SaveSchema } from './models/SaveSchema';

View File

@@ -7,6 +7,7 @@ import type { RomFileSchema } from './RomFileSchema';
import type { RomIGDBMetadata } from './RomIGDBMetadata';
import type { RomMobyMetadata } from './RomMobyMetadata';
import type { RomSchema } from './RomSchema';
import type { RomSSMetadata } from './RomSSMetadata';
import type { RomUserSchema } from './RomUserSchema';
import type { SaveSchema } from './SaveSchema';
import type { ScreenshotSchema } from './ScreenshotSchema';
@@ -17,6 +18,7 @@ export type DetailedRomSchema = {
igdb_id: (number | null);
sgdb_id: (number | null);
moby_id: (number | null);
ss_id: (number | null);
platform_id: number;
platform_slug: string;
platform_fs_slug: string;
@@ -44,9 +46,13 @@ export type DetailedRomSchema = {
age_ratings: Array<string>;
igdb_metadata: (RomIGDBMetadata | null);
moby_metadata: (RomMobyMetadata | null);
ss_metadata: (RomSSMetadata | null);
path_cover_small: (string | null);
path_cover_large: (string | null);
url_cover: (string | null);
has_manual: boolean;
path_manual: (string | null);
url_manual: (string | null);
is_unidentified: boolean;
revision: (string | null);
regions: Array<string>;

View File

@@ -6,6 +6,7 @@ export type MetadataSourcesDict = {
ANY_SOURCE_ENABLED: boolean;
IGDB_API_ENABLED: boolean;
MOBY_API_ENABLED: boolean;
SS_API_ENABLED: boolean;
STEAMGRIDDB_ENABLED: boolean;
};

View File

@@ -13,6 +13,7 @@ export type PlatformSchema = {
igdb_id?: (number | null);
sgdb_id?: (number | null);
moby_id?: (number | null);
ss_id?: (number | null);
category?: (string | null);
generation?: (number | null);
family_name?: (string | null);

View File

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type RomSSMetadata = {
ss_score?: string;
first_release_date?: (number | null);
alternative_names?: Array<string>;
companies?: Array<string>;
franchises?: Array<string>;
game_modes?: Array<string>;
genres?: Array<string>;
};

View File

@@ -5,11 +5,13 @@
import type { RomFileSchema } from './RomFileSchema';
import type { RomIGDBMetadata } from './RomIGDBMetadata';
import type { RomMobyMetadata } from './RomMobyMetadata';
import type { RomSSMetadata } from './RomSSMetadata';
export type RomSchema = {
id: number;
igdb_id: (number | null);
sgdb_id: (number | null);
moby_id: (number | null);
ss_id: (number | null);
platform_id: number;
platform_slug: string;
platform_fs_slug: string;
@@ -37,9 +39,13 @@ export type RomSchema = {
age_ratings: Array<string>;
igdb_metadata: (RomIGDBMetadata | null);
moby_metadata: (RomMobyMetadata | null);
ss_metadata: (RomSSMetadata | null);
path_cover_small: (string | null);
path_cover_large: (string | null);
url_cover: (string | null);
has_manual: boolean;
path_manual: (string | null);
url_manual: (string | null);
is_unidentified: boolean;
revision: (string | null);
regions: Array<string>;

View File

@@ -5,11 +5,13 @@
export type SearchRomSchema = {
igdb_id?: (number | null);
moby_id?: (number | null);
ss_id?: (number | null);
slug: string;
name: string;
summary: string;
igdb_url_cover?: string;
moby_url_cover?: string;
ss_url_cover?: string;
platform_id: number;
};

View File

@@ -6,12 +6,14 @@ import type { RomFileSchema } from './RomFileSchema';
import type { RomIGDBMetadata } from './RomIGDBMetadata';
import type { RomMobyMetadata } from './RomMobyMetadata';
import type { RomSchema } from './RomSchema';
import type { RomSSMetadata } from './RomSSMetadata';
import type { RomUserSchema } from './RomUserSchema';
export type SimpleRomSchema = {
id: number;
igdb_id: (number | null);
sgdb_id: (number | null);
moby_id: (number | null);
ss_id: (number | null);
platform_id: number;
platform_slug: string;
platform_fs_slug: string;
@@ -39,9 +41,13 @@ export type SimpleRomSchema = {
age_ratings: Array<string>;
igdb_metadata: (RomIGDBMetadata | null);
moby_metadata: (RomMobyMetadata | null);
ss_metadata: (RomSSMetadata | null);
path_cover_small: (string | null);
path_cover_large: (string | null);
url_cover: (string | null);
has_manual: boolean;
path_manual: (string | null);
url_manual: (string | null);
is_unidentified: boolean;
revision: (string | null);
regions: Array<string>;

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { useTheme, useDisplay } from "vuetify";
import type { DetailedRom } from "@/stores/roms";
import VuePdfApp from "vue3-pdf-app";
// Props
const props = defineProps<{ rom: DetailedRom }>();
const { xs } = useDisplay();
const theme = useTheme();
const pdfViewerConfig = {
sidebarToggle: "sidebarToggleId",
pageNumber: "pageNumberId",
numPages: "numPagesId",
zoomIn: "zoomInId",
zoomOut: "zoomOutId",
firstPage: "firstPageId",
previousPage: "previousPageId",
nextPage: "nextPageId",
lastPage: "lastPageId",
download: "downloadId",
};
</script>
<template>
<v-toolbar class="bg-toplayer px-2" density="compact" :elevation="0">
<button
class="pdfv-toolbar-btn"
:id="pdfViewerConfig.sidebarToggle"
type="button"
>
<v-icon>mdi-menu</v-icon>
</button>
<v-spacer />
<input
class="px-1"
style="width: 40px"
:id="pdfViewerConfig.pageNumber"
type="number"
/>
<span class="ml-2" :id="pdfViewerConfig.numPages"></span>
<button
class="pdfv-toolbar-btn"
:class="{ 'ml-8': !xs, 'ml-4': xs }"
:id="pdfViewerConfig.firstPage"
type="button"
>
<v-icon>mdi-page-first</v-icon>
</button>
<button
v-show="!xs"
class="pdfv-toolbar-btn"
:id="pdfViewerConfig.previousPage"
type="button"
>
<v-icon>mdi-chevron-left</v-icon>
</button>
<button
v-show="!xs"
class="pdfv-toolbar-btn"
:id="pdfViewerConfig.nextPage"
type="button"
>
<v-icon>mdi-chevron-right</v-icon>
</button>
<button
class="pdfv-toolbar-btn"
:id="pdfViewerConfig.lastPage"
type="button"
>
<v-icon>mdi-page-last</v-icon>
</button>
<button
class="pdfv-toolbar-btn"
:class="{ 'ml-8': !xs, 'ml-4': xs }"
:id="pdfViewerConfig.zoomIn"
type="button"
>
<v-icon>mdi-magnify-plus-outline</v-icon>
</button>
<button
class="pdfv-toolbar-btn"
:id="pdfViewerConfig.zoomOut"
type="button"
>
<v-icon>mdi-magnify-minus-outline</v-icon>
</button>
<v-spacer />
<button
class="pdfv-toolbar-btn"
:id="pdfViewerConfig.download"
type="button"
>
<v-icon>mdi-download</v-icon>
</button>
</v-toolbar>
<vue-pdf-app
:id-config="pdfViewerConfig"
:config="{ toolbar: false }"
:theme="theme.name.value == 'dark' ? 'dark' : 'light'"
style="height: 100dvh"
:pdf="`/assets/romm/resources/${rom.path_manual}`"
>
</vue-pdf-app>
</template>
<style scoped>
/* the vue-pdf-app needs to be styled because it only accepts basic html elements */
.pdf-app.light,
.pdf-app.dark {
--pdf-app-background-color: rgba(var(--v-theme-surface)) !important;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
text-align: right;
padding-left: 1px;
padding-right: 1px;
border: 1px solid rgba(var(--v-theme-secondary));
border-radius: 5px;
-webkit-transition: 0.5s;
transition: 0.5s;
outline: none;
}
.pdfv-toolbar-btn {
transition:
color 0.15s ease-in-out,
transform 0.1s ease-in-out background-color 0.15s linear;
border-radius: 5px;
padding: 6px;
}
.pdfv-toolbar-btn:hover {
color: rgba(var(--v-theme-primary));
background-color: rgba(var(--v-theme-surface));
}
.pdfv-toolbar-btn:active {
transform: translateY(1px) translateX(1px);
}
</style>

View File

@@ -139,7 +139,7 @@ const hasReleaseDate = Number(props.rom.first_release_date) > 0;
</v-row>
<v-row
v-if="rom.igdb_id || rom.moby_id"
v-if="rom.igdb_id || rom.moby_id || rom.ss_id"
class="text-white text-shadow mt-2"
:class="{ 'text-center': smAndDown }"
no-gutters
@@ -151,12 +151,14 @@ const hasReleaseDate = Number(props.rom.first_release_date) > 0;
:href="`https://www.igdb.com/games/${rom.slug}`"
target="_blank"
>
<v-chip size="x-small" @click.stop>
<span>IGDB</span>
<v-chip class="pl-0 mt-1" size="small" @click.stop>
<v-avatar class="mr-2" size="30" rounded="0">
<v-img src="/assets/scrappers/igdb.png" />
</v-avatar>
<span>{{ rom.igdb_id }}</span>
<v-divider class="mx-2 border-opacity-25" vertical />
<span>ID: {{ rom.igdb_id }}</span>
<v-divider class="mx-2 border-opacity-25" vertical />
<span>Rating: {{ rom.igdb_metadata?.total_rating }}</span>
<span>{{ rom.igdb_metadata?.total_rating }}</span>
<v-icon class="ml-1">mdi-star</v-icon>
</v-chip>
</a>
<a
@@ -166,12 +168,31 @@ const hasReleaseDate = Number(props.rom.first_release_date) > 0;
target="_blank"
:class="{ 'ml-1': rom.igdb_id }"
>
<v-chip size="x-small" @click.stop>
<span>Mobygames</span>
<v-chip class="pl-0 mt-1" size="small" @click.stop>
<v-avatar class="mr-2" size="30" rounded="0">
<v-img src="/assets/scrappers/moby.png" />
</v-avatar>
<span>{{ rom.moby_id }}</span>
<v-divider class="mx-2 border-opacity-25" vertical />
<span>ID: {{ rom.moby_id }}</span>
<span>{{ rom.moby_metadata?.moby_score }}</span>
<v-icon class="ml-1">mdi-star</v-icon>
</v-chip>
</a>
<a
v-if="rom.ss_id"
style="text-decoration: none; color: inherit"
:href="`https://www.screenscraper.fr/gameinfos.php?gameid=${rom.ss_id}`"
target="_blank"
:class="{ 'ml-1': rom.igdb_id || rom.moby_id }"
>
<v-chip class="pl-0 mt-1" size="small" @click.stop>
<v-avatar class="mr-2" size="30" rounded="0">
<v-img src="/assets/scrappers/ss.png" />
</v-avatar>
<span>{{ rom.ss_id }}</span>
<v-divider class="mx-2 border-opacity-25" vertical />
<span>Rating: {{ rom.moby_metadata?.moby_score }}</span>
<span>{{ rom.ss_metadata?.ss_score }}</span>
<v-icon class="ml-1">mdi-star</v-icon>
</v-chip>
</a>
</v-col>

View File

@@ -195,7 +195,6 @@ async function updateCollection() {
:with-link="false"
:collection="currentCollection"
:src="imagePreviewUrl"
title-on-hover
>
<template v-if="isEditable" #append-inner>
<v-btn-group divided density="compact">
@@ -373,10 +372,10 @@ async function updateCollection() {
z-index: 1;
}
.drawer-desktop {
top: 54px !important;
top: 56px !important;
}
.drawer-mobile {
top: 114px !important;
top: 110px !important;
width: calc(100% - 16px) !important;
}
</style>

View File

@@ -277,31 +277,60 @@ watch(
</v-btn>
</div>
</div>
<div class="mt-4 text-center">
<a
v-if="currentPlatform.igdb_id"
style="text-decoration: none; color: inherit"
:href="currentPlatform.url ? currentPlatform.url : ''"
target="_blank"
>
<v-chip size="x-small" @click.stop>
<span>IGDB</span>
<v-divider class="mx-2 border-opacity-25" vertical />
<span>ID: {{ currentPlatform.igdb_id }}</span>
</v-chip>
</a>
<v-chip
size="x-small"
class="ml-1"
@click.stop
:class="{ 'ml-1': currentPlatform.igdb_id }"
v-if="currentPlatform.moby_id"
>
<span>Mobygames</span>
<v-divider class="mx-2 border-opacity-25" vertical />
<span>ID: {{ currentPlatform.moby_id }}</span>
</v-chip>
</div>
<v-row
v-if="
currentPlatform.igdb_id ||
currentPlatform.moby_id ||
currentPlatform.ss_id
"
class="text-white text-shadow mt-2 text-center"
no-gutters
>
<v-col cols="12">
<a
v-if="currentPlatform.igdb_id"
style="text-decoration: none; color: inherit"
:href="`https://www.igdb.com/platforms/${currentPlatform.slug}`"
target="_blank"
>
<v-chip class="pl-0 mt-1" size="small" @click.stop>
<v-avatar class="mr-2" size="30" rounded="0">
<v-img src="/assets/scrappers/igdb.png" />
</v-avatar>
<span>{{ currentPlatform.igdb_id }}</span>
</v-chip>
</a>
<a
v-if="currentPlatform.moby_id"
style="text-decoration: none; color: inherit"
target="_blank"
:class="{ 'ml-1': currentPlatform.igdb_id }"
>
<v-chip class="pl-0 mt-1" size="small" @click.stop>
<v-avatar class="mr-2" size="30" rounded="0">
<v-img src="/assets/scrappers/moby.png" />
</v-avatar>
<span>{{ currentPlatform.moby_id }}</span>
</v-chip>
</a>
<a
v-if="currentPlatform.ss_id"
style="text-decoration: none; color: inherit"
:href="`https://www.screenscraper.fr/systemeinfos.php?plateforme=${currentPlatform.ss_id}`"
target="_blank"
:class="{
'ml-1': currentPlatform.igdb_id || currentPlatform.moby_id,
}"
>
<v-chip class="pl-0 mt-1" size="small" @click.stop>
<v-avatar class="mr-2" size="30" rounded="0">
<v-img src="/assets/scrappers/ss.png" />
</v-avatar>
<span>{{ currentPlatform.ss_id }}</span>
</v-chip>
</a>
</v-col>
</v-row>
<v-card class="mt-4 bg-toplayer fill-width" elevation="0">
<v-card-text class="pa-4">
<template

View File

@@ -76,7 +76,7 @@ const computedAspectRatio = computed(() => {
return parseFloat(ratio.toString());
});
const fallbackCoverImage = computed(() =>
props.rom.igdb_id || props.rom.moby_id
props.rom.igdb_id || props.rom.moby_id || props.rom.ss_id
? getMissingCoverImage(props.rom.name || props.rom.slug || "")
: getUnmatchedCoverImage(props.rom.name || props.rom.slug || ""),
);
@@ -128,6 +128,7 @@ const fallbackCoverImage = computed(() =>
? rom.path_cover_large || fallbackCoverImage
: rom.igdb_url_cover ||
rom.moby_url_cover ||
rom.ss_url_cover ||
fallbackCoverImage)
"
:lazy-src="
@@ -136,6 +137,7 @@ const fallbackCoverImage = computed(() =>
? rom.path_cover_small || fallbackCoverImage
: rom.igdb_url_cover ||
rom.moby_url_cover ||
rom.ss_url_cover ||
fallbackCoverImage)
"
:aspect-ratio="computedAspectRatio"
@@ -146,10 +148,13 @@ const fallbackCoverImage = computed(() =>
<div
v-if="
isHovering ||
(romsStore.isSimpleRom(rom) && rom.is_unidentified) ||
(romsStore.isSimpleRom(rom) &&
rom.is_unidentified &&
!rom.path_cover_large) ||
(!romsStore.isSimpleRom(rom) &&
!rom.igdb_url_cover &&
!rom.moby_url_cover)
!rom.moby_url_cover &&
!rom.ss_url_cover)
"
class="translucent-dark text-caption text-white"
>

View File

@@ -14,7 +14,7 @@ defineProps<{ rom: SearchRomSchema }>();
open-delay="500"
><template #activator="{ props }">
<v-avatar v-bind="props" v-if="rom.igdb_id" size="30" rounded="1">
<v-img src="/assets/scrappers/igdb.png" /> </v-avatar></template
<v-img src="/assets/scrappers/igdb.png" /></v-avatar></template
></v-tooltip>
<v-tooltip
location="top"
@@ -30,7 +30,23 @@ defineProps<{ rom: SearchRomSchema }>();
size="30"
rounded="1"
>
<v-img src="/assets/scrappers/moby.png" /> </v-avatar></template
<v-img src="/assets/scrappers/moby.png" /></v-avatar></template
></v-tooltip>
<v-tooltip
location="top"
class="tooltip"
transition="fade-transition"
text="Screenscraper matched"
open-delay="500"
><template #activator="{ props }">
<v-avatar
v-bind="props"
v-if="rom.ss_id"
class="ml-1"
size="30"
rounded="1"
>
<v-img src="/assets/scrappers/ss.png" /></v-avatar></template
></v-tooltip>
</v-row>
</template>

View File

@@ -6,6 +6,7 @@ import storeGalleryView from "@/stores/galleryView";
import storeHeartbeat from "@/stores/heartbeat";
import storePlatforms from "@/stores/platforms";
import storeRoms, { type SimpleRom } from "@/stores/roms";
import storeUpload from "@/stores/upload";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { computed, inject, ref } from "vue";
@@ -24,8 +25,10 @@ const rom = ref<UpdateRom>();
const romsStore = storeRoms();
const imagePreviewUrl = ref<string | undefined>("");
const removeCover = ref(false);
const manualFiles = ref<File[]>([]);
const platfotmsStore = storePlatforms();
const galleryViewStore = storeGalleryView();
const uploadStore = storeUpload();
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showEditRomDialog", (romToEdit: UpdateRom | undefined) => {
show.value = true;
@@ -78,7 +81,7 @@ async function removeArtwork() {
}
const noMetadataMatch = computed(() => {
return !rom.value?.igdb_id && !rom.value?.moby_id && !rom.value?.sgdb_id;
return !rom.value?.igdb_id && !rom.value?.moby_id && !rom.value?.ss_id;
});
async function handleRomUpdate(
@@ -120,6 +123,51 @@ async function handleRomUpdate(
});
}
async function uploadManuals() {
if (!rom.value) return;
await romApi
.uploadManuals({
romId: rom.value.id,
filesToUpload: manualFiles.value,
})
.then((responses: PromiseSettledResult<unknown>[]) => {
const successfulUploads = responses.filter(
(d) => d.status == "fulfilled",
);
const failedUploads = responses.filter((d) => d.status == "rejected");
if (failedUploads.length == 0) {
uploadStore.reset();
}
if (successfulUploads.length == 0) {
return emitter?.emit("snackbarShow", {
msg: `All manuals skipped, nothing to upload.`,
icon: "mdi-close-circle",
color: "orange",
timeout: 5000,
});
}
emitter?.emit("snackbarShow", {
msg: `${successfulUploads.length} manuals uploaded successfully (and ${failedUploads.length} skipped/failed).`,
icon: "mdi-check-bold",
color: "green",
timeout: 3000,
});
})
.catch(({ response, message }) => {
emitter?.emit("snackbarShow", {
msg: `Unable to upload manuals: ${response?.data?.detail || response?.statusText || message}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
});
manualFiles.value = [];
}
async function unmatchRom() {
if (!rom.value) return;
await handleRomUpdate(
@@ -213,6 +261,54 @@ function closeDialog() {
/>
</v-col>
</v-row>
<v-row class="px-2 mt-2" no-gutters>
<v-col>
<v-chip
:variant="rom.has_manual ? 'flat' : 'tonal'"
label
size="large"
class="pr-0 bg-toplayer"
>
<span
: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
@click="triggerFileInput"
class="bg-toplayer ml-3"
icon="mdi-upload"
rounded="0"
size="small"
>
<v-icon size="large">mdi-upload</v-icon>
<v-file-input
id="file-input"
v-model="manualFiles"
accept="application/pdf"
hide-details
multiple
required
class="file-input"
@change="uploadManuals"
/>
</v-btn>
</v-chip>
<div v-if="rom.has_manual" class="mt-1">
<v-label class="text-caption text-wrap">
<v-icon size="small" class="mr-2 text-primary">
mdi-folder-file-outline
</v-icon>
<span> /romm/resources/{{ rom.path_manual }} </span>
</v-label>
</div>
</v-col>
</v-row>
</v-col>
<v-col cols="12" md="4" xl="3">
<v-row

View File

@@ -6,6 +6,7 @@ import romApi from "@/services/api/rom";
import storeGalleryView from "@/stores/galleryView";
import storeHeartbeat from "@/stores/heartbeat";
import storeRoms, { type SimpleRom } from "@/stores/roms";
import storePlatforms from "@/stores/platforms";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { computed, inject, onBeforeUnmount, ref } from "vue";
@@ -16,7 +17,7 @@ import { getMissingCoverImage } from "@/utils/covers";
type MatchedSource = {
url_cover: string | undefined;
name: "IGDB" | "Mobygames";
name: "IGDB" | "Mobygames" | "Screenscraper";
logo_path: string;
};
@@ -27,6 +28,7 @@ const show = ref(false);
const rom = ref<SimpleRom | null>(null);
const romsStore = storeRoms();
const galleryViewStore = storeGalleryView();
const platfotmsStore = storePlatforms();
const searching = ref(false);
const route = useRoute();
const searchTerm = ref("");
@@ -42,9 +44,17 @@ const sources = ref<MatchedSource[]>([]);
const heartbeat = storeHeartbeat();
const isIGDBFiltered = ref(true);
const isMobyFiltered = ref(true);
const isSSFiltered = ref(true);
const computedAspectRatio = computed(() => {
const ratio =
platfotmsStore.getAspectRatio(rom.value?.platform_id ?? -1) ||
galleryViewStore.defaultAspectRatioCover;
return parseFloat(ratio.toString());
});
emitter?.on("showMatchRomDialog", (romToSearch) => {
rom.value = romToSearch;
show.value = true;
matchedRoms.value = [];
// Use name as search term, only when it's matched
// Otherwise use the filename without tags and extensions
@@ -70,11 +80,17 @@ function toggleSourceFilter(source: MatchedSource["name"]) {
heartbeat.value.METADATA_SOURCES.MOBY_API_ENABLED
) {
isMobyFiltered.value = !isMobyFiltered.value;
} else if (
source == "Screenscraper" &&
heartbeat.value.METADATA_SOURCES.SS_API_ENABLED
) {
isSSFiltered.value = !isSSFiltered.value;
}
filteredMatchedRoms.value = matchedRoms.value.filter((rom) => {
if (
(rom.igdb_id && isIGDBFiltered.value) ||
(rom.moby_id && isMobyFiltered.value)
(rom.moby_id && isMobyFiltered.value) ||
(rom.ss_id && isSSFiltered.value)
) {
return true;
}
@@ -103,7 +119,8 @@ async function searchRom() {
filteredMatchedRoms.value = matchedRoms.value.filter((rom) => {
if (
(rom.igdb_id && isIGDBFiltered.value) ||
(rom.moby_id && isMobyFiltered.value)
(rom.moby_id && isMobyFiltered.value) ||
(rom.ss_id && isSSFiltered.value)
) {
return true;
}
@@ -131,16 +148,31 @@ function showSources(matchedRom: SearchRomSchema) {
}
showSelectSource.value = true;
selectedMatchRom.value = matchedRom;
sources.value.push({
url_cover: matchedRom.igdb_url_cover,
name: "IGDB",
logo_path: "/assets/scrappers/igdb.png",
});
sources.value.push({
url_cover: matchedRom.moby_url_cover,
name: "Mobygames",
logo_path: "/assets/scrappers/moby.png",
});
sources.value = [];
if (matchedRom.igdb_url_cover || matchedRom.igdb_id) {
sources.value.push({
url_cover: matchedRom.igdb_url_cover,
name: "IGDB",
logo_path: "/assets/scrappers/igdb.png",
});
}
if (matchedRom.moby_url_cover || matchedRom.moby_id) {
sources.value.push({
url_cover: matchedRom.moby_url_cover,
name: "Mobygames",
logo_path: "/assets/scrappers/moby.png",
});
}
if (matchedRom.ss_url_cover || matchedRom.ss_id) {
sources.value.push({
url_cover: matchedRom.ss_url_cover,
name: "Screenscraper",
logo_path: "/assets/scrappers/ss.png",
});
}
if (sources.value.length == 1) {
selectedCover.value = sources.value[0];
}
}
function selectCover(source: MatchedSource) {
@@ -213,7 +245,6 @@ function closeDialog() {
selectedCover.value = undefined;
selectedMatchRom.value = undefined;
renameAsSource.value = false;
matchedRoms.value = [];
}
onBeforeUnmount(() => {
@@ -291,6 +322,34 @@ onBeforeUnmount(() => {
>
<v-img src="/assets/scrappers/moby.png" /></v-avatar></template
></v-tooltip>
<v-tooltip
location="top"
class="tooltip"
transition="fade-transition"
:text="
heartbeat.value.METADATA_SOURCES.SS_API_ENABLED
? 'Filter Screenscraper matches'
: 'Screenscraper source is not enabled'
"
open-delay="500"
><template #activator="{ props }">
<v-avatar
@click="toggleSourceFilter('Screenscraper')"
v-bind="props"
class="ml-3 cursor-pointer opacity-40"
:class="{
'opacity-100':
isSSFiltered && heartbeat.value.METADATA_SOURCES.SS_API_ENABLED,
'cursor-not-allowed':
!heartbeat.value.METADATA_SOURCES.SS_API_ENABLED,
}"
size="30"
rounded="1"
>
<v-img src="/assets/scrappers/ss.png" />
</v-avatar>
</template>
</v-tooltip>
</template>
<template #toolbar>
<v-row class="align-center" no-gutters>
@@ -324,6 +383,7 @@ onBeforeUnmount(() => {
@click="searchRom()"
class="bg-toplayer"
variant="text"
rounded="0"
icon="mdi-search-web"
block
:disabled="searching"
@@ -371,7 +431,7 @@ onBeforeUnmount(() => {
</v-card-text>
</v-card>
</v-col>
<v-col cols="12">
<v-col v-if="sources.length > 1" cols="12">
<v-row no-gutters class="mt-4 justify-center text-center">
<v-col>
<span class="text-body-1">{{
@@ -397,7 +457,7 @@ onBeforeUnmount(() => {
>
<v-img
:src="source.url_cover || missingCoverImage"
:aspect-ratio="galleryViewStore.defaultAspectRatioCover"
:aspect-ratio="computedAspectRatio"
cover
lazy
>

View File

@@ -76,6 +76,7 @@ function clear() {
<template
v-if="showVirtualCollections && filteredVirtualCollections.length > 0"
>
<v-divider class="my-4 mx-4" />
<v-list-subheader class="uppercase">{{
t("common.virtual-collections").toUpperCase()
}}</v-list-subheader>

View File

@@ -57,7 +57,6 @@ async function logout() {
'mx-2': smAndDown || activeSettingsDrawer,
'my-2': !smAndDown || activeSettingsDrawer,
'drawer-mobile': smAndDown,
'drawer-desktop': !smAndDown,
}"
class="bg-surface pa-1"
style="height: unset"
@@ -115,26 +114,15 @@ async function logout() {
append-icon="mdi-security"
>{{ t("common.administration") }}
</v-list-item>
<template v-if="smAndDown && scopes.includes('me.write')">
<v-list-item
@click="logout"
append-icon="mdi-location-exit"
rounded
class="bg-toplayer border-sm text-romm-red border-romm-red mt-1"
>{{ t("common.logout") }}</v-list-item
>
</template>
</v-list>
<template v-if="!smAndDown && scopes.includes('me.write')" #append>
<v-list class="pa-0">
<v-list-item
@click="logout"
append-icon="mdi-location-exit"
rounded
class="bg-toplayer border-sm text-romm-red border-romm-red"
>{{ t("common.logout") }}</v-list-item
>
</v-list>
<template v-if="scopes.includes('me.write')" #append>
<v-btn
@click="logout"
append-icon="mdi-location-exit"
block
class="bg-toplayer text-romm-red"
>{{ t("common.logout") }}</v-btn
>
</template>
</v-navigation-drawer>
</template>

View File

@@ -30,12 +30,12 @@ watch(files, (newList) => {
absolute
:location="xs ? 'bottom' : 'bottom right'"
class="mb-4 mr-4"
color="tooltip"
color="toplayer"
>
<v-list>
<v-list class="bg-toplayer pa-0">
<v-list-item
v-for="file in files"
class="py-2 px-4"
class="py-2 px-4 bg-toplayer"
:disabled="file.finished && !file.failed"
>
<template v-if="file.failed">
@@ -43,7 +43,7 @@ watch(files, (newList) => {
{{ file.filename }}
<v-icon :icon="`mdi-close`" :color="`red`" class="mx-2" />
</v-list-item-title>
<v-list-item-subtitle class="text-red mt-1">
<v-list-item-subtitle v-if="file.failureReason" class="text-red mt-1">
{{ file.failureReason }}
</v-list-item-subtitle>
</template>
@@ -82,12 +82,12 @@ watch(files, (newList) => {
</template>
</v-list-item>
</v-list>
<div class="text-center">
<div class="bg-surface text-center">
<v-btn
size="small"
variant="tonal"
class="my-2"
color="primary"
variant="text"
:disabled="!files.some((f) => f.finished || f.failed)"
@click="clearFinished"
>

View File

@@ -30,6 +30,7 @@ const heartbeatStore = storeHeartbeat();
#container {
background-image: url("/assets/auth_background.svg");
background-size: cover;
background-position: center;
max-width: 100vw;
}
</style>

View File

@@ -28,6 +28,7 @@
"copy-link": "Download-Link kopieren",
"cant-copy-link": "Link kann nicht in Zwischenablage kopiert werden. Bitte manuell kopieren.",
"details": "Details",
"manual": "Benutzerhandbuch",
"personal": "Persönlich",
"version": "Version",
"default": "Standard",

View File

@@ -28,6 +28,7 @@
"copy-link": "Copy download link",
"cant-copy-link": "Can't copy link to clipboard, copy it manually",
"details": "Details",
"manual": "User manual",
"personal": "Personal",
"version": "Version",
"default": "Default",

View File

@@ -28,6 +28,7 @@
"copy-link": "Copy download link",
"cant-copy-link": "Can't copy link to clipboard, copy it manually",
"details": "Details",
"manual": "User manual",
"personal": "Personal",
"version": "Version",
"default": "Default",

View File

@@ -28,6 +28,7 @@
"copy-link": "Copiar link de descarga",
"cant-copy-link": "No se pudo copiar el link al portapapeles, copialo manualmente",
"details": "Detalles",
"manual": "Manual de usuario",
"personal": "Personal",
"version": "Versión",
"default": "Por defecto",

View File

@@ -28,6 +28,7 @@
"copy-link": "Copier le lien de téléchargement",
"cant-copy-link": "Impossible de copier le lien dans le presse-papiers, copiez-le manuellement",
"details": "Détails",
"manual": "Manuel utilisateur",
"personal": "Personnel",
"version": "Version",
"default": "Par défaut",

View File

@@ -28,6 +28,7 @@
"copy-link": "다운로드 링크 복사",
"cant-copy-link": "링크를 클립보드에 복사할 수 없습니다. 수동으로 복사합니다",
"details": "자세히",
"manual": "사용자 설명서",
"personal": "개인",
"version": "버전",
"default": "기본으로",

View File

@@ -28,6 +28,7 @@
"copy-link": "Copiar link de download",
"cant-copy-link": "Não é possível copiar o link para a área de transferência, copie manualmente",
"details": "Detalhes",
"manual": "Manual do usuário",
"personal": "Pessoal",
"version": "Versão",
"default": "Padrão",

View File

@@ -28,6 +28,7 @@
"copy-link": "Скопировать ссылку для скачивания",
"cant-copy-link": "Не удается скопировать ссылку в буфер обмена, скопируйте ее вручную",
"details": "Детали",
"manual": "Руководство пользователя",
"personal": "Личное",
"version": "Версия",
"default": "По умолчанию",

View File

@@ -29,6 +29,7 @@
"copy-link": "复制下载链接",
"cant-copy-link": "无法将链接复制到剪贴板,请手动复制",
"details": "详情",
"manual": "用户手册",
"personal": "私人",
"version": "版本",
"default": "默认",

View File

@@ -155,6 +155,7 @@ async function updateRom({
const formData = new FormData();
if (rom.igdb_id) formData.append("igdb_id", rom.igdb_id.toString());
if (rom.moby_id) formData.append("moby_id", rom.moby_id.toString());
if (rom.ss_id) formData.append("ss_id", rom.ss_id.toString());
formData.append("name", rom.name || "");
formData.append("fs_name", rom.fs_name);
formData.append("summary", rom.summary || "");
@@ -170,17 +171,44 @@ async function updateRom({
});
}
async function deleteRoms({
roms,
deleteFromFs = [],
async function uploadManuals({
romId,
filesToUpload,
}: {
roms: SimpleRom[];
deleteFromFs: number[];
}): Promise<{ data: MessageResponse }> {
return api.post("/roms/delete", {
roms: roms.map((r) => r.id),
delete_from_fs: deleteFromFs,
romId: number;
filesToUpload: File[];
}): Promise<PromiseSettledResult<unknown>[]> {
const heartbeat = storeHeartbeat();
const uploadStore = storeUpload();
console.log(filesToUpload);
const promises = filesToUpload.map((file) => {
const formData = new FormData();
formData.append(file.name, file);
uploadStore.start(file.name);
return new Promise((resolve, reject) => {
api
.post(`/roms/${romId}/manuals`, formData, {
headers: {
"Content-Type": "multipart/form-data",
"X-Upload-Filename": file.name,
},
timeout: heartbeat.value.FRONTEND.UPLOAD_TIMEOUT * 1000,
params: {},
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
uploadStore.update(file.name, progressEvent);
},
})
.then(resolve)
.catch((error) => {
uploadStore.fail(file.name, error.response?.data?.detail);
reject(error);
});
});
});
return Promise.allSettled(promises);
}
async function updateUserRomProps({
@@ -201,6 +229,19 @@ async function updateUserRomProps({
});
}
async function deleteRoms({
roms,
deleteFromFs = [],
}: {
roms: SimpleRom[];
deleteFromFs: number[];
}): Promise<{ data: MessageResponse }> {
return api.post("/roms/delete", {
roms: roms.map((r) => r.id),
delete_from_fs: deleteFromFs,
});
}
export default {
uploadRoms,
getRoms,
@@ -210,6 +251,7 @@ export default {
downloadRom,
searchRom,
updateRom,
deleteRoms,
uploadManuals,
updateUserRomProps,
deleteRoms,
};

View File

@@ -25,6 +25,11 @@ export default defineStore("heartbeat", {
value: "moby",
disabled: !this.value.METADATA_SOURCES?.MOBY_API_ENABLED,
},
{
name: "Screenscraper",
value: "ss",
disabled: !this.value.METADATA_SOURCES?.SS_API_ENABLED,
},
]).value.filter((s) => !s.disabled);
},
reset() {

View File

@@ -7,8 +7,8 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaSTbQWt4N.woff2)
format("woff2");
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F,
U+FE2E-FE2F;
unicode-range:
U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
@@ -41,8 +41,8 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaSTbQWt4N.woff2)
format("woff2");
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1,
U+03A3-03FF;
unicode-range:
U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
@@ -53,9 +53,10 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaSTbQWt4N.woff2)
format("woff2");
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315,
U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A,
U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6,
unicode-range:
U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A,
U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346,
U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6,
U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043,
U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C,
U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121,
@@ -75,20 +76,21 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaSTbQWt4N.woff2)
format("woff2");
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0,
U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0,
U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A,
U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB,
U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C,
U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3,
U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F,
U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D,
U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2,
U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3,
U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442,
U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3,
U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6,
U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB,
unicode-range:
U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4,
U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3,
U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF,
U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF,
U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0,
U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F,
U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315,
U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382,
U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6,
U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7,
U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444,
U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0,
U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA,
U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB,
U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513,
U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D,
U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC,
@@ -108,9 +110,10 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaSTbQWt4N.woff2)
format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169,
U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
U+0329, U+1EA0-1EF9, U+20AB;
unicode-range:
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
@@ -121,9 +124,10 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaSTbQWt4N.woff2)
format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
@@ -134,9 +138,10 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnkaSTbQWg.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
@@ -147,8 +152,8 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBHMdazTgWw.woff2)
format("woff2");
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F,
U+FE2E-FE2F;
unicode-range:
U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
@@ -181,8 +186,8 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBHMdazTgWw.woff2)
format("woff2");
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1,
U+03A3-03FF;
unicode-range:
U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
@@ -193,9 +198,10 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBHMdazTgWw.woff2)
format("woff2");
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315,
U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A,
U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6,
unicode-range:
U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A,
U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346,
U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6,
U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043,
U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C,
U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121,
@@ -215,20 +221,21 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBHMdazTgWw.woff2)
format("woff2");
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0,
U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0,
U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A,
U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB,
U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C,
U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3,
U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F,
U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D,
U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2,
U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3,
U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442,
U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3,
U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6,
U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB,
unicode-range:
U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4,
U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3,
U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF,
U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF,
U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0,
U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F,
U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315,
U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382,
U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6,
U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7,
U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444,
U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0,
U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA,
U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB,
U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513,
U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D,
U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC,
@@ -248,9 +255,10 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBHMdazTgWw.woff2)
format("woff2");
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169,
U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
U+0329, U+1EA0-1EF9, U+20AB;
unicode-range:
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
@@ -261,9 +269,10 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2)
format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
@@ -274,7 +283,8 @@
font-display: swap;
src: url(/assets/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}

View File

@@ -29,6 +29,12 @@ const metadataOptions = computed(() => [
logo_path: "/assets/scrappers/moby.png",
disabled: !heartbeat.value.METADATA_SOURCES?.MOBY_API_ENABLED,
},
{
name: "ScreenScrapper",
value: "ss",
logo_path: "/assets/scrappers/ss.png",
disabled: !heartbeat.value.METADATA_SOURCES?.SS_API_ENABLED,
},
{
name: "SteamgridDB",
value: "sgdb",

View File

@@ -5,6 +5,7 @@ import BackgroundHeader from "@/components/Details/BackgroundHeader.vue";
import FileInfo from "@/components/Details/Info/FileInfo.vue";
import GameInfo from "@/components/Details/Info/GameInfo.vue";
import Personal from "@/components/Details/Personal.vue";
import PdfViewer from "@/components/Details/PDFViewer.vue";
import RelatedGames from "@/components/Details/RelatedGames.vue";
import Saves from "@/components/Details/Saves.vue";
import States from "@/components/Details/States.vue";
@@ -27,6 +28,7 @@ const { t } = useI18n();
const route = useRoute();
const tab = ref<
| "details"
| "manual"
| "saves"
| "states"
| "personal"
@@ -40,6 +42,7 @@ const noRomError = ref(false);
const romsStore = storeRoms();
const { currentRom, gettingRoms } = storeToRefs(romsStore);
// Functions
async function fetchDetails() {
gettingRoms.value = true;
await romApi
@@ -115,6 +118,9 @@ watch(
:class="{ 'mt-4': smAndDown }"
>
<v-tab value="details"> {{ t("rom.details") }} </v-tab>
<v-tab value="manual" v-if="currentRom.has_manual">
{{ t("rom.manual") }}
</v-tab>
<v-tab value="saves"> {{ t("common.saves") }} </v-tab>
<v-tab value="states"> {{ t("common.states") }} </v-tab>
<v-tab value="personal">
@@ -152,6 +158,9 @@ watch(
</v-col>
</v-row>
</v-window-item>
<v-window-item value="manual">
<pdf-viewer v-if="currentRom.has_manual" :rom="currentRom" />
</v-window-item>
<v-window-item value="saves">
<saves :rom="currentRom" />
</v-window-item>

View File

@@ -30,11 +30,17 @@ const metadataOptions = computed(() => [
disabled: !heartbeat.value.METADATA_SOURCES?.IGDB_API_ENABLED,
},
{
name: "MobyGames",
name: "Mobygames",
value: "moby",
logo_path: "/assets/scrappers/moby.png",
disabled: !heartbeat.value.METADATA_SOURCES?.MOBY_API_ENABLED,
},
{
name: "Screenscraper",
value: "ss",
logo_path: "/assets/scrappers/ss.png",
disabled: !heartbeat.value.METADATA_SOURCES?.SS_API_ENABLED,
},
]);
// Use the computed metadataOptions to filter out disabled sources
const metadataSources = ref(metadataOptions.value.filter((s) => !s.disabled));
@@ -199,11 +205,7 @@ async function stopScan() {
<template #chip="{ item }">
<v-chip>
<v-avatar class="mr-2" size="15" rounded="1">
<v-img
:src="`/assets/scrappers/${item.raw.name
.slice(0, 4)
.toLowerCase()}.png`"
/>
<v-img :src="item.raw.logo_path" />
</v-avatar>
{{ item.raw.name }}
</v-chip>
@@ -296,76 +298,85 @@ async function stopScan() {
/>
<!-- Scan log -->
<v-card elevation="0" class="bg-surface mx-auto mt-2 mb-14" max-width="800">
<v-card-text class="pa-0">
<v-expansion-panels v-model="panels" multiple flat variant="accordion">
<v-expansion-panel
v-for="platform in scanningPlatforms"
:key="platform.id"
>
<v-expansion-panel-title>
<v-list-item class="pa-0">
<template #prepend>
<v-avatar size="40">
<platform-icon
:key="platform.slug"
:slug="platform.slug"
:name="platform.name"
:fs-slug="platform.fs_slug"
/>
</v-avatar>
</template>
{{ platform.name }}
<template #append>
<v-chip class="ml-3" color="primary" size="x-small" label>{{
platform.roms.length
}}</v-chip>
</template>
</v-list-item>
</v-expansion-panel-title>
<v-expansion-panel-text class="bg-toplayer">
<rom-list-item
v-for="rom in platform.roms"
class="pa-4"
:rom="rom"
with-link
with-filename
<v-row no-gutters>
<v-col>
<v-card
elevation="0"
class="bg-surface mx-auto mt-2 mb-14"
max-width="800"
>
<v-card-text class="pa-0">
<v-expansion-panels
v-model="panels"
multiple
flat
variant="accordion"
>
<v-expansion-panel
v-for="platform in scanningPlatforms"
:key="platform.id"
>
<template #append-body>
<v-chip
v-if="!rom.igdb_id && !rom.moby_id"
color="red"
size="x-small"
label
>Not identified<v-icon class="ml-1">mdi-close</v-icon></v-chip
<v-expansion-panel-title>
<v-list-item class="pa-0">
<template #prepend>
<v-avatar rounded="0" size="40">
<platform-icon
:key="platform.slug"
:slug="platform.slug"
:name="platform.name"
/>
</v-avatar>
</template>
{{ platform.name }}
<template #append>
<v-chip class="ml-3" color="primary" size="x-small" label>{{
platform.roms.length
}}</v-chip>
</template>
</v-list-item>
</v-expansion-panel-title>
<v-expansion-panel-text class="bg-toplayer">
<rom-list-item
v-for="rom in platform.roms"
class="pa-4"
:rom="rom"
with-link
with-filename
>
</template>
</rom-list-item>
<v-list-item
v-if="platform.roms.length == 0"
class="text-center my-2"
>
{{ t("scan.no-new-roms") }}
</v-list-item>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
<template #append-body>
<v-chip
v-if="!rom.igdb_id && !rom.moby_id && !rom.ss_id"
color="red"
size="x-small"
label
>Not identified<v-icon class="ml-1"
>mdi-close</v-icon
></v-chip
>
</template>
</rom-list-item>
<v-list-item
v-if="platform.roms.length == 0"
class="text-center my-2"
>
{{ t("scan.no-new-roms") }}
</v-list-item>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Scan stats -->
<div
v-if="scanningPlatforms.length > 0"
class="text-caption position-fixed d-flex w-100 m-1 justify-center"
style="bottom: 0.5rem"
>
<v-chip variant="outlined" color="toplayer" class="px-2 py-5 bg-background">
<v-chip
v-if="scanningPlatforms.length > 0"
color="primary"
text-color="white"
size="small"
class="mr-1 my-1"
>
<v-chip color="primary" text-color="white" size="small" class="mr-1 my-1">
<v-icon left>mdi-controller</v-icon>
<span v-if="xs" class="ml-2">{{
t("scan.platforms-scanned-n", scanningPlatforms.length)