Add config to store media on disk

This commit is contained in:
Georges-Antoine Assi
2025-10-24 16:04:54 -04:00
parent 4ec67ff5fc
commit 463bb27ea9
31 changed files with 521 additions and 252 deletions

View File

@@ -20,6 +20,7 @@ from config import (
ROMM_DB_DRIVER,
)
from exceptions.config_exceptions import ConfigNotWritableException
from handler.metadata.base_handler import MetadataMediaType
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
@@ -66,6 +67,7 @@ class Config:
SCAN_ARTWORK_PRIORITY: list[str]
SCAN_REGION_PRIORITY: list[str]
SCAN_LANGUAGE_PRIORITY: list[str]
SCAN_MEDIA: list[str]
def __init__(self, **entries):
self.__dict__.update(entries)
@@ -242,6 +244,15 @@ class ConfigManager:
"scan.priority.language",
["en", "fr"],
),
SCAN_MEDIA=pydash.get(
self._raw_config,
"scan.media",
[
"box2d",
"screenshot",
"manual",
],
),
)
def _get_ejs_controls(self) -> dict[str, EjsControls]:
@@ -420,6 +431,17 @@ class ConfigManager:
log.critical("Invalid config.yml: scan.priority.language must be a list")
sys.exit(3)
if not isinstance(self.config.SCAN_MEDIA, list):
log.critical("Invalid config.yml: scan.media must be a list")
sys.exit(3)
for media in self.config.SCAN_MEDIA:
if media not in MetadataMediaType:
log.critical(
f"Invalid config.yml: scan.media.{media} is not a valid media type"
)
sys.exit(3)
def get_config(self) -> Config:
try:
with open(self.config_file, "r") as config_file:

View File

@@ -826,7 +826,7 @@ async def update_rom(
cleaned_data.update({"moby_id": None, "moby_metadata": {}})
if cleaned_data["ss_id"] and int(cleaned_data["ss_id"]) != rom.ss_id:
ss_rom = await meta_ss_handler.get_rom_by_id(cleaned_data["ss_id"])
ss_rom = await meta_ss_handler.get_rom_by_id(rom, cleaned_data["ss_id"])
cleaned_data.update(ss_rom)
elif rom.ss_id and not cleaned_data["ss_id"]:
cleaned_data.update({"ss_id": None, "ss_metadata": {}})

View File

@@ -97,7 +97,7 @@ async def search_rom(
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)),
meta_ss_handler.get_matched_rom_by_id(rom, int(search_term)),
)
except ValueError as exc:
log.error(f"Search error: invalid ID '{search_term}'")
@@ -123,7 +123,9 @@ async def search_rom(
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),
meta_ss_handler.get_matched_roms_by_name(
rom, search_term, rom.platform.ss_id
),
meta_flashpoint_handler.get_matched_roms_by_name(
search_term, rom.platform.slug
),

View File

@@ -27,6 +27,7 @@ from handler.filesystem import (
fs_rom_handler,
)
from handler.filesystem.roms_handler import FSRom
from handler.metadata.ss_handler import get_preferred_media_types
from handler.redis_handler import high_prio_queue, redis_client
from handler.scan_handler import (
ScanType,
@@ -337,6 +338,24 @@ async def _identify_rom(
if badge_url and badge_path:
await fs_resource_handler.store_ra_badge(badge_url, badge_path)
if _added_rom.ss_metadata:
preferred_media_types = get_preferred_media_types()
for media_type in preferred_media_types:
if _added_rom.ss_metadata.get(f"{media_type.value}_path"):
await fs_resource_handler.store_media_file(
_added_rom.ss_metadata[f"{media_type.value}_url"],
_added_rom.ss_metadata[f"{media_type.value}_path"],
)
if _added_rom.gamelist_metadata:
preferred_media_types = get_preferred_media_types()
for media_type in preferred_media_types:
if _added_rom.gamelist_metadata.get(f"{media_type.value}_path"):
await fs_resource_handler.store_media_file(
_added_rom.gamelist_metadata[f"{media_type.value}_url"],
_added_rom.gamelist_metadata[f"{media_type.value}_path"],
)
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
entity=_added_rom,
overwrite=should_update_props,

View File

@@ -9,6 +9,7 @@ from fastapi import status
from PIL import Image, ImageFile, UnidentifiedImageError
from config import ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP, RESOURCES_BASE_PATH
from handler.metadata.base_handler import MetadataMediaType
from logger.logger import log
from models.collection import Collection
from models.rom import Rom
@@ -26,6 +27,7 @@ class FSResourcesHandler(FSHandler):
def get_platform_resources_path(self, platform_id: int) -> str:
return os.path.join("roms", str(platform_id))
# Cover art
def cover_exists(self, entity: Rom | Collection, size: CoverSize) -> bool:
"""Check if rom cover exists in filesystem
@@ -227,6 +229,7 @@ class FSResourcesHandler(FSHandler):
path_cover_s.relative_to(self.base_path)
)
# Screenshots
async def _store_screenshot(self, rom: Rom, url_screenhot: str, idx: int):
"""Store roms resources in filesystem
@@ -322,6 +325,7 @@ class FSResourcesHandler(FSHandler):
return path_screenshots
# Manuals
def manual_exists(self, rom: Rom) -> bool:
"""Check if rom manual exists in filesystem
@@ -415,6 +419,7 @@ class FSResourcesHandler(FSHandler):
async def remove_manual(self, rom: Rom):
await self.remove_directory(f"{rom.fs_resources_path}/manual")
# Retroachievements
async def store_ra_badge(self, url: str, path: str) -> None:
httpx_client = ctx_httpx_client.get()
directory, filename = os.path.split(path)
@@ -447,3 +452,52 @@ class FSResourcesHandler(FSHandler):
async def create_ra_resources_path(self, platform_id: int, rom_id: int) -> None:
await self.make_directory(self.get_ra_resources_path(platform_id, rom_id))
# Mixed media
def get_media_resources_path(
self,
platform_id: int,
rom_id: int,
media_type: MetadataMediaType,
) -> str:
return os.path.join("roms", str(platform_id), str(rom_id), media_type.value)
async def create_media_resources_path(
self,
platform_id: int,
rom_id: int,
media_type: MetadataMediaType,
) -> None:
await self.make_directory(
self.get_media_resources_path(platform_id, rom_id, media_type)
)
async def store_media_file(self, url: str, path: str) -> None:
httpx_client = ctx_httpx_client.get()
directory, filename = os.path.split(path)
if await self.file_exists(path):
log.debug(f"Media file {path} already exists, skipping download")
return
try:
async with httpx_client.stream("GET", url, timeout=120) as response:
if response.status_code == status.HTTP_200_OK:
async with await self.write_file_streamed(
path=directory, filename=filename
) as f:
async for chunk in response.aiter_raw():
await f.write(chunk)
except httpx.TransportError as exc:
log.error(f"Unable to fetch media file at {url}: {str(exc)}")
return None
async def remove_media_resources_path(
self,
platform_id: int,
rom_id: int,
media_type: MetadataMediaType,
) -> None:
await self.remove_directory(
self.get_media_resources_path(platform_id, rom_id, media_type)
)

View File

@@ -54,6 +54,20 @@ class BaseRom(TypedDict):
url_manual: NotRequired[str]
class MetadataMediaType(enum.StrEnum):
BEZEL = "bezel"
BOX2D = "box2d"
BOX3D = "box3d"
MIXIMAGE = "miximage"
PHYSICAL = "physical"
SCREENSHOT = "screenshot"
TITLE_SCREEN = "title_screen"
MARQUEE = "marquee"
FANART = "fanart"
VIDEO = "video"
MANUAL = "manual"
# This caches results to avoid repeated normalization of the same search term
@lru_cache(maxsize=1024)
def _normalize_search_term(

View File

@@ -7,29 +7,43 @@ from xml.etree.ElementTree import Element # trunk-ignore(bandit/B405)
import pydash
from defusedxml import ElementTree as ET
from handler.filesystem import fs_platform_handler
from config.config_manager import config_manager as cm
from handler.filesystem import fs_platform_handler, fs_resource_handler
from logger.logger import log
from models.platform import Platform
from models.rom import Rom
from .base_handler import BaseRom, MetadataHandler
from .base_handler import BaseRom, MetadataHandler, MetadataMediaType
# https://github.com/Aloshi/EmulationStation/blob/master/GAMELISTS.md#reference
def get_preferred_media_types() -> list[MetadataMediaType]:
"""Get preferred media types from config"""
config = cm.get_config()
return [MetadataMediaType(media) for media in config.SCAN_MEDIA]
class GamelistMetadataMedia(TypedDict):
box2d: str | None
box2d_back: str | None
box3d: str | None
fanart: str | None
image: str | None
manual: str | None
marquee: str | None
miximage: str | None
physical: str | None
screenshot: str | None
thumbnail: str | None
title_screen: str | None
video: str | None
box2d_url: str | None
box2d_back_url: str | None
box3d_url: str | None
fanart_url: str | None
image_url: str | None
manual_url: str | None
marquee_url: str | None
miximage_url: str | None
physical_url: str | None
screenshot_url: str | None
thumbnail_url: str | None
title_screen_url: str | None
video_url: str | None
# Resources stored in filesystem
box3d_path: str | None
miximage_path: str | None
physical_path: str | None
video_path: str | None
class GamelistMetadata(GamelistMetadataMedia):
@@ -49,7 +63,9 @@ class GamelistRom(BaseRom):
gamelist_metadata: NotRequired[GamelistMetadata]
def extract_media_from_gamelist_rom(game: Element) -> GamelistMetadataMedia:
def extract_media_from_gamelist_rom(rom: Rom, game: Element) -> GamelistMetadataMedia:
preferred_media_types = get_preferred_media_types()
image_elem = game.find("image")
video_elem = game.find("video")
box3d_elem = game.find("box3d")
@@ -65,75 +81,103 @@ def extract_media_from_gamelist_rom(game: Element) -> GamelistMetadataMedia:
thumbnail_elem = game.find("thumbnail")
return GamelistMetadataMedia(
image=(
image_url=(
image_elem.text.replace("./", "")
if image_elem is not None and image_elem.text
else None
),
video=(
video_elem.text.replace("./", "")
if video_elem is not None and video_elem.text
else None
),
box2d=(
box2d_url=(
box2d_elem.text.replace("./", "")
if box2d_elem is not None and box2d_elem.text
else None
),
box2d_back=(
box2d_back_url=(
box2d_back_elem.text.replace("./", "")
if box2d_back_elem is not None and box2d_back_elem.text
else None
),
box3d=(
box3d_url=(
box3d_elem.text.replace("./", "")
if box3d_elem is not None and box3d_elem.text
else None
),
fanart=(
box3d_path=(
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.BOX3D)}/box3d.png"
if box3d_elem is not None
and box3d_elem.text
and MetadataMediaType.BOX3D in preferred_media_types
else None
),
fanart_url=(
fanart_elem.text.replace("./", "")
if fanart_elem is not None and fanart_elem.text
else None
),
manual=(
manual_url=(
manual_elem.text.replace("./", "")
if manual_elem is not None and manual_elem.text
else None
),
marquee=(
marquee_url=(
marquee_elem.text.replace("./", "")
if marquee_elem is not None and marquee_elem.text
else None
),
miximage=(
miximage_url=(
miximage_elem.text.replace("./", "")
if miximage_elem is not None and miximage_elem.text
else None
),
physical=(
miximage_path=(
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.MIXIMAGE)}/miximage.png"
if miximage_elem is not None
and miximage_elem.text
and MetadataMediaType.MIXIMAGE in preferred_media_types
else None
),
physical_url=(
physical_elem.text.replace("./", "")
if physical_elem is not None and physical_elem.text
else None
),
screenshot=(
physical_path=(
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.PHYSICAL)}/physical.png"
if physical_elem is not None
and physical_elem.text
and MetadataMediaType.PHYSICAL in preferred_media_types
else None
),
screenshot_url=(
screenshot_elem.text.replace("./", "")
if screenshot_elem is not None and screenshot_elem.text
else None
),
title_screen=(
title_screen_url=(
title_screen_elem.text.replace("./", "")
if title_screen_elem is not None and title_screen_elem.text
else None
),
thumbnail=(
thumbnail_url=(
thumbnail_elem.text.replace("./", "")
if thumbnail_elem is not None and thumbnail_elem.text
else None
),
video_url=(
video_elem.text.replace("./", "")
if video_elem is not None and video_elem.text
else None
),
video_path=(
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.VIDEO)}/video.mp4"
if video_elem is not None
and video_elem.text
and MetadataMediaType.VIDEO in preferred_media_types
else None
),
)
def extract_metadata_from_gamelist_rom(game: Element) -> GamelistMetadata:
def extract_metadata_from_gamelist_rom(rom: Rom, game: Element) -> GamelistMetadata:
rating_elem = game.find("rating")
releasedate_elem = game.find("releasedate")
developer_elem = game.find("developer")
@@ -178,7 +222,7 @@ def extract_metadata_from_gamelist_rom(game: Element) -> GamelistMetadata:
genres=pydash.compact([genre]),
player_count=players,
md5_hash=md5,
**extract_media_from_gamelist_rom(game),
**extract_media_from_gamelist_rom(rom, game),
)
@@ -204,9 +248,10 @@ class GamelistHandler(MetadataHandler):
return None
def _parse_gamelist_xml(
self, gamelist_path: Path, platform: Platform
self, gamelist_path: Path, platform: Platform, rom: Rom
) -> dict[str, GamelistRom]:
"""Parse a gamelist.xml file and return ROM data indexed by filename"""
preferred_media_types = get_preferred_media_types()
roms_data: dict[str, GamelistRom] = {}
try:
@@ -250,7 +295,7 @@ class GamelistHandler(MetadataHandler):
)
# Build ROM data
rom_metadata = extract_metadata_from_gamelist_rom(game)
rom_metadata = extract_metadata_from_gamelist_rom(rom, game)
rom_data = GamelistRom(
gamelist_id=str(uuid.uuid4()),
name=name,
@@ -265,7 +310,7 @@ class GamelistHandler(MetadataHandler):
)
# Choose which cover style to use
cover_path = rom_metadata["box2d"]
cover_path = rom_metadata["box2d_url"]
if cover_path:
cover_path_path = fs_platform_handler.validate_path(
f"{platform_dir}/{cover_path}"
@@ -273,38 +318,43 @@ class GamelistHandler(MetadataHandler):
rom_data["url_cover"] = f"file://{str(cover_path_path)}"
# Grab the manual
if rom_metadata["manual"]:
if (
rom_metadata["manual_url"]
and MetadataMediaType.MANUAL in preferred_media_types
):
manual_path = fs_platform_handler.validate_path(
f"{platform_dir}/{rom_metadata['manual']}"
f"{platform_dir}/{rom_metadata['manual_url']}"
)
rom_data["url_manual"] = f"file://{str(manual_path)}"
# Build list of screenshot URLs
url_screenshots = []
if rom_metadata["screenshot"]:
if (
rom_metadata["screenshot_url"]
and MetadataMediaType.SCREENSHOT in preferred_media_types
):
screenshot_path = fs_platform_handler.validate_path(
f"{platform_dir}/{rom_metadata['screenshot']}"
f"{platform_dir}/{rom_metadata['screenshot_url']}"
)
url_screenshots.append(f"file://{str(screenshot_path)}")
if rom_metadata["title_screen"]:
if (
rom_metadata["title_screen_url"]
and MetadataMediaType.TITLE_SCREEN in preferred_media_types
):
title_screen_path = fs_platform_handler.validate_path(
f"{platform_dir}/{rom_metadata['title_screen']}"
f"{platform_dir}/{rom_metadata['title_screen_url']}"
)
url_screenshots.append(f"file://{str(title_screen_path)}")
if rom_metadata["miximage"]:
if (
rom_metadata["miximage_url"]
and MetadataMediaType.MIXIMAGE in preferred_media_types
):
miximage_path = fs_platform_handler.validate_path(
f"{platform_dir}/{rom_metadata['miximage']}"
f"{platform_dir}/{rom_metadata['miximage_url']}"
)
url_screenshots.append(f"file://{str(miximage_path)}")
rom_data["url_screenshots"] = url_screenshots
# TODO: Add support for importing videos
# if rom_metadata["video"]:
# video_path = fs_platform_handler.validate_path(
# f"{platform_dir}/{rom_metadata['video']}"
# )
# rom_data["url_video"] = f"file://{str(video_path)}")
# Store by filename for matching
roms_data[rom_filename] = rom_data
except ET.ParseError as e:
@@ -314,7 +364,7 @@ class GamelistHandler(MetadataHandler):
return roms_data
async def get_rom(self, fs_name: str, platform: Platform) -> GamelistRom:
async def get_rom(self, fs_name: str, platform: Platform, rom: Rom) -> GamelistRom:
"""Get ROM metadata from gamelist.xml files"""
if not self.is_enabled():
return GamelistRom(gamelist_id=None)
@@ -325,7 +375,7 @@ class GamelistHandler(MetadataHandler):
return GamelistRom(gamelist_id=None)
# Parse the gamelist file
all_roms_data = self._parse_gamelist_xml(gamelist_file_path, platform)
all_roms_data = self._parse_gamelist_xml(gamelist_file_path, platform, rom)
# Try to find exact match first
if fs_name in all_roms_data:

View File

@@ -11,7 +11,9 @@ from adapters.services.screenscraper import ScreenScraperService
from adapters.services.screenscraper_types import SSGame, SSGameDate
from config import SCREENSCRAPER_PASSWORD, SCREENSCRAPER_USER
from config.config_manager import config_manager as cm
from handler.filesystem import fs_resource_handler
from logger.logger import log
from models.rom import Rom
from .base_handler import (
PS2_OPL_REGEX,
@@ -20,6 +22,7 @@ from .base_handler import (
SWITCH_TITLEDB_REGEX,
BaseRom,
MetadataHandler,
MetadataMediaType,
)
from .base_handler import UniversalPlatformSlug as UPS
@@ -41,6 +44,12 @@ def get_preferred_languages() -> list[str]:
return list(dict.fromkeys(config.SCAN_LANGUAGE_PRIORITY + ["en", "fr"]))
def get_preferred_media_types() -> list[MetadataMediaType]:
"""Get preferred media types from config"""
config = cm.get_config()
return [MetadataMediaType(media) for media in config.SCAN_MEDIA]
PS1_SS_ID: Final = 57
PS2_SS_ID: Final = 58
PSP_SS_ID: Final = 61
@@ -133,23 +142,30 @@ class SSAgeRating(TypedDict):
class SSMetadataMedia(TypedDict):
bezel: str | None # bezel-16-9
box2d: str | None # box-2D
box2d_side: str | None # box-2D-side
box2d_back: str | None # box-2D-back
box3d: str | None # box-3D
fanart: str | None # fanart
fullbox: str | None # box-texture
logo: str | None # wheel-hd
manual: str | None # manual
marquee: str | None # screenmarquee
miximage: str | None # mixrbv1 | mixrbv2
physical: str | None # support-2D
screenshot: str | None # ss
steamgrid: str | None # steamgrid
title_screen: str | None # sstitle
video: str | None # video
video_normalized: str | None # video-normalized
bezel_url: str | None # bezel-16-9
box2d_url: str | None # box-2D
box2d_side_url: str | None # box-2D-side
box2d_back_url: str | None # box-2D-back
box3d_url: str | None # box-3D
fanart_url: str | None # fanart
fullbox_url: str | None # box-texture
logo_url: str | None # wheel-hd
manual_url: str | None # manual
marquee_url: str | None # screenmarquee
miximage_url: str | None # mixrbv1 | mixrbv2
physical_url: str | None # support-2D
screenshot_url: str | None # ss
steamgrid_url: str | None # steamgrid
title_screen_url: str | None # sstitle
video_url: str | None # video
video_normalized_url: str | None # video-normalized
# Resources stored in filesystem
bezel_path: str | None
box3d_path: str | None
miximage_path: str | None
physical_path: str | None
video_path: str | None
class SSMetadata(SSMetadataMedia):
@@ -167,25 +183,32 @@ class SSRom(BaseRom):
ss_metadata: NotRequired[SSMetadata]
def extract_media_from_ss_rom(game: SSGame) -> SSMetadataMedia:
def extract_media_from_ss_game(rom: Rom, game: SSGame) -> SSMetadataMedia:
preferred_media_types = get_preferred_media_types()
ss_media = SSMetadataMedia(
bezel=None,
box2d=None,
box2d_back=None,
box2d_side=None,
box3d=None,
fanart=None,
fullbox=None,
logo=None,
manual=None,
marquee=None,
miximage=None,
physical=None,
screenshot=None,
steamgrid=None,
title_screen=None,
video=None,
video_normalized=None,
bezel_url=None,
box2d_url=None,
box2d_back_url=None,
box2d_side_url=None,
box3d_url=None,
fanart_url=None,
fullbox_url=None,
logo_url=None,
manual_url=None,
marquee_url=None,
miximage_url=None,
physical_url=None,
screenshot_url=None,
steamgrid_url=None,
title_screen_url=None,
video_url=None,
video_normalized_url=None,
bezel_path=None,
box3d_path=None,
miximage_path=None,
physical_path=None,
video_path=None,
)
for region in get_preferred_regions():
@@ -193,53 +216,78 @@ def extract_media_from_ss_rom(game: SSGame) -> SSMetadataMedia:
if not media.get("region") == region or media.get("parent") != "jeu":
continue
if media.get("type") == "box-2D-back" and not ss_media["box2d_back"]:
ss_media["box2d_back"] = media["url"]
elif media.get("type") == "bezel-16-9" and not ss_media["bezel"]:
ss_media["bezel"] = media["url"]
elif media.get("type") == "box-2D" and not ss_media["box2d"]:
ss_media["box2d"] = media["url"]
elif media.get("type") == "fanart" and not ss_media["fanart"]:
ss_media["fanart"] = media["url"]
elif media.get("type") == "box-texture" and not ss_media["fullbox"]:
ss_media["fullbox"] = media["url"]
elif media.get("type") == "wheel-hd" and not ss_media["logo"]:
ss_media["logo"] = media["url"]
elif media.get("type") == "manual" and not ss_media["manual"]:
ss_media["manual"] = media["url"]
elif media.get("type") == "screenmarquee" and not ss_media["marquee"]:
ss_media["marquee"] = media["url"]
if media.get("type") == "box-2D-back" and not ss_media["box2d_back_url"]:
ss_media["box2d_back_url"] = media["url"]
elif media.get("type") == "bezel-16-9" and not ss_media["bezel_url"]:
ss_media["bezel_url"] = media["url"]
if MetadataMediaType.BEZEL in preferred_media_types:
ss_media["bezel_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.BEZEL)}/bezel.png"
)
elif media.get("type") == "box-2D" and not ss_media["box2d_url"]:
ss_media["box2d_url"] = media["url"]
elif media.get("type") == "fanart" and not ss_media["fanart_url"]:
ss_media["fanart_url"] = media["url"]
elif media.get("type") == "box-texture" and not ss_media["fullbox_url"]:
ss_media["fullbox_url"] = media["url"]
elif media.get("type") == "wheel-hd" and not ss_media["logo_url"]:
ss_media["logo_url"] = media["url"]
elif media.get("type") == "manual" and not ss_media["manual_url"]:
ss_media["manual_url"] = media["url"]
elif media.get("type") == "screenmarquee" and not ss_media["marquee_url"]:
ss_media["marquee_url"] = media["url"]
elif (
media.get("type") == "miximage1"
or media.get("type") == "miximage2"
or media.get("type") == "mixrbv1"
or media.get("type") == "mixrbv2"
) and not ss_media["miximage"]:
ss_media["miximage"] = media["url"]
elif media.get("type") == "support-2D" and not ss_media["physical"]:
ss_media["physical"] = media["url"]
elif media.get("type") == "ss" and not ss_media["screenshot"]:
ss_media["screenshot"] = media["url"]
elif media.get("type") == "box-2D-side" and not ss_media["box2d_side"]:
ss_media["box2d_side"] = media["url"]
elif media.get("type") == "steamgrid" and not ss_media["steamgrid"]:
ss_media["steamgrid"] = media["url"]
elif media.get("type") == "box-3D" and not ss_media["box3d"]:
ss_media["box3d"] = media["url"]
elif media.get("type") == "sstitle" and not ss_media["title_screen"]:
ss_media["title_screen"] = media["url"]
elif media.get("type") == "video" and not ss_media["video"]:
ss_media["video"] = media["url"]
) and not ss_media["miximage_url"]:
ss_media["miximage_url"] = media["url"]
if MetadataMediaType.MIXIMAGE in preferred_media_types:
ss_media["miximage_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.MIXIMAGE)}/miximage.png"
)
elif media.get("type") == "support-2D" and not ss_media["physical_url"]:
ss_media["physical_url"] = media["url"]
if MetadataMediaType.PHYSICAL in preferred_media_types:
ss_media["physical_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.PHYSICAL)}/physical.png"
)
elif (
media.get("type") == "ss"
and not ss_media["screenshot_url"]
and MetadataMediaType.SCREENSHOT in preferred_media_types
):
ss_media["screenshot_url"] = media["url"]
ss_media["screenshot_url"] = media["url"]
elif media.get("type") == "box-2D-side" and not ss_media["box2d_side_url"]:
ss_media["box2d_side_url"] = media["url"]
elif media.get("type") == "steamgrid" and not ss_media["steamgrid_url"]:
ss_media["steamgrid_url"] = media["url"]
elif media.get("type") == "box-3D" and not ss_media["box3d_url"]:
ss_media["box3d_url"] = media["url"]
if MetadataMediaType.BOX3D in preferred_media_types:
ss_media["box3d_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.BOX3D)}/box3d.png"
)
elif media.get("type") == "sstitle" and not ss_media["title_screen_url"]:
ss_media["title_screen_url"] = media["url"]
elif media.get("type") == "video" and not ss_media["video_url"]:
ss_media["video_url"] = media["url"]
if MetadataMediaType.VIDEO in preferred_media_types:
ss_media["video_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.VIDEO)}/video.mp4"
)
elif (
media.get("type") == "video-normalized"
and not ss_media["video_normalized"]
and not ss_media["video_normalized_url"]
):
ss_media["video_normalized"] = media["url"]
ss_media["video_normalized_url"] = media["url"]
return ss_media
def extract_metadata_from_ss_rom(rom: SSGame) -> SSMetadata:
def extract_metadata_from_ss_rom(rom: Rom, game: SSGame) -> SSMetadata:
preferred_languages = get_preferred_languages()
def _normalize_score(score: str) -> str:
@@ -262,19 +310,19 @@ def extract_metadata_from_ss_rom(rom: SSGame) -> SSMetadata:
except ValueError:
return None
def _get_genres(rom: SSGame) -> list[str]:
def _get_genres(game: SSGame) -> list[str]:
return [
genre_name["text"]
for genre in rom.get("genres", [])
for genre in game.get("genres", [])
for genre_name in genre.get("noms", [])
if genre_name.get("langue") == "en"
]
def _get_franchises(rom: SSGame) -> list[str]:
def _get_franchises(game: SSGame) -> list[str]:
for lang in preferred_languages:
franchises = [
franchise_name["text"]
for franchise in rom.get("familles", [])
for franchise in game.get("familles", [])
for franchise_name in franchise.get("noms", [])
if franchise_name.get("langue") == lang
]
@@ -282,11 +330,11 @@ def extract_metadata_from_ss_rom(rom: SSGame) -> SSMetadata:
return franchises
return []
def _get_game_modes(rom: SSGame) -> list[str]:
def _get_game_modes(game: SSGame) -> list[str]:
for lang in preferred_languages:
modes = [
mode_name["text"]
for mode in rom.get("modes", [])
for mode in game.get("modes", [])
for mode_name in mode.get("noms", [])
if mode_name.get("langue") == lang
]
@@ -296,25 +344,26 @@ def extract_metadata_from_ss_rom(rom: SSGame) -> SSMetadata:
return SSMetadata(
{
"ss_score": _normalize_score(rom.get("note", {}).get("text", "")),
"alternative_names": [name["text"] for name in rom.get("noms", [])],
"ss_score": _normalize_score(game.get("note", {}).get("text", "")),
"alternative_names": [name["text"] for name in game.get("noms", [])],
"companies": pydash.compact(
[
rom.get("editeur", {}).get("text"),
rom.get("developpeur", {}).get("text"),
game.get("editeur", {}).get("text"),
game.get("developpeur", {}).get("text"),
]
),
"genres": _get_genres(rom),
"first_release_date": _get_lowest_date(rom.get("dates", [])),
"franchises": _get_franchises(rom),
"game_modes": _get_game_modes(rom),
**extract_media_from_ss_rom(rom),
"genres": _get_genres(game),
"first_release_date": _get_lowest_date(game.get("dates", [])),
"franchises": _get_franchises(game),
"game_modes": _get_game_modes(game),
**extract_media_from_ss_game(rom, game),
}
)
def build_ss_rom(game: SSGame) -> SSRom:
ss_metadata = extract_metadata_from_ss_rom(game)
def build_ss_game(rom: Rom, game: SSGame) -> SSRom:
ss_metadata = extract_metadata_from_ss_rom(rom, game)
preferred_media_types = get_preferred_media_types()
res_name = ""
for region in get_preferred_regions():
@@ -342,18 +391,39 @@ def build_ss_rom(game: SSGame) -> SSRom:
if res_summary:
break
url_cover = ss_metadata["box2d"]
url_manual = ss_metadata["manual"]
url_cover = ss_metadata["box2d_url"]
url_manual = (
ss_metadata["manual_url"]
if MetadataMediaType.MANUAL in preferred_media_types
else None
)
url_screenshots = pydash.compact(
[
ss_metadata["screenshot"],
ss_metadata["title_screen"],
ss_metadata["miximage"],
(
ss_metadata["screenshot_url"]
if MetadataMediaType.SCREENSHOT in preferred_media_types
else None
),
(
ss_metadata["title_screen_url"]
if MetadataMediaType.TITLE_SCREEN in preferred_media_types
else None
),
(
ss_metadata["marquee_url"]
if MetadataMediaType.MARQUEE in preferred_media_types
else None
),
(
ss_metadata["fanart_url"]
if MetadataMediaType.FANART in preferred_media_types
else None
),
]
)
ss_id = int(game["id"]) if game.get("id") is not None else None
rom: SSRom = {
game_rom: SSRom = {
"ss_id": ss_id,
"name": res_name.replace(" : ", ": "), # Normalize colons
"summary": res_summary,
@@ -363,7 +433,7 @@ def build_ss_rom(game: SSGame) -> SSRom:
"ss_metadata": ss_metadata,
}
return SSRom({k: v for k, v in rom.items() if v}) # type: ignore[misc]
return SSRom({k: v for k, v in game_rom.items() if v}) # type: ignore[misc]
class SSHandler(MetadataHandler):
@@ -438,7 +508,7 @@ class SSHandler(MetadataHandler):
name=platform["name"],
)
async def get_rom(self, file_name: str, platform_ss_id: int) -> SSRom:
async def get_rom(self, rom: Rom, file_name: str, platform_ss_id: int) -> SSRom:
from handler.filesystem import fs_rom_handler
if not self.is_enabled():
@@ -451,7 +521,7 @@ class SSHandler(MetadataHandler):
ss_id_from_tag = self.extract_ss_id_from_filename(file_name)
if ss_id_from_tag:
log.debug(f"Found ScreenScraper ID tag in filename: {ss_id_from_tag}")
rom_by_id = await self.get_rom_by_id(ss_id_from_tag)
rom_by_id = await self.get_rom_by_id(rom, ss_id_from_tag)
if rom_by_id["ss_id"]:
log.debug(
f"Successfully matched ROM by ScreenScraper ID tag: {file_name} -> {ss_id_from_tag}"
@@ -541,9 +611,9 @@ class SSHandler(MetadataHandler):
if not res or not res.get("id"):
return fallback_rom
return build_ss_rom(res)
return build_ss_game(rom, res)
async def get_rom_by_id(self, ss_id: int) -> SSRom:
async def get_rom_by_id(self, rom: Rom, ss_id: int) -> SSRom:
if not self.is_enabled():
return SSRom(ss_id=None)
@@ -551,17 +621,17 @@ class SSHandler(MetadataHandler):
if not res:
return SSRom(ss_id=None)
return build_ss_rom(res)
return build_ss_game(rom, res)
async def get_matched_rom_by_id(self, ss_id: int) -> SSRom | None:
async def get_matched_rom_by_id(self, rom: Rom, ss_id: int) -> SSRom | None:
if not self.is_enabled():
return None
rom = await self.get_rom_by_id(ss_id)
return rom if rom.get("ss_id", "") else None
game_rom = await self.get_rom_by_id(rom, ss_id)
return game_rom if game_rom.get("ss_id", "") else None
async def get_matched_roms_by_name(
self, search_term: str, platform_ss_id: int | None
self, rom: Rom, search_term: str, platform_ss_id: int | None
) -> list[SSRom]:
if not self.is_enabled():
return []
@@ -569,18 +639,18 @@ class SSHandler(MetadataHandler):
if not platform_ss_id:
return []
matched_roms = await self.ss_service.search_games(
matched_games = await self.ss_service.search_games(
term=quote(uc(search_term), safe="/ "),
system_id=platform_ss_id,
)
def _is_ss_region(rom: SSGame) -> bool:
return any(name.get("region") == "ss" for name in rom.get("noms", []))
def _is_ss_region(game: SSGame) -> bool:
return any(name.get("region") == "ss" for name in game.get("noms", []))
return [
build_ss_rom(rom)
for rom in matched_roms
if _is_ss_region(rom) and rom.get("id")
build_ss_game(rom, game)
for game in matched_games
if _is_ss_region(game) and game.get("id")
]

View File

@@ -470,7 +470,9 @@ async def scan_rom(
or (scan_type == ScanType.UPDATE and rom.gamelist_id)
or (scan_type == ScanType.UNMATCHED and not rom.gamelist_id)
):
return await meta_gamelist_handler.get_rom(rom_attrs["fs_name"], platform)
return await meta_gamelist_handler.get_rom(
rom_attrs["fs_name"], platform, rom
)
return GamelistRom(gamelist_id=None)
@@ -553,10 +555,10 @@ async def scan_rom(
)
):
if scan_type == ScanType.UPDATE and rom.ss_id:
return await meta_ss_handler.get_rom_by_id(rom.ss_id)
return await meta_ss_handler.get_rom_by_id(rom, rom.ss_id)
else:
return await meta_ss_handler.get_rom(
rom_attrs["fs_name"], platform_ss_id=platform.ss_id
rom, rom_attrs["fs_name"], platform_ss_id=platform.ss_id
)
return SSRom(ss_id=None)

View File

@@ -45,6 +45,11 @@ scan:
language:
- jp
- es
media:
- box2d
- box3d
- physical
- miximage
emulatorjs:
debug: true

View File

@@ -47,10 +47,10 @@ system:
# The folder name where your roms are located
filesystem: {} # { roms_folder: 'roms' } For example if your folder structure is /home/user/library/roms_folder
# Metadata priority during scans
# Below are the default priority values used
# scan:
# # Metadata priority during scans
# priority:
# # Below are the default priority values used
# metadata: # Top-level metadata source priority
# - "igdb" # IGDB (highest priority)
# - "moby" # MobyGames
@@ -80,6 +80,25 @@ filesystem: {} # { roms_folder: 'roms' } For example if your folder structure is
# language: # Cover art and game title (only used by Screenscraper)
# - "en"
# - "fr"
# # Media assets to download
# # Only used by Screenscraper and ES-DE gamelist.xml
# media:
# # Used as alternative cover art
# - box2d # Normal cover art (enabled by default)
# - box3d # 3D box art
# - miximage # Mixed image of multiple media
# - physical # Disc, cartridge, etc.
# # Added to the screenshots carousel
# - screenshot # Screenshot (enabled by default)
# - title_screen # Title screen
# - marquee # Transparent logo
# - fanart # User uploaded artwork
# # Bezel displayed around the emulatorjs window
# - bezel
# # Manual in PDF format
# - manual (enabled by default)
# # Gameplay video
# - video # Video (warning: large file size)
# EmulatorJS per-core options
# emulatorjs:

View File

@@ -2,4 +2,4 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type RomFileCategory = 'dlc' | 'hack' | 'manual' | 'patch' | 'update' | 'mod' | 'demo' | 'translation' | 'prototype';
export type RomFileCategory = 'game' | 'dlc' | 'hack' | 'manual' | 'patch' | 'update' | 'mod' | 'demo' | 'translation' | 'prototype';

View File

@@ -3,19 +3,23 @@
/* tslint:disable */
/* eslint-disable */
export type RomGamelistMetadata = {
box2d?: (string | null);
box2d_back?: (string | null);
box3d?: (string | null);
fanart?: (string | null);
image?: (string | null);
manual?: (string | null);
marquee?: (string | null);
miximage?: (string | null);
physical?: (string | null);
screenshot?: (string | null);
thumbnail?: (string | null);
title_screen?: (string | null);
video?: (string | null);
box2d_url?: (string | null);
box2d_back_url?: (string | null);
box3d_url?: (string | null);
fanart_url?: (string | null);
image_url?: (string | null);
manual_url?: (string | null);
marquee_url?: (string | null);
miximage_url?: (string | null);
physical_url?: (string | null);
screenshot_url?: (string | null);
thumbnail_url?: (string | null);
title_screen_url?: (string | null);
video_url?: (string | null);
box3d_path?: (string | null);
miximage_path?: (string | null);
physical_path?: (string | null);
video_path?: (string | null);
rating?: (number | null);
first_release_date?: (string | null);
companies?: (Array<string> | null);

View File

@@ -3,23 +3,28 @@
/* tslint:disable */
/* eslint-disable */
export type RomSSMetadata = {
bezel?: (string | null);
box2d?: (string | null);
box2d_side?: (string | null);
box2d_back?: (string | null);
box3d?: (string | null);
fanart?: (string | null);
fullbox?: (string | null);
logo?: (string | null);
manual?: (string | null);
marquee?: (string | null);
miximage?: (string | null);
physical?: (string | null);
screenshot?: (string | null);
steamgrid?: (string | null);
title_screen?: (string | null);
video?: (string | null);
video_normalized?: (string | null);
bezel_url?: (string | null);
box2d_url?: (string | null);
box2d_side_url?: (string | null);
box2d_back_url?: (string | null);
box3d_url?: (string | null);
fanart_url?: (string | null);
fullbox_url?: (string | null);
logo_url?: (string | null);
manual_url?: (string | null);
marquee_url?: (string | null);
miximage_url?: (string | null);
physical_url?: (string | null);
screenshot_url?: (string | null);
steamgrid_url?: (string | null);
title_screen_url?: (string | null);
video_url?: (string | null);
video_normalized_url?: (string | null);
bezel_path?: (string | null);
box3d_path?: (string | null);
miximage_path?: (string | null);
physical_path?: (string | null);
video_path?: (string | null);
ss_score?: string;
first_release_date?: (number | null);
alternative_names?: Array<string>;

View File

@@ -52,7 +52,15 @@ const enableExperimentalCacheRef = useLocalStorage(
);
// Boxart
const boxartStyleRef = useLocalStorage("settings.boxartStyle", "box2d");
export type BoxartStyleOption =
| "cover"
| "box3d_path"
| "physical_path"
| "miximage_path";
const boxartStyleRef = useLocalStorage<BoxartStyleOption>(
"settings.boxartStyle",
"cover",
);
const homeOptions = computed(() => [
{
@@ -185,11 +193,10 @@ const galleryOptions = computed(() => [
]);
const boxartStyleOptions = computed(() => [
{ title: t("settings.boxart-box2d"), value: "box2d" },
{ title: t("settings.boxart-box3d"), value: "box3d" },
{ title: t("settings.boxart-physical"), value: "physical" },
{ title: t("settings.boxart-miximage"), value: "miximage" },
{ title: t("settings.boxart-fanart"), value: "fanart" },
{ title: t("settings.boxart-cover"), value: "cover" },
{ title: t("settings.boxart-box3d"), value: "box3d_path" },
{ title: t("settings.boxart-physical"), value: "physical_path" },
{ title: t("settings.boxart-miximage"), value: "miximage_path" },
]);
const setPlatformDrawerGroupBy = (value: string) => {
@@ -211,7 +218,7 @@ const setVirtualCollectionType = async (value: string) => {
virtualCollectionTypeRef.value = value;
collectionsStore.fetchVirtualCollections(value);
};
const setBoxartStyle = (value: string) => {
const setBoxartStyle = (value: BoxartStyleOption) => {
boxartStyleRef.value = value;
};
const toggleShowStats = (value: boolean) => {

View File

@@ -12,6 +12,7 @@ import {
} from "vue";
import { useDisplay } from "vuetify";
import type { SearchRomSchema } from "@/__generated__";
import type { BoxartStyleOption } from "@/components/Settings/UserInterface/Interface.vue";
import ActionBar from "@/components/common/Game/Card/ActionBar.vue";
import Flags from "@/components/common/Game/Card/Flags.vue";
import Skeleton from "@/components/common/Game/Card/Skeleton.vue";
@@ -26,6 +27,7 @@ import storePlatforms from "@/stores/platforms";
import storeRoms from "@/stores/roms";
import { type SimpleRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { FRONTEND_RESOURCES_PATH } from "@/utils";
import {
getMissingCoverImage,
getUnmatchedCoverImage,
@@ -124,9 +126,10 @@ const activeMenu = ref(false);
const showActionBarAlways = useLocalStorage("settings.showActionBar", false);
const showGameTitleAlways = useLocalStorage("settings.showGameTitle", false);
const showSiblings = useLocalStorage("settings.showSiblings", true);
const boxartStyle = useLocalStorage<
"box2d" | "box3d" | "physical" | "miximage" | "fanart"
>("settings.boxartStyle", "box2d");
const boxartStyle = useLocalStorage<BoxartStyleOption>(
"settings.boxartStyle",
"cover",
);
const hasNotes = computed(() => {
if (!romsStore.isSimpleRom(props.rom)) return false;
@@ -154,7 +157,7 @@ const boxartStyleCover = computed(() => {
if (
props.coverSrc ||
!romsStore.isSimpleRom(props.rom) ||
boxartStyle.value === "box2d"
boxartStyle.value === "cover"
)
return null;
const ssMedia = props.rom.ss_metadata?.[boxartStyle.value];
@@ -164,7 +167,9 @@ const boxartStyleCover = computed(() => {
const largeCover = computed(() => {
if (props.coverSrc) return props.coverSrc;
if (boxartStyleCover.value) return boxartStyleCover.value;
debugger;
if (boxartStyleCover.value)
return `${FRONTEND_RESOURCES_PATH}/${boxartStyleCover.value}`;
if (!romsStore.isSimpleRom(props.rom)) {
return (
props.rom.igdb_url_cover ||
@@ -182,7 +187,8 @@ const largeCover = computed(() => {
const smallCover = computed(() => {
if (props.coverSrc) return props.coverSrc;
if (boxartStyleCover.value) return boxartStyleCover.value;
if (boxartStyleCover.value)
return `${FRONTEND_RESOURCES_PATH}/${boxartStyleCover.value}`;
if (!romsStore.isSimpleRom(props.rom)) return "";
const pathCoverSmall = isWebpEnabled.value
? props.rom.path_cover_small?.replace(EXTENSION_REGEX, ".webp")

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useLocalStorage } from "@vueuse/core";
import { computed, onMounted, useTemplateRef, watch } from "vue";
import type { BoxartStyleOption } from "@/components/Settings/UserInterface/Interface.vue";
import Skeleton from "@/components/common/Game/Card/Skeleton.vue";
import {
continuePlayingElementRegistry,
@@ -8,7 +9,8 @@ import {
} from "@/console/composables/useElementRegistry";
import storeCollections from "@/stores/collections";
import storeHeartbeat from "@/stores/heartbeat";
import storeRoms, { type SimpleRom } from "@/stores/roms";
import { type SimpleRom } from "@/stores/roms";
import { FRONTEND_RESOURCES_PATH } from "@/utils";
import {
EXTENSION_REGEX,
getMissingCoverImage,
@@ -25,11 +27,11 @@ const props = defineProps<{
}>();
const heartbeatStore = storeHeartbeat();
const romsStore = storeRoms();
const boxartStyle = useLocalStorage<
"box2d" | "box3d" | "physical" | "miximage" | "fanart"
>("settings.boxartStyle", "box2d");
const boxartStyle = useLocalStorage<BoxartStyleOption>(
"settings.boxartStyle",
"cover",
);
const isWebpEnabled = computed(
() => heartbeatStore.value.TASKS?.ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP,
@@ -37,14 +39,15 @@ const isWebpEnabled = computed(
// User selected alternative cover image
const boxartStyleCover = computed(() => {
if (boxartStyle.value === "box2d") return null;
if (boxartStyle.value === "cover") return null;
const ssMedia = props.rom.ss_metadata?.[boxartStyle.value];
const gamelistMedia = props.rom.gamelist_metadata?.[boxartStyle.value];
return ssMedia || gamelistMedia;
});
const largeCover = computed(() => {
if (boxartStyleCover.value) return boxartStyleCover.value;
if (boxartStyleCover.value)
return `${FRONTEND_RESOURCES_PATH}/${boxartStyleCover.value}`;
const pathCoverLarge = isWebpEnabled.value
? props.rom.path_cover_large?.replace(EXTENSION_REGEX, ".webp")
: props.rom.path_cover_large;
@@ -52,7 +55,8 @@ const largeCover = computed(() => {
});
const smallCover = computed(() => {
if (boxartStyleCover.value) return boxartStyleCover.value;
if (boxartStyleCover.value)
return `${FRONTEND_RESOURCES_PATH}/${boxartStyleCover.value}`;
const pathCoverSmall = isWebpEnabled.value
? props.rom.path_cover_small?.replace(EXTENSION_REGEX, ".webp")
: props.rom.path_cover_small;

View File

@@ -1,9 +1,8 @@
{
"backlogged": "Rückstand",
"boxart-box2d": "2D-Box",
"boxart-cover": "2D-Box",
"boxart-box3d": "3D-Box",
"boxart-desc": "Wählen Sie den Boxart-Stil für Spielkarten",
"boxart-fanart": "Fanart",
"boxart-miximage": "Mix-Bild",
"boxart-physical": "Physisch",
"boxart-style": "Boxart-Stil",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "Backlogged",
"boxart-box2d": "2D Box",
"boxart-cover": "2D Box",
"boxart-box3d": "3D Box",
"boxart-desc": "Choose the boxart style for game cards",
"boxart-fanart": "Fanart",
"boxart-miximage": "Mix Image",
"boxart-physical": "Physical",
"boxart-style": "Boxart style",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "Backlogged",
"boxart-box2d": "2D Box",
"boxart-cover": "2D Box",
"boxart-box3d": "3D Box",
"boxart-desc": "Choose the boxart style for game cards",
"boxart-fanart": "Fanart",
"boxart-miximage": "Mix Image",
"boxart-physical": "Physical",
"boxart-style": "Boxart style",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "Pendiente",
"boxart-box2d": "Caja 2D",
"boxart-cover": "Caja 2D",
"boxart-box3d": "Caja 3D",
"boxart-desc": "Elige el estilo de carátula para las tarjetas de juegos",
"boxart-fanart": "Fanart",
"boxart-miximage": "Imagen mixta",
"boxart-physical": "Físico",
"boxart-style": "Estilo de carátula",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "En attente",
"boxart-box2d": "Boîte 2D",
"boxart-cover": "Boîte 2D",
"boxart-box3d": "Boîte 3D",
"boxart-desc": "Choisissez le style de jaquette pour les cartes de jeux",
"boxart-fanart": "Fanart",
"boxart-miximage": "Image mixte",
"boxart-physical": "Physique",
"boxart-style": "Style de jaquette",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "In sospeso",
"boxart-box2d": "Scatola 2D",
"boxart-cover": "Scatola 2D",
"boxart-box3d": "Scatola 3D",
"boxart-desc": "Scegli lo stile della copertina per le carte dei giochi",
"boxart-fanart": "Fanart",
"boxart-miximage": "Immagine mista",
"boxart-physical": "Fisico",
"boxart-style": "Stile copertina",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "バックログ",
"boxart-box2d": "2Dボックス",
"boxart-cover": "2Dボックス",
"boxart-box3d": "3Dボックス",
"boxart-desc": "ゲームカードのボックスアートスタイルを選択",
"boxart-fanart": "ファンアート",
"boxart-miximage": "ミックス画像",
"boxart-physical": "物理",
"boxart-style": "ボックスアートスタイル",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "백로그",
"boxart-box2d": "2D 박스",
"boxart-cover": "2D 박스",
"boxart-box3d": "3D 박스",
"boxart-desc": "게임 카드의 박스아트 스타일을 선택하세요",
"boxart-fanart": "팬아트",
"boxart-miximage": "믹스 이미지",
"boxart-physical": "물리적",
"boxart-style": "박스아트 스타일",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "Zaległości",
"boxart-box2d": "Pudełko 2D",
"boxart-cover": "Pudełko 2D",
"boxart-box3d": "Pudełko 3D",
"boxart-desc": "Wybierz styl okładki dla kart gier",
"boxart-fanart": "Fanart",
"boxart-miximage": "Obraz mieszany",
"boxart-physical": "Fizyczny",
"boxart-style": "Styl okładki",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "Pendente",
"boxart-box2d": "Caixa 2D",
"boxart-cover": "Caixa 2D",
"boxart-box3d": "Caixa 3D",
"boxart-desc": "Escolha o estilo da capa para os cartões de jogos",
"boxart-fanart": "Fanart",
"boxart-miximage": "Imagem mista",
"boxart-physical": "Físico",
"boxart-style": "Estilo da capa",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "În așteptare",
"boxart-box2d": "Cutie 2D",
"boxart-cover": "Cutie 2D",
"boxart-box3d": "Cutie 3D",
"boxart-desc": "Alege stilul copertii pentru cardurile de jocuri",
"boxart-fanart": "Fanart",
"boxart-miximage": "Imagine mixtă",
"boxart-physical": "Fizic",
"boxart-style": "Stilul copertii",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "В очереди",
"boxart-box2d": "2D коробка",
"boxart-cover": "2D коробка",
"boxart-box3d": "3D коробка",
"boxart-desc": "Выберите стиль обложки для карточек игр",
"boxart-fanart": "Фанарт",
"boxart-miximage": "Смешанное изображение",
"boxart-physical": "Физический",
"boxart-style": "Стиль обложки",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "待办",
"boxart-box2d": "2D盒子",
"boxart-cover": "2D盒子",
"boxart-box3d": "3D盒子",
"boxart-desc": "选择游戏卡片的封面样式",
"boxart-fanart": "粉丝艺术",
"boxart-miximage": "混合图像",
"boxart-physical": "物理",
"boxart-style": "封面样式",

View File

@@ -1,9 +1,8 @@
{
"backlogged": "待辦",
"boxart-box2d": "2D盒子",
"boxart-cover": "2D盒子",
"boxart-box3d": "3D盒子",
"boxart-desc": "選擇遊戲卡片的封面樣式",
"boxart-fanart": "粉絲藝術",
"boxart-miximage": "混合圖像",
"boxart-physical": "物理",
"boxart-style": "封面樣式",