mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
Merge pull request #3369 from gtronset/gt-relative-gamelist-assets
Fix `gamelist.xml` export to use relative media paths for local exports
This commit is contained in:
@@ -52,6 +52,7 @@ DEFAULT_EXCLUDED_FILES: Final = [
|
||||
]
|
||||
DEFAULT_EXCLUDED_DIRS: Final = [
|
||||
"@eaDir",
|
||||
"assets",
|
||||
"__MACOSX",
|
||||
"$RECYCLE.BIN",
|
||||
".Trash-*",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from os.path import isabs
|
||||
from xml.etree.ElementTree import fromstring
|
||||
|
||||
import pytest
|
||||
|
||||
from config import FRONTEND_RESOURCES_PATH
|
||||
from handler.database import db_platform_handler, db_rom_handler
|
||||
from handler.filesystem import fs_platform_handler, fs_resource_handler
|
||||
from models.platform import Platform
|
||||
from models.rom import Rom
|
||||
from models.user import User
|
||||
@@ -40,7 +43,14 @@ def platform_with_roms(admin_user: User):
|
||||
"companies": ["Nintendo", "Nintendo EAD"],
|
||||
"first_release_date": 709257600, # 1992-06-23 UTC in seconds; view *1000
|
||||
"total_rating": 92.0, # view uses this directly as a 0-100 igdb_rating
|
||||
}
|
||||
},
|
||||
"path_cover_l": "snes/covers/super-mario-world.jpg",
|
||||
"path_manual": "snes/manuals/super-mario-world.pdf",
|
||||
"path_screenshots": ["snes/screenshots/super-mario-world-1.jpg"],
|
||||
"gamelist_metadata": {
|
||||
"player_count": "2",
|
||||
"video_path": "snes/videos/super-mario-world.mp4", # feeds rom.path_video property
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -211,3 +221,193 @@ def test_export_gamelist_xml_scrap_element(platform_with_roms):
|
||||
scrap = game.find("scrap")
|
||||
assert scrap is not None
|
||||
assert scrap.get("name") == "RomM"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", ["thumbnail", "image", "video", "screenshot", "manual"])
|
||||
def test_export_gamelist_xml_local_media_relative_path(platform_with_roms, tag):
|
||||
platform, _ = platform_with_roms
|
||||
exporter = GamelistExporter(local_export=True)
|
||||
xml_str = exporter.export_platform_to_xml(platform.id, request=None)
|
||||
root = fromstring(xml_str)
|
||||
game = root.findall("game")[0]
|
||||
|
||||
elem = game.find(tag)
|
||||
assert elem is not None
|
||||
assert elem.text is not None
|
||||
assert not isabs(elem.text)
|
||||
|
||||
|
||||
def test_export_gamelist_xml_local_ss_metadata_media_relative(platform_with_roms):
|
||||
platform, roms = platform_with_roms
|
||||
|
||||
db_rom_handler.update_rom(
|
||||
roms[0].id,
|
||||
{
|
||||
"ss_metadata": {
|
||||
"box3d_path": "snes-ss/box3d/test.png",
|
||||
"box2d_back_path": "snes-ss/boxback/test.png",
|
||||
"fanart_path": "snes-ss/fanart/test.png",
|
||||
"logo_path": "snes-ss/logo/test.png",
|
||||
"miximage_path": "snes-ss/miximage/test.png",
|
||||
"physical_path": "snes-ss/physical/test.png",
|
||||
"title_screen_path": "snes-ss/titlescreen/test.png",
|
||||
"bezel_path": "snes-ss/bezel/test.png",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
exporter = GamelistExporter(local_export=True)
|
||||
xml_str = exporter.export_platform_to_xml(platform.id, request=None)
|
||||
root = fromstring(xml_str)
|
||||
game = root.findall("game")[0]
|
||||
|
||||
media_tags = [
|
||||
"box3d",
|
||||
"boxback",
|
||||
"fanart",
|
||||
"marquee",
|
||||
"miximage",
|
||||
"physicalmedia",
|
||||
"title_screen",
|
||||
"bezel",
|
||||
]
|
||||
for tag in media_tags:
|
||||
elem = game.find(tag)
|
||||
assert elem is not None and elem.text is not None
|
||||
|
||||
assert not isabs(elem.text)
|
||||
|
||||
|
||||
def test_export_gamelist_xml_local_no_absolute_paths_anywhere(platform_with_roms):
|
||||
"""Catch-all: when local_export=True, no element text should contain
|
||||
the FRONTEND_RESOURCES_PATH absolute prefix."""
|
||||
platform, _ = platform_with_roms
|
||||
|
||||
exporter = GamelistExporter(local_export=True)
|
||||
xml_str = exporter.export_platform_to_xml(platform.id, request=None)
|
||||
root = fromstring(xml_str)
|
||||
|
||||
for elem in root.iter():
|
||||
if elem.text and FRONTEND_RESOURCES_PATH in elem.text:
|
||||
pytest.fail(
|
||||
f"<{elem.tag}> contains absolute FRONTEND_RESOURCES_PATH: {elem.text}"
|
||||
)
|
||||
|
||||
|
||||
def test_export_gamelist_xml_rejects_path_traversal(platform_with_roms):
|
||||
"""Paths with traversal segments must not escape the resources directory."""
|
||||
platform, roms = platform_with_roms
|
||||
|
||||
db_rom_handler.update_rom(roms[0].id, {"path_cover_l": "../../etc/passwd"})
|
||||
|
||||
exporter = GamelistExporter(local_export=True)
|
||||
with pytest.raises(ValueError, match="invalid parent directory references"):
|
||||
exporter.export_platform_to_xml(platform.id, request=None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_filesystem(tmp_path, monkeypatch):
|
||||
"""Redirect resource and library base paths to a temp directory so that
|
||||
export_platform_to_file() can copy real assets and write gamelist.xml
|
||||
without touching the host filesystem."""
|
||||
resources_base = tmp_path / "resources"
|
||||
library_base = tmp_path / "library"
|
||||
monkeypatch.setattr(fs_resource_handler, "base_path", resources_base)
|
||||
monkeypatch.setattr(fs_platform_handler, "base_path", library_base)
|
||||
return resources_base, library_base
|
||||
|
||||
|
||||
async def test_export_platform_to_file_copies_assets(
|
||||
platform_with_roms, isolated_filesystem
|
||||
):
|
||||
"""export_platform_to_file copies each media file into <platform>/assets/<subdir>/
|
||||
and writes gamelist.xml referencing those relative paths."""
|
||||
resources_base, library_base = isolated_filesystem
|
||||
platform, _ = platform_with_roms
|
||||
|
||||
sources = {
|
||||
"snes/covers/super-mario-world.jpg": b"cover-bytes",
|
||||
"snes/screenshots/super-mario-world-1.jpg": b"shot-bytes",
|
||||
"snes/manuals/super-mario-world.pdf": b"manual-bytes",
|
||||
"snes/videos/super-mario-world.mp4": b"video-bytes",
|
||||
}
|
||||
for rel, content in sources.items():
|
||||
src = resources_base / rel
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
src.write_bytes(content)
|
||||
|
||||
exporter = GamelistExporter(local_export=True)
|
||||
assert await exporter.export_platform_to_file(platform.id, request=None) is True
|
||||
|
||||
platform_dir = library_base / fs_platform_handler.get_platform_fs_structure(
|
||||
platform.fs_slug
|
||||
)
|
||||
|
||||
expected_assets = {
|
||||
"assets/covers/Super Mario World (USA).jpg": b"cover-bytes",
|
||||
"assets/screenshots/Super Mario World (USA).jpg": b"shot-bytes",
|
||||
"assets/manuals/Super Mario World (USA).pdf": b"manual-bytes",
|
||||
"assets/videos/Super Mario World (USA).mp4": b"video-bytes",
|
||||
}
|
||||
for rel, content in expected_assets.items():
|
||||
dest = platform_dir / rel
|
||||
assert dest.is_file(), f"missing asset {dest}"
|
||||
assert dest.read_bytes() == content
|
||||
|
||||
gamelist = platform_dir / "gamelist.xml"
|
||||
assert gamelist.is_file()
|
||||
game = fromstring(gamelist.read_text()).findall("game")[0]
|
||||
|
||||
expected_refs = {
|
||||
"thumbnail": "./assets/covers/Super Mario World (USA).jpg",
|
||||
"screenshot": "./assets/screenshots/Super Mario World (USA).jpg",
|
||||
"video": "./assets/videos/Super Mario World (USA).mp4",
|
||||
"manual": "./assets/manuals/Super Mario World (USA).pdf",
|
||||
}
|
||||
for tag, expected in expected_refs.items():
|
||||
elem = game.find(tag)
|
||||
assert elem is not None and elem.text == expected
|
||||
|
||||
|
||||
async def test_export_platform_to_file_omits_tags_when_copy_fails(
|
||||
platform_with_roms, isolated_filesystem
|
||||
):
|
||||
"""When a source resource is missing, _copy_asset returns False; the
|
||||
corresponding tag must be omitted from gamelist.xml and no asset file
|
||||
must be written for it. Other assets still export normally."""
|
||||
resources_base, library_base = isolated_filesystem
|
||||
platform, _ = platform_with_roms
|
||||
|
||||
# Provide cover and screenshot, deliberately omit manual + video sources.
|
||||
for rel in (
|
||||
"snes/covers/super-mario-world.jpg",
|
||||
"snes/screenshots/super-mario-world-1.jpg",
|
||||
):
|
||||
src = resources_base / rel
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
src.write_bytes(b"X")
|
||||
|
||||
exporter = GamelistExporter(local_export=True)
|
||||
assert await exporter.export_platform_to_file(platform.id, request=None) is True
|
||||
|
||||
platform_dir = library_base / fs_platform_handler.get_platform_fs_structure(
|
||||
platform.fs_slug
|
||||
)
|
||||
|
||||
# Successful copies present
|
||||
assert (platform_dir / "assets/covers/Super Mario World (USA).jpg").is_file()
|
||||
assert (platform_dir / "assets/screenshots/Super Mario World (USA).jpg").is_file()
|
||||
# Failed copies don't produce destination files (an empty subdir may be
|
||||
# left behind because _copy_asset mkdirs before opening the source).
|
||||
assert not (platform_dir / "assets/manuals/Super Mario World (USA).pdf").exists()
|
||||
assert not (platform_dir / "assets/videos/Super Mario World (USA).mp4").exists()
|
||||
|
||||
game = fromstring((platform_dir / "gamelist.xml").read_text()).findall("game")[0]
|
||||
assert game.find("manual") is None
|
||||
assert game.find("video") is None
|
||||
thumbnail = game.find("thumbnail")
|
||||
assert thumbnail is not None
|
||||
assert thumbnail.text == "./assets/covers/Super Mario World (USA).jpg"
|
||||
screenshot = game.find("screenshot")
|
||||
assert screenshot is not None
|
||||
assert screenshot.text == "./assets/screenshots/Super Mario World (USA).jpg"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import shutil
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from xml.etree.ElementTree import ( # trunk-ignore(bandit/B405)
|
||||
Element,
|
||||
SubElement,
|
||||
@@ -7,14 +9,31 @@ from xml.etree.ElementTree import ( # trunk-ignore(bandit/B405)
|
||||
)
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URLPath
|
||||
|
||||
from config import FRONTEND_RESOURCES_PATH, YOUTUBE_BASE_URL
|
||||
from config.config_manager import config_manager as cm
|
||||
from handler.database import db_platform_handler, db_rom_handler
|
||||
from handler.filesystem import fs_platform_handler
|
||||
from handler.filesystem import fs_platform_handler, fs_resource_handler
|
||||
from logger.logger import log
|
||||
from models.rom import Rom
|
||||
|
||||
# Map gamelist asset keys to subdirectory names inside assets/
|
||||
ASSET_DIRS: dict[str, str] = {
|
||||
"box2d": "covers",
|
||||
"box3d": "boxes",
|
||||
"box2d_back": "backcovers",
|
||||
"fanart": "fanart",
|
||||
"marquee": "marquees",
|
||||
"miximage": "miximages",
|
||||
"physical": "physical",
|
||||
"screenshot": "screenshots",
|
||||
"title_screen": "titlescreens",
|
||||
"bezel": "bezels",
|
||||
"video": "videos",
|
||||
"manual": "manuals",
|
||||
}
|
||||
|
||||
|
||||
def get_media_options_for_export() -> tuple[str, str]:
|
||||
"""Get media options for export from config"""
|
||||
@@ -35,8 +54,123 @@ class GamelistExporter:
|
||||
"%Y%m%dT%H%M%S"
|
||||
)
|
||||
|
||||
def _collect_assets(self, rom: Rom) -> dict[str, Path]:
|
||||
"""Collect available media assets for a ROM.
|
||||
|
||||
Returns a dict mapping gamelist asset key to the absolute source file path.
|
||||
"""
|
||||
assets: dict[str, Path] = {}
|
||||
|
||||
if rom.path_cover_l:
|
||||
assets["box2d"] = fs_resource_handler.validate_path(rom.path_cover_l)
|
||||
|
||||
if rom.path_screenshots:
|
||||
assets["screenshot"] = fs_resource_handler.validate_path(
|
||||
rom.path_screenshots[0]
|
||||
)
|
||||
|
||||
if rom.path_video:
|
||||
assets["video"] = fs_resource_handler.validate_path(rom.path_video)
|
||||
|
||||
if rom.path_manual:
|
||||
assets["manual"] = fs_resource_handler.validate_path(rom.path_manual)
|
||||
|
||||
ss = rom.ss_metadata or {}
|
||||
gl = rom.gamelist_metadata or {}
|
||||
|
||||
# Each gamelist asset key may be sourced from screenscraper or gamelist
|
||||
# metadata; preference order is screenscraper first, gamelist second.
|
||||
extended: dict[str, list[str]] = {
|
||||
"box3d": [ss.get("box3d_path", ""), gl.get("box3d_path", "")],
|
||||
"box2d_back": [
|
||||
ss.get("box2d_back_path", ""),
|
||||
gl.get("box2d_back_path", ""),
|
||||
],
|
||||
"fanart": [ss.get("fanart_path", ""), gl.get("fanart_path", "")],
|
||||
"marquee": [ss.get("logo_path", ""), gl.get("marquee_path", "")],
|
||||
"miximage": [ss.get("miximage_path", ""), gl.get("miximage_path", "")],
|
||||
"physical": [ss.get("physical_path", ""), gl.get("physical_path", "")],
|
||||
"title_screen": [
|
||||
ss.get("title_screen_path", ""),
|
||||
gl.get("title_screen_path", ""),
|
||||
],
|
||||
"bezel": [ss.get("bezel_path", "")],
|
||||
}
|
||||
|
||||
for asset_key, candidates in extended.items():
|
||||
for candidate in candidates:
|
||||
if candidate:
|
||||
assets[asset_key] = fs_resource_handler.validate_path(candidate)
|
||||
break
|
||||
|
||||
return assets
|
||||
|
||||
def _build_asset_refs(
|
||||
self,
|
||||
rom: Rom,
|
||||
request: Request | None,
|
||||
assets: dict[str, Path],
|
||||
platform_dir: Path | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build the asset references that will appear in gamelist.xml.
|
||||
|
||||
For local exports, returns relative paths like ``assets/covers/<rom>.jpg``
|
||||
and, if ``platform_dir`` is provided, copies the source files into place.
|
||||
|
||||
For non-local exports, returns absolute URLs built from ``request.base_url``.
|
||||
"""
|
||||
refs: dict[str, str] = {}
|
||||
|
||||
if self.local_export:
|
||||
for asset_key, source_path in assets.items():
|
||||
subdir = ASSET_DIRS.get(asset_key, asset_key)
|
||||
dest_name = f"{rom.fs_name_no_ext}{source_path.suffix}"
|
||||
rel_path = f"./assets/{subdir}/{dest_name}"
|
||||
|
||||
if platform_dir is not None:
|
||||
dest_path = platform_dir / rel_path
|
||||
if not self._copy_asset(source_path, dest_path):
|
||||
continue
|
||||
|
||||
refs[asset_key] = rel_path
|
||||
return refs
|
||||
|
||||
if request is None:
|
||||
raise ValueError("Request object must be provided for non-local exports")
|
||||
|
||||
for asset_key, source_path in assets.items():
|
||||
resource_part = source_path.relative_to(
|
||||
Path(fs_resource_handler.base_path).resolve()
|
||||
)
|
||||
refs[asset_key] = str(
|
||||
URLPath(
|
||||
f"{FRONTEND_RESOURCES_PATH}/{resource_part.as_posix()}"
|
||||
).make_absolute_url(request.base_url)
|
||||
)
|
||||
|
||||
return refs
|
||||
|
||||
def _copy_asset(self, source: Path, dest: Path) -> bool:
|
||||
"""Copy a file from source to dest. Returns True on success."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dest.exists():
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(source, "rb") as src, open(dest, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
return True
|
||||
except OSError as e:
|
||||
log.warning(f"Failed to copy {source} -> {dest}: {e}")
|
||||
return False
|
||||
|
||||
def _create_game_element(
|
||||
self, rom: Rom, request: Request | None, media_image: str, media_thumbnail: str
|
||||
self,
|
||||
rom: Rom,
|
||||
request: Request | None,
|
||||
asset_refs: dict[str, str],
|
||||
media_image: str,
|
||||
media_thumbnail: str,
|
||||
) -> Element:
|
||||
"""Create a <game> element for a ROM"""
|
||||
game = Element("game")
|
||||
@@ -62,85 +196,28 @@ class GamelistExporter:
|
||||
if rom.summary:
|
||||
SubElement(game, "desc").text = rom.summary
|
||||
|
||||
# Media files
|
||||
thumbnail_path: str | None = None
|
||||
match media_thumbnail:
|
||||
case "box3d":
|
||||
if rom.ss_metadata and rom.ss_metadata.get("box3d_path"):
|
||||
thumbnail_path = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['box3d_path']}"
|
||||
)
|
||||
elif rom.gamelist_metadata and rom.gamelist_metadata.get("box3d_path"):
|
||||
thumbnail_path = f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['box3d_path']}"
|
||||
case "miximage":
|
||||
if rom.ss_metadata and rom.ss_metadata.get("miximage_path"):
|
||||
thumbnail_path = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['miximage_path']}"
|
||||
)
|
||||
elif rom.gamelist_metadata and rom.gamelist_metadata.get(
|
||||
"miximage_path"
|
||||
):
|
||||
thumbnail_path = f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['miximage_path']}"
|
||||
case "physical":
|
||||
if rom.ss_metadata and rom.ss_metadata.get("physical_path"):
|
||||
thumbnail_path = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['physical_path']}"
|
||||
)
|
||||
elif rom.gamelist_metadata and rom.gamelist_metadata.get(
|
||||
"physical_path"
|
||||
):
|
||||
thumbnail_path = f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['physical_path']}"
|
||||
# Thumbnail: prefer media_thumbnail option, fall back to box2d (cover)
|
||||
thumbnail = asset_refs.get(media_thumbnail) or asset_refs.get("box2d")
|
||||
if thumbnail:
|
||||
SubElement(game, "thumbnail").text = thumbnail
|
||||
|
||||
# "cover" and "box2d" both map to path_cover_l
|
||||
if thumbnail_path is None and rom.path_cover_l:
|
||||
thumbnail_path = f"{FRONTEND_RESOURCES_PATH}/{rom.path_cover_l}"
|
||||
if thumbnail_path:
|
||||
SubElement(game, "thumbnail").text = thumbnail_path
|
||||
|
||||
if path_video := rom.path_video:
|
||||
SubElement(game, "video").text = f"{FRONTEND_RESOURCES_PATH}/{path_video}"
|
||||
if "video" in asset_refs:
|
||||
SubElement(game, "video").text = asset_refs["video"]
|
||||
elif rom.youtube_video_id:
|
||||
SubElement(game, "video").text = (
|
||||
f"{YOUTUBE_BASE_URL}/embed/{rom.youtube_video_id}"
|
||||
)
|
||||
|
||||
if rom.path_screenshots:
|
||||
SubElement(game, "screenshot").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.path_screenshots[0]}"
|
||||
)
|
||||
if "screenshot" in asset_refs:
|
||||
SubElement(game, "screenshot").text = asset_refs["screenshot"]
|
||||
|
||||
image_path: str | None = None
|
||||
match media_image:
|
||||
case "title_screen":
|
||||
if rom.ss_metadata and rom.ss_metadata.get("title_screen_path"):
|
||||
image_path = f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['title_screen_path']}"
|
||||
elif rom.gamelist_metadata and rom.gamelist_metadata.get(
|
||||
"title_screen_path"
|
||||
):
|
||||
image_path = f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['title_screen_path']}"
|
||||
case "miximage":
|
||||
if rom.ss_metadata and rom.ss_metadata.get("miximage_path"):
|
||||
image_path = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['miximage_path']}"
|
||||
)
|
||||
elif rom.gamelist_metadata and rom.gamelist_metadata.get(
|
||||
"miximage_path"
|
||||
):
|
||||
image_path = f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['miximage_path']}"
|
||||
case "box2d":
|
||||
if rom.path_cover_l:
|
||||
image_path = f"{FRONTEND_RESOURCES_PATH}/{rom.path_cover_l}"
|
||||
# Image: prefer media_image option, fall back to screenshot
|
||||
image = asset_refs.get(media_image) or asset_refs.get("screenshot")
|
||||
if image:
|
||||
SubElement(game, "image").text = image
|
||||
|
||||
# "screenshot" (default) and anything else falls through to path_screenshots
|
||||
if image_path is None and rom.path_screenshots:
|
||||
image_path = f"{FRONTEND_RESOURCES_PATH}/{rom.path_screenshots[0]}"
|
||||
if image_path:
|
||||
SubElement(game, "image").text = image_path
|
||||
|
||||
if rom.path_manual:
|
||||
SubElement(game, "manual").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.path_manual}"
|
||||
)
|
||||
if "manual" in asset_refs:
|
||||
SubElement(game, "manual").text = asset_refs["manual"]
|
||||
|
||||
# Additional metadata
|
||||
if rom.metadatum.companies and len(rom.metadatum.companies) > 0:
|
||||
@@ -178,70 +255,20 @@ class GamelistExporter:
|
||||
if rom.gamelist_id:
|
||||
SubElement(game, "id").text = str(rom.gamelist_id)
|
||||
|
||||
# Provider specific metadata
|
||||
if rom.ss_metadata:
|
||||
if rom.ss_metadata.get("box3d_path"):
|
||||
SubElement(game, "box3d").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['box3d_path']}"
|
||||
)
|
||||
if rom.ss_metadata.get("box2d_back_path"):
|
||||
SubElement(game, "boxback").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['box2d_back_path']}"
|
||||
)
|
||||
if rom.ss_metadata.get("fanart_path"):
|
||||
SubElement(game, "fanart").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['fanart_path']}"
|
||||
)
|
||||
if rom.ss_metadata.get("logo_path"):
|
||||
SubElement(game, "marquee").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['logo_path']}"
|
||||
)
|
||||
if rom.ss_metadata.get("miximage_path"):
|
||||
SubElement(game, "miximage").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['miximage_path']}"
|
||||
)
|
||||
if rom.ss_metadata.get("physical_path"):
|
||||
SubElement(game, "physicalmedia").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['physical_path']}"
|
||||
)
|
||||
if rom.ss_metadata.get("title_screen_path"):
|
||||
SubElement(game, "title_screen").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['title_screen_path']}"
|
||||
)
|
||||
if rom.ss_metadata.get("bezel_path"):
|
||||
SubElement(game, "bezel").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.ss_metadata['bezel_path']}"
|
||||
)
|
||||
|
||||
if rom.gamelist_metadata:
|
||||
if rom.gamelist_metadata.get("box3d_path"):
|
||||
SubElement(game, "box3d").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['box3d_path']}"
|
||||
)
|
||||
if rom.gamelist_metadata.get("box2d_back_path"):
|
||||
SubElement(game, "boxback").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['box2d_back_path']}"
|
||||
)
|
||||
if rom.gamelist_metadata.get("fanart_path"):
|
||||
SubElement(game, "fanart").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['fanart_path']}"
|
||||
)
|
||||
if rom.gamelist_metadata.get("marquee_path"):
|
||||
SubElement(game, "marquee").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['marquee_path']}"
|
||||
)
|
||||
if rom.gamelist_metadata.get("miximage_path"):
|
||||
SubElement(game, "miximage").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['miximage_path']}"
|
||||
)
|
||||
if rom.gamelist_metadata.get("physical_path"):
|
||||
SubElement(game, "physicalmedia").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['physical_path']}"
|
||||
)
|
||||
if rom.gamelist_metadata.get("title_screen_path"):
|
||||
SubElement(game, "title_screen").text = (
|
||||
f"{FRONTEND_RESOURCES_PATH}/{rom.gamelist_metadata['title_screen_path']}"
|
||||
)
|
||||
# Dedicated asset elements
|
||||
element_names: dict[str, str] = {
|
||||
"box3d": "box3d",
|
||||
"box2d_back": "boxback",
|
||||
"fanart": "fanart",
|
||||
"marquee": "marquee",
|
||||
"miximage": "miximage",
|
||||
"physical": "physicalmedia",
|
||||
"title_screen": "title_screen",
|
||||
"bezel": "bezel",
|
||||
}
|
||||
for asset_key, tag in element_names.items():
|
||||
if asset_key in asset_refs:
|
||||
SubElement(game, tag).text = asset_refs[asset_key]
|
||||
|
||||
# Add scraping info
|
||||
scrap = SubElement(game, "scrap")
|
||||
@@ -250,41 +277,52 @@ class GamelistExporter:
|
||||
|
||||
return game
|
||||
|
||||
def export_platform_to_xml(self, platform_id: int, request: Request | None) -> str:
|
||||
"""Export a platform's ROMs to gamelist.xml format
|
||||
|
||||
Args:
|
||||
platform_id: Platform ID to export
|
||||
|
||||
Returns:
|
||||
XML string in gamelist.xml format
|
||||
"""
|
||||
def _build_gamelist_xml(
|
||||
self,
|
||||
platform_id: int,
|
||||
request: Request | None,
|
||||
platform_dir: Path | None,
|
||||
) -> tuple[str, int]:
|
||||
"""Build gamelist XML, optionally copying assets into ``platform_dir/assets/``."""
|
||||
platform = db_platform_handler.get_platform(platform_id)
|
||||
if not platform:
|
||||
raise ValueError(f"Platform with ID {platform_id} not found")
|
||||
|
||||
roms = db_rom_handler.get_roms_scalar(platform_ids=[platform_id])
|
||||
|
||||
# Create root element
|
||||
root = Element("gameList")
|
||||
|
||||
media_image, media_thumbnail = get_media_options_for_export()
|
||||
|
||||
count = 0
|
||||
for rom in roms:
|
||||
if rom and not rom.missing_from_fs and rom.fs_name != "gamelist.xml":
|
||||
game_element = self._create_game_element(
|
||||
rom,
|
||||
request=request,
|
||||
media_image=media_image,
|
||||
media_thumbnail=media_thumbnail,
|
||||
)
|
||||
root.append(game_element)
|
||||
if not rom or rom.missing_from_fs or rom.fs_name == "gamelist.xml":
|
||||
continue
|
||||
|
||||
assets = self._collect_assets(rom)
|
||||
asset_refs = self._build_asset_refs(
|
||||
rom, request=request, assets=assets, platform_dir=platform_dir
|
||||
)
|
||||
|
||||
game_element = self._create_game_element(
|
||||
rom,
|
||||
request=request,
|
||||
asset_refs=asset_refs,
|
||||
media_image=media_image,
|
||||
media_thumbnail=media_thumbnail,
|
||||
)
|
||||
root.append(game_element)
|
||||
count += 1
|
||||
|
||||
# Convert to XML string
|
||||
indent(root, space=" ", level=0)
|
||||
xml_str = tostring(root, encoding="unicode", xml_declaration=True)
|
||||
log.info(f"Exported {count} ROMs for platform {platform.name}")
|
||||
return xml_str, count
|
||||
|
||||
log.info(f"Exported {len(roms)} ROMs for platform {platform.name}")
|
||||
def export_platform_to_xml(self, platform_id: int, request: Request | None) -> str:
|
||||
"""Export a platform's ROMs to gamelist.xml format (no asset files copied)."""
|
||||
xml_str, _ = self._build_gamelist_xml(
|
||||
platform_id, request=request, platform_dir=None
|
||||
)
|
||||
return xml_str
|
||||
|
||||
async def export_platform_to_file(
|
||||
@@ -292,11 +330,8 @@ class GamelistExporter:
|
||||
platform_id: int,
|
||||
request: Request | None,
|
||||
) -> bool:
|
||||
"""Export platform ROMs to gamelist.xml file in the platform's directory
|
||||
|
||||
Args:
|
||||
platform_id: Platform ID to export
|
||||
request: FastAPI request object for URL generation
|
||||
"""Export platform ROMs to gamelist.xml in the platform's directory,
|
||||
copying media assets into a local assets/ folder when local_export=True.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
@@ -310,8 +345,15 @@ class GamelistExporter:
|
||||
platform_fs_structure = fs_platform_handler.get_platform_fs_structure(
|
||||
platform.fs_slug
|
||||
)
|
||||
platform_dir = (
|
||||
fs_platform_handler.base_path / platform_fs_structure
|
||||
if self.local_export
|
||||
else None
|
||||
)
|
||||
|
||||
xml_content = self.export_platform_to_xml(platform_id, request=request)
|
||||
xml_content, _ = self._build_gamelist_xml(
|
||||
platform_id, request=request, platform_dir=platform_dir
|
||||
)
|
||||
await fs_platform_handler.write_file(
|
||||
xml_content.encode("utf-8"), platform_fs_structure, "gamelist.xml"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user