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:
Georges-Antoine Assi
2026-05-16 09:04:26 -04:00
committed by GitHub
3 changed files with 408 additions and 165 deletions

View File

@@ -52,6 +52,7 @@ DEFAULT_EXCLUDED_FILES: Final = [
]
DEFAULT_EXCLUDED_DIRS: Final = [
"@eaDir",
"assets",
"__MACOSX",
"$RECYCLE.BIN",
".Trash-*",

View File

@@ -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"

View File

@@ -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"
)