Skip to content

Commit a5854b5

Browse files
authored
Merge pull request #4121 from sdornan/claude/launchbox-n64-matching-b7ce16
fix(launchbox): match ROMs during a scan the way Match ROM does
2 parents 05dc9ea + 4a6a461 commit a5854b5

12 files changed

Lines changed: 630 additions & 103 deletions

File tree

backend/handler/metadata/launchbox_handler/handler.py

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -142,37 +142,42 @@ async def get_rom(
142142
if not remote_available:
143143
return fallback_rom
144144

145-
# `keep_tags` prevents stripping content that is considered a tag, e.g., anything between `()` or `[]`.
146-
# By default, tags are still stripped to keep scan behavior consistent with previous versions.
147-
# If `keep_tags` is True, the full `fs_name` is used for searching.
148-
if not keep_tags:
149-
search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name)
150-
else:
151-
search_term = fs_name
152-
153-
# Resolve MAME arcade filename (e.g. wrlok_l3.zip) to its full title
154-
# via LaunchBox's Mame.xml before name-based lookup.
155-
if platform_slug == UPS.ARCADE:
156-
mame_entry = await self._remote.get_mame_entry(fs_name)
157-
if mame_entry:
158-
name = (mame_entry.get("Name") or "").strip()
159-
if name:
160-
search_term = name
161-
fallback_rom = LaunchboxRom(launchbox_id=None, name=name)
162-
163-
# We replace " - "/"- " with ": " to match Launchbox's naming convention
164-
search_term = re.sub(DASH_COLON_REGEX, ": ", search_term).lower()
165-
166-
# Check if game is scummvm shortname
167-
if platform_slug == UPS.SCUMMVM:
168-
search_term = await self._scummvm_format(search_term)
169-
fallback_rom = LaunchboxRom(launchbox_id=None, name=search_term)
170-
171-
index_entry = await self._remote.get_rom(
172-
search_term,
173-
platform_slug,
174-
assume_cache_present=True,
175-
)
145+
# LaunchBox indexes the filenames its own dumps use, so try that before
146+
# rewriting the name into something the title index might accept.
147+
index_entry = await self._remote.get_rom_by_file_name(fs_name, platform_slug)
148+
149+
if index_entry is None:
150+
# `keep_tags` prevents stripping content that is considered a tag, e.g., anything between `()` or `[]`.
151+
# By default, tags are still stripped to keep scan behavior consistent with previous versions.
152+
# If `keep_tags` is True, the full `fs_name` is used for searching.
153+
if not keep_tags:
154+
search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name)
155+
else:
156+
search_term = fs_name
157+
158+
# Resolve MAME arcade filename (e.g. wrlok_l3.zip) to its full title
159+
# via LaunchBox's Mame.xml before name-based lookup.
160+
if platform_slug == UPS.ARCADE:
161+
mame_entry = await self._remote.get_mame_entry(fs_name)
162+
if mame_entry:
163+
name = (mame_entry.get("Name") or "").strip()
164+
if name:
165+
search_term = name
166+
fallback_rom = LaunchboxRom(launchbox_id=None, name=name)
167+
168+
# We replace " - "/"- " with ": " to match Launchbox's naming convention
169+
search_term = re.sub(DASH_COLON_REGEX, ": ", search_term).lower()
170+
171+
# Check if game is scummvm shortname
172+
if platform_slug == UPS.SCUMMVM:
173+
search_term = await self._scummvm_format(search_term)
174+
fallback_rom = LaunchboxRom(launchbox_id=None, name=search_term)
175+
176+
index_entry = await self._remote.get_rom(
177+
search_term,
178+
platform_slug,
179+
assume_cache_present=True,
180+
)
176181

177182
if not index_entry:
178183
return fallback_rom

backend/handler/metadata/launchbox_handler/local_source.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
from pathlib import Path, PureWindowsPath
1+
from pathlib import PureWindowsPath
22

33
from defusedxml import ElementTree as ET
44

55
from logger.logger import log
66

77
from .platforms import get_platform
88
from .types import LAUNCHBOX_PLATFORMS_DIR
9+
from .utils import file_name_forms
910

1011

1112
class LocalSource:
@@ -46,6 +47,8 @@ async def get_rom(self, fs_name: str, platform_slug: str) -> dict[str, str] | No
4647
app_base = PureWindowsPath(app_path).name.strip().lower()
4748
if app_base:
4849
indexed_val.setdefault(app_base, entry)
50+
for stem in file_name_forms(app_base):
51+
indexed_val.setdefault(f"stem:{stem}", entry)
4952

5053
title = (entry.get("Title") or "").strip().lower()
5154
if title:
@@ -68,18 +71,17 @@ async def get_rom(self, fs_name: str, platform_slug: str) -> dict[str, str] | No
6871
if not fs_key:
6972
return None
7073

71-
direct = indexed_val.get(fs_key)
72-
if direct is not None:
73-
return direct
74+
stems = file_name_forms(fs_name)
75+
probes = [
76+
fs_key,
77+
*(f"stem:{stem}" for stem in stems),
78+
*(f"title:{stem}" for stem in stems),
79+
f"title:{fs_key}",
80+
]
7481

75-
try:
76-
stem = Path(fs_name).stem.strip().lower()
77-
except Exception:
78-
stem = ""
82+
for probe in dict.fromkeys(probes):
83+
hit = indexed_val.get(probe)
84+
if hit is not None:
85+
return hit
7986

80-
if stem:
81-
by_title = indexed_val.get(f"title:{stem}")
82-
if by_title is not None:
83-
return by_title
84-
85-
return indexed_val.get(f"title:{fs_key}")
87+
return None

backend/handler/metadata/launchbox_handler/remote_source.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55

66
from .platforms import get_platform
77
from .types import (
8+
LAUNCHBOX_FILES_KEY,
89
LAUNCHBOX_MAME_KEY,
910
LAUNCHBOX_METADATA_ALTERNATE_NAME_KEY,
1011
LAUNCHBOX_METADATA_DATABASE_ID_KEY,
1112
LAUNCHBOX_METADATA_IMAGE_KEY,
1213
LAUNCHBOX_METADATA_NAME_KEY,
1314
)
15+
from .utils import deinvert_article, file_name_forms
1416

1517

1618
class RemoteSource:
@@ -49,6 +51,14 @@ async def get_rom(
4951
if lower != file_name_clean:
5052
candidates.append(lower)
5153

54+
# Dump filenames invert leading articles, so the inverted form is tried
55+
# against both indexes before giving up.
56+
for candidate in list(candidates):
57+
deinverted = deinvert_article(candidate)
58+
if deinverted:
59+
candidates.append(deinverted)
60+
candidates = list(dict.fromkeys(candidates))
61+
5262
for candidate in candidates:
5363
metadata_name_index_entry = await async_cache.hget(
5464
LAUNCHBOX_METADATA_NAME_KEY, f"{candidate}:{platform_name}"
@@ -81,6 +91,38 @@ async def get_rom(
8191

8292
return None
8393

94+
async def get_rom_by_file_name(
95+
self, file_name: str, platform_slug: str
96+
) -> dict | None:
97+
"""Resolve a ROM file name to its metadata entry via LaunchBox's Files.xml.
98+
99+
The dump ships a filename to title mapping, the only route to games whose
100+
files are named nothing like them (MS-DOS, Amiga, Arcade). On sets where
101+
the mapping is just the filename again it simply misses.
102+
"""
103+
platform_name = get_platform(platform_slug).get("name")
104+
if not platform_name:
105+
return None
106+
107+
for candidate in file_name_forms(file_name):
108+
entry = await async_cache.hget(
109+
LAUNCHBOX_FILES_KEY, f"{candidate}:{platform_name}"
110+
)
111+
if not entry:
112+
continue
113+
114+
game_name = (json.loads(entry).get("GameName") or "").strip()
115+
if not game_name:
116+
continue
117+
118+
index_entry = await self.get_rom(
119+
game_name, platform_slug, assume_cache_present=True
120+
)
121+
if index_entry:
122+
return index_entry
123+
124+
return None
125+
84126
async def get_mame_entry(self, file_name: str) -> dict | None:
85127
"""Resolve a MAME arcade filename to its LaunchBox MAME entry.
86128

backend/handler/metadata/launchbox_handler/utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from pathlib import Path
44

55
from handler.filesystem.base_handler import region_name_to_provider_shortcode
6+
from models.base import compute_file_name_no_ext, compute_file_name_no_tags
7+
from models.rom import ARTICLES
68

79
from .types import LAUNCHBOX_LOCAL_DIR
810

@@ -15,6 +17,46 @@
1517
"the netherlands": "nl",
1618
}
1719

20+
# Articles No-Intro moves to the end of a title ("Legend of Zelda, The"), which
21+
# LaunchBox keeps in front. The article has to sit at the end of the title or
22+
# right before a subtitle colon, so the group is anchored on both sides.
23+
_INVERTED_ARTICLE_REGEX = re.compile(
24+
rf"^(?P<title>.+?), (?P<article>{'|'.join(ARTICLES)})(?P<subtitle>:.*)?$",
25+
re.IGNORECASE,
26+
)
27+
28+
29+
def deinvert_article(term: str) -> str | None:
30+
"""Move a trailing article back to the front of a title.
31+
32+
"legend of zelda, the: ocarina of time" becomes
33+
"the legend of zelda: ocarina of time". Returns None when the term isn't in
34+
the inverted form.
35+
"""
36+
match = _INVERTED_ARTICLE_REGEX.match(term.strip())
37+
if not match:
38+
return None
39+
40+
subtitle = match.group("subtitle") or ""
41+
return f"{match.group('article')} {match.group('title')}{subtitle}"
42+
43+
44+
def file_name_forms(file_name: str) -> list[str]:
45+
"""Lowercased extension-less forms of a file name, most specific first.
46+
47+
Reduces both sides of a filename comparison to the same shapes: a library of
48+
`.zip` archives has to reach a LaunchBox entry naming a `.z64`, and a
49+
No-Intro stem still carries region tags a title never has.
50+
"""
51+
forms = [
52+
form.strip().lower()
53+
for form in (
54+
compute_file_name_no_ext(file_name),
55+
compute_file_name_no_tags(file_name),
56+
)
57+
]
58+
return list(dict.fromkeys(form for form in forms if form))
59+
1860

1961
def launchbox_region_to_shortcode(region_name: str | None) -> str | None:
2062
"""Map a LaunchBox image Region name to a provider shortcode (e.g. "us").

backend/handler/scan_handler.py

Lines changed: 63 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,61 @@ async def scan_firmware(
331331
return Firmware(**firmware_attrs)
332332

333333

334+
async def resolve_launchbox_rom(
335+
*,
336+
rom: Rom,
337+
fs_name: str,
338+
platform_slug: str,
339+
scan_type: ScanType,
340+
playmatch_rom: PlaymatchRomMatch,
341+
remote_enabled: bool,
342+
) -> LaunchboxRom:
343+
"""Resolve a ROM's LaunchBox match, by ID where one is known, else by filename."""
344+
# An ID already on the ROM is a decision (often a manual match), so it is
345+
# never traded for a filename guess.
346+
if (
347+
remote_enabled
348+
and rom.launchbox_id
349+
and (
350+
scan_type == ScanType.UPDATE
351+
or (scan_type == ScanType.UNMATCHED and not rom.launchbox_metadata)
352+
)
353+
):
354+
return await meta_launchbox_handler.get_rom_by_id(
355+
rom.launchbox_id,
356+
remote_enabled=True,
357+
fs_name=fs_name,
358+
platform_slug=platform_slug,
359+
)
360+
361+
launchbox_rom = LaunchboxRom(launchbox_id=None)
362+
363+
if playmatch_rom["launchbox_id"] is not None and remote_enabled:
364+
log.debug(
365+
f"{hl(fs_name)} identified by Playmatch as LaunchBox "
366+
f"{hl(str(playmatch_rom['launchbox_id']), color=BLUE)} {emoji.EMOJI_ALIEN_MONSTER}",
367+
extra=LOGGER_MODULE_NAME,
368+
)
369+
launchbox_rom = await meta_launchbox_handler.get_rom_by_id(
370+
playmatch_rom["launchbox_id"],
371+
remote_enabled=True,
372+
fs_name=fs_name,
373+
platform_slug=platform_slug,
374+
)
375+
376+
# Playmatch suggests an ID the metadata store may not hold, and on some
377+
# platforms it answers for nearly every ROM. Letting that miss stand would
378+
# strand the whole platform unmatched, so the filename lookup still runs.
379+
if not launchbox_rom.get("launchbox_id"):
380+
launchbox_rom = await meta_launchbox_handler.get_rom(
381+
fs_name,
382+
platform_slug,
383+
remote_enabled=remote_enabled,
384+
)
385+
386+
return launchbox_rom
387+
388+
334389
async def scan_rom(
335390
scan_type: ScanType,
336391
platform: Platform,
@@ -726,48 +781,14 @@ async def fetch_launchbox_rom(
726781
and rom.platform_slug in LAUNCHBOX_PLATFORM_LIST
727782
)
728783
):
729-
if (
730-
scan_type == ScanType.UPDATE
731-
and rom.launchbox_id
732-
and launchbox_remote_enabled
733-
):
734-
launchbox_rom = await meta_launchbox_handler.get_rom_by_id(
735-
rom.launchbox_id,
736-
remote_enabled=True,
737-
fs_name=rom_attrs["fs_name"],
738-
platform_slug=platform_slug,
739-
)
740-
elif (
741-
scan_type == ScanType.UNMATCHED
742-
and rom.launchbox_id
743-
and not rom.launchbox_metadata
744-
and launchbox_remote_enabled
745-
):
746-
# ID was set manually but metadata was never fetched
747-
launchbox_rom = await meta_launchbox_handler.get_rom_by_id(
748-
rom.launchbox_id,
749-
remote_enabled=True,
750-
fs_name=rom_attrs["fs_name"],
751-
platform_slug=platform_slug,
752-
)
753-
elif playmatch_rom["launchbox_id"] is not None and launchbox_remote_enabled:
754-
log.debug(
755-
f"{hl(rom_attrs['fs_name'])} identified by Playmatch as LaunchBox "
756-
f"{hl(str(playmatch_rom['launchbox_id']), color=BLUE)} {emoji.EMOJI_ALIEN_MONSTER}",
757-
extra=LOGGER_MODULE_NAME,
758-
)
759-
launchbox_rom = await meta_launchbox_handler.get_rom_by_id(
760-
playmatch_rom["launchbox_id"],
761-
remote_enabled=True,
762-
fs_name=rom_attrs["fs_name"],
763-
platform_slug=platform_slug,
764-
)
765-
else:
766-
launchbox_rom = await meta_launchbox_handler.get_rom(
767-
rom_attrs["fs_name"],
768-
platform_slug,
769-
remote_enabled=launchbox_remote_enabled,
770-
)
784+
launchbox_rom = await resolve_launchbox_rom(
785+
rom=rom,
786+
fs_name=str(rom_attrs["fs_name"]),
787+
platform_slug=platform_slug,
788+
scan_type=scan_type,
789+
playmatch_rom=playmatch_rom,
790+
remote_enabled=launchbox_remote_enabled,
791+
)
771792

772793
metadata = launchbox_rom.get("launchbox_metadata")
773794
if metadata:

0 commit comments

Comments
 (0)