feat: read batocera md5 written as a list

batocera.linux 6b08b873 lets a biosFiles item declare its md5 as a
list of strings, and checkBios accepts a file matching any of them.
The scrapers join the values with commas, as for Recalbox, and record
md5_list so the export writes the list back as a list, byte for byte.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 07:39:25 +02:00
1 parent e86defdb1e
commit 0b4504992b
9 files changed
+298 -13

No files matched your search

+40 -5
View File
@@ -23,7 +23,9 @@ _ENTRY_START = re.compile(r'^(\s{4})"([^"]+)":\s*\{')
_INDENT = " " * 4
# One BIOS item: a dict literal with no nested braces.
_ITEM = re.compile(r"\{[^{}\n]*\}")
_MD5_VALUE = re.compile(r'("md5"\s*:\s*")[^"]*(")')
# The md5 of one item: a string, or a list of strings since batocera.linux
# 6b08b873 (checkBios accepts any of them).
_MD5_VALUE = re.compile(r'("md5"\s*:\s*)("[^"]*"|\[[^\[\]]*\])')
_STRING = re.compile(r'"(?:[^"\\\n]|\\.)*"')
@@ -56,6 +58,17 @@ def _closing_line(text: str, start: int) -> int | None:
return None
def _md5_literal(value: str | list[str], spaced: bool = True) -> str:
"""An md5 as the script writes it: a quoted string, or a list of them
with Batocera's spacing inside the brackets (`[ "a", "b" ]`)."""
if isinstance(value, str):
return f'"{value}"'
if not value:
return "[]"
inner = ", ".join(f'"{v}"' for v in value)
return f"[ {inner} ]" if spaced else f"[{inner}]"
def _note_key(text: str) -> tuple[str, str]:
"""(file, zippedFile) of the item a line holds, ("", "") for none."""
path = re.search(r'"file"\s*:\s*"([^"]+)"', text)
@@ -68,7 +81,8 @@ class Exporter(BaseExporter):
# checkBios compares one md5 for a plain file. altmd5 is read only for
# a zippedFile: retrodream's boot.bin written with the first of four
# md5 refused the three other dumps the core accepts.
# md5 refused the three other dumps the core accepts. An item the
# platform writes with a list of md5 keeps its list (md5_list).
SINGLE_HASH_FIELD = "md5"
# checkBios reads emulator names from es_systems, and Batocera's
@@ -164,8 +178,25 @@ class Exporter(BaseExporter):
return False
return fe.platform is not None or self._addition_keys(fe) is not None
@staticmethod
def md5_list(fe: NativeFile) -> bool:
"""Whether the platform writes this item's md5 as a list. Its
checker then accepts any listed value, so every one is written."""
return fe.platform is not None and bool(fe.platform.get("md5_list"))
def md5_value(self, fe: NativeFile) -> str | list[str]:
"""The md5 an item is written with, in the form the platform used."""
if self.md5_list(fe):
return fe.hashes("md5")
return self.single_value(fe, "md5")
def written(self, fe: NativeFile, field_name: str) -> str:
if field_name == "md5" and self.md5_list(fe):
return ",".join(fe.hashes("md5"))
return super().written(fe, field_name)
def states(self, fe: NativeFile, field_name: str) -> bool:
if field_name == "md5" and "md5" in fe.filled:
if field_name == "md5" and "md5" in fe.filled and not self.md5_list(fe):
return bool(self.single_value(fe, "md5"))
return super().states(fe, field_name)
@@ -222,7 +253,7 @@ class Exporter(BaseExporter):
def _bios_item(self, fe: NativeFile, runner: list[str] | None, originals: dict) -> str:
# The platform states an unhashed file as an empty md5 rather
# than leaving it out, and so do we.
item = [f'"md5": "{self.single_value(fe, "md5")}"']
item = [f'"md5": {_md5_literal(self.md5_value(fe))}']
alt = fe.native("alt_md5", "")
if alt:
item.append(f'"altmd5": "{alt}"')
@@ -379,7 +410,11 @@ class Exporter(BaseExporter):
if {k for k in {*ours, *theirs} if ours.get(k) != theirs.get(k)} - {"md5"}:
return None
patched = _MD5_VALUE.sub(
lambda m: m.group(1) + str(ours["md5"]) + m.group(2), match.group(0), count=1
lambda m: m.group(1) + _md5_literal(
ours["md5"], spaced=not m.group(2).startswith("[\"")
),
match.group(0),
count=1,
)
edits.append((match.start(), match.end(), patched))
if last is None:
+19
View File
@@ -74,6 +74,25 @@ class ChangeSet:
MAX_RESPONSE_SIZE = 50 * 1024 * 1024 # 50 MB
def md5_field(raw: object) -> tuple[list[str], bool]:
"""The md5 values a batocera-systems item declares, and whether it
writes them as a list.
Since batocera.linux 6b08b873 an item's md5 is one string or a list of
strings, and checkBios accepts a file whose md5 is any of them. The
platform YAML joins the values with commas, as it does for Recalbox, and
records the list form so the export writes a list back as a list: a
one-item list and an empty one are not the same text as a string.
"""
if raw is None:
return [], False
if isinstance(raw, str):
return ([raw] if raw else []), False
if isinstance(raw, list) and all(isinstance(item, str) for item in raw):
return [item for item in raw if item], True
raise ValueError(f"md5 is neither a string nor a list of strings: {raw!r}")
def requirement_entry(req: BiosRequirement) -> dict:
"""Serialize a requirement to a platform YAML file entry.
+9 -2
View File
@@ -24,6 +24,7 @@ from .base_scraper import (
BaseScraper,
BiosRequirement,
github_headers,
md5_field,
requirement_entry,
)
from .batocera_launchers import extract_launchers
@@ -420,10 +421,16 @@ def requirements_from_systems(
for bios in sys_data.get("biosFiles", []):
declared_path = bios.get("file", "")
file_path = declared_path
md5 = _resolve_truncated_md5(bios.get("md5", ""), md5_index)
values, as_list = md5_field(bios.get("md5"))
md5 = ",".join(
_resolve_truncated_md5(value, md5_index) for value in values
)
zipped_file = bios.get("zippedFile", "")
if strip and file_path.startswith(strip):
file_path = file_path[len(strip):]
native: dict[str, object] = {"native_name": sys_data.get("name", "")}
if as_list:
native["md5_list"] = True
requirements.append(
BiosRequirement(
name=file_path.split("/")[-1],
@@ -434,7 +441,7 @@ def requirements_from_systems(
required=True,
zipped_file=zipped_file or None,
native_id=sys_key,
native={"native_name": sys_data.get("name", "")},
native=native,
)
)
return requirements
+15 -3
View File
@@ -22,6 +22,7 @@ try:
BaseScraper,
BiosRequirement,
fetch_github_latest_version,
md5_field,
requirement_entry,
)
from .rocknix_launchers import extract_launchers
@@ -31,6 +32,7 @@ except ImportError:
BaseScraper,
BiosRequirement,
fetch_github_latest_version,
md5_field,
requirement_entry,
)
from rocknix_launchers import extract_launchers
@@ -133,6 +135,10 @@ _SYSTEM_HEADER = re.compile(
r'\s*"biosFiles"\s*:\s*\[',
)
_FIELD = re.compile(r'"(\w+)"\s*:\s*"([^"]*)"')
# A field written as a list of strings (`"md5": [ "a", "b" ]`), the form
# batocera.linux 6b08b873 gives the md5 of a file several dumps satisfy.
_LIST_FIELD = re.compile(r'"(\w+)"\s*:\s*\[([^\[\]]*)\]')
_LIST_ITEM = re.compile(r'"([^"]*)"')
def _slice_bios_block(source: str, start: int) -> tuple[str, int]:
@@ -162,7 +168,9 @@ def parse_systems(source: str) -> dict[str, dict]:
body, _ = _slice_bios_block(source, header.end() - 1)
files = []
for raw_entry in re.findall(r"\{[^{}]*\}", body):
fields = dict(_FIELD.findall(raw_entry))
fields: dict[str, str | list[str]] = dict(_FIELD.findall(raw_entry))
for key, items in _LIST_FIELD.findall(raw_entry):
fields[key] = _LIST_ITEM.findall(items)
if fields.get("file"):
files.append(fields)
systems[header.group("key")] = {
@@ -236,15 +244,19 @@ class Scraper(BaseScraper):
# Declared paths are relative to the storage root: strip the
# leading bios/ so destinations match base_destination
destination = path[len("bios/"):] if path.startswith("bios/") else path
values, as_list = md5_field(bios.get("md5"))
native: dict[str, object] = {"native_name": entry["name"]}
if as_list:
native["md5_list"] = True
req = BiosRequirement(
name=destination.rsplit("/", 1)[-1],
system=system_slug,
md5=bios.get("md5", "") or None,
md5=",".join(values) or None,
alt_md5=bios.get("altmd5", "") or None,
destination=destination,
required=True,
native_id=key,
native={"native_name": entry["name"]},
native=native,
)
if bios.get("zippedFile"):
req.zipped_file = bios["zippedFile"]