mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
* fix(sync): resolve RetroArch paths across emulators sharing a core folder Several RomM emulator ids map to one RetroArch folder (mednafen_psx_hw, beetle_psx_hw and the raw "Beetle PSX HW" all sync through saves/Beetle PSX HW/). The manifest built paths from whichever id an asset carried, but GET parsed the folder back to the canonical id and matched it exactly, so an asset filed under another alias was advertised and then 404'd. Uploads also started a second autosave history beside the alias's. The manifest now lists the newest asset per folder, and GET, PUT and DELETE resolve a path across every alias. Rewrites of an existing unslotted save or state stay under that row's own emulator. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(sync): prefer on-disk alias saves and share newest-per-key grouping pr-ready step 2 (code-review --fix). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(sync): one alias helper and a lineage computed once pr-ready step 3 (simplify). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * test(sync): type the alias fixture's asset kind pr-ready step 4 (review-polish). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * test(sync): isolate the asset dir per RetroArch sync test The alias tests write different bytes to the same asset paths, and user and ROM ids repeat across xdist worker databases, so parallel runs raced on those files. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(sync): serve the newest on-disk RetroArch state in a slot Missing states are dropped before slot grouping for the manifest and reads, so a newer missing state (or alias) no longer hides an older one still on disk. A PUT still matches the missing row and revives it in place. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(sync): fall back past a RetroArch file that vanished unflagged Addresses Greptile's review on #5193: a state whose file was deleted before `missing_from_fs` was set still won its slot. The manifest now rebuilds when hashing flags an asset, and GET flags a missing file and resolves again, so an older asset at the path is listed and served. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
811 lines
27 KiB
Python
811 lines
27 KiB
Python
"""Maps RetroArch Cloud Sync's ``<root>/<core>/<file>`` namespace onto RomM's
|
|
per-ROM asset storage, and back again for the ``manifest.server`` it diffs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
from collections import defaultdict
|
|
from collections.abc import (
|
|
Awaitable,
|
|
Callable,
|
|
Collection,
|
|
Hashable,
|
|
Iterable,
|
|
Sequence,
|
|
)
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from functools import partial
|
|
from typing import Literal, NamedTuple
|
|
|
|
from fastapi import UploadFile
|
|
|
|
from config import MAX_SAVES_PER_SLOT
|
|
from handler.asset_store import (
|
|
prune_save_slot,
|
|
reserve_version_name,
|
|
strip_datetime_tag,
|
|
)
|
|
from handler.database import (
|
|
db_rom_handler,
|
|
db_save_handler,
|
|
db_screenshot_handler,
|
|
db_state_handler,
|
|
)
|
|
from handler.filesystem import fs_asset_handler, fs_retroarch_sync_handler
|
|
from handler.filesystem.assets_handler import hash_save_content
|
|
from handler.redis_handler import async_cache
|
|
from handler.scan_handler import scan_save
|
|
from handler.sync.retroarch import psp
|
|
from handler.sync.retroarch.emulator_names import (
|
|
retroarch_aliases,
|
|
to_retroarch_dir_name,
|
|
to_romm_emulator,
|
|
)
|
|
from logger.logger import log
|
|
from models.assets import (
|
|
AUTOSAVE_SLOT,
|
|
EMULATOR_MAX_LENGTH,
|
|
Save,
|
|
SaveLineage,
|
|
Screenshot,
|
|
State,
|
|
)
|
|
from models.rom import Rom
|
|
from models.user import User
|
|
from utils.filesystem import check_filename_length
|
|
|
|
AssetKind = Literal["saves", "states"]
|
|
|
|
MANIFEST_FILE_NAME = "manifest.server"
|
|
|
|
ASSET_ROOTS: tuple[AssetKind, ...] = ("saves", "states")
|
|
|
|
# RetroArch's config/thumbnails/system sync categories belong to no ROM, so
|
|
# they're kept as opaque per-user blobs.
|
|
BLOB_CATEGORIES = ("config", "thumbnails", "system")
|
|
|
|
# `<game>.state`, `<game>.state3`, `<game>.state.auto`: the auto suffix makes
|
|
# this a two-segment extension, which splitext alone gets wrong.
|
|
STATE_SUFFIX_PATTERN = re.compile(r"\.state\d*(?:\.auto)?$", re.IGNORECASE)
|
|
|
|
# Rehashing every asset on each manifest fetch would read gigabytes. Keys carry
|
|
# size and mtime, so a changed file misses the cache instead of going stale.
|
|
HASH_CACHE_TTL_SECONDS = 60 * 60 * 24
|
|
HASH_CONCURRENCY = 8
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RetroArchSyncPath:
|
|
"""A parsed client-side path, e.g. ``saves/Snes9x/Super Mario World.srm``."""
|
|
|
|
kind: AssetKind
|
|
emulator: str | None
|
|
file_name: str
|
|
|
|
@property
|
|
def is_state_screenshot(self) -> bool:
|
|
return self.kind == "states" and is_state_screenshot_path(self.file_name)
|
|
|
|
|
|
def split_segments(path: str) -> list[str] | None:
|
|
"""A client path's non-empty segments, or None if any is `.`, `..` or holds a NUL."""
|
|
segments = [segment for segment in path.strip("/").split("/") if segment]
|
|
if any(
|
|
segment in (os.curdir, os.pardir) or "\x00" in segment for segment in segments
|
|
):
|
|
return None
|
|
return segments
|
|
|
|
|
|
def emulator_from_dir_name(dir_name: str) -> str | None:
|
|
"""RomM's `emulator` for a core folder name, or None if too long to store."""
|
|
emulator = to_romm_emulator(dir_name)
|
|
return emulator if len(emulator) <= EMULATOR_MAX_LENGTH else None
|
|
|
|
|
|
def parse_retroarch_sync_path(path: str) -> RetroArchSyncPath | None:
|
|
"""Parse a ``<root>/[<core>/]<file>`` client path, or None if unsupported."""
|
|
segments = split_segments(path)
|
|
if segments is None or not 2 <= len(segments) <= 3:
|
|
return None
|
|
|
|
if segments[0] not in ASSET_ROOTS:
|
|
return None
|
|
kind = segments[0]
|
|
|
|
emulator = None
|
|
if len(segments) == 3:
|
|
emulator = emulator_from_dir_name(segments[1])
|
|
if emulator is None:
|
|
return None
|
|
|
|
return RetroArchSyncPath(kind=kind, emulator=emulator, file_name=segments[-1])
|
|
|
|
|
|
def is_state_screenshot_path(file_name: str) -> bool:
|
|
"""Whether a `states/...` file is the `<state file name>.png` RetroArch syncs."""
|
|
return file_name.lower().endswith(".png")
|
|
|
|
|
|
def state_name_of_screenshot(file_name: str) -> str:
|
|
"""The state file name a `<state file name>.png` screenshot belongs to."""
|
|
return file_name[: -len(".png")]
|
|
|
|
|
|
def game_name_from_file_name(kind: AssetKind, file_name: str) -> str:
|
|
"""The ROM file name (minus extension) an asset file belongs to."""
|
|
if kind == "states":
|
|
base = (
|
|
state_name_of_screenshot(file_name)
|
|
if is_state_screenshot_path(file_name)
|
|
else file_name
|
|
)
|
|
stripped = STATE_SUFFIX_PATTERN.sub("", base)
|
|
if stripped != base:
|
|
return stripped
|
|
return os.path.splitext(base)[0]
|
|
|
|
return os.path.splitext(file_name)[0]
|
|
|
|
|
|
def state_slot_suffix(file_name: str) -> str:
|
|
"""The RetroArch load slot (``state``, ``state1``, ``state.auto``) a state name ends in."""
|
|
match = STATE_SUFFIX_PATTERN.search(file_name)
|
|
if match:
|
|
return match.group(0)[1:].lower()
|
|
return os.path.splitext(file_name)[1][1:].lower()
|
|
|
|
|
|
def retroarch_folder(emulator: str | None) -> str | None:
|
|
"""The core folder an `emulator` syncs through, which its aliases share."""
|
|
return to_retroarch_dir_name(emulator) if emulator else None
|
|
|
|
|
|
def recency_key(asset: Save | State) -> tuple[datetime, int]:
|
|
"""Recency order, with `id` breaking ties on a shared timestamp."""
|
|
return (asset.updated_at, asset.id)
|
|
|
|
|
|
def on_disk_recency_key(asset: Save | State) -> tuple[bool, datetime, int]:
|
|
"""Recency order that ranks a file on disk above any missing one."""
|
|
return (not asset.missing_from_fs, *recency_key(asset))
|
|
|
|
|
|
def newest_per_key[A: (Save, State), K: Hashable](
|
|
assets: Iterable[A],
|
|
key: Callable[[A], K],
|
|
rank: Callable[[A], tuple[object, ...]] = recency_key,
|
|
) -> dict[K, A]:
|
|
"""The highest ranked of the assets sharing each ``key``, by key."""
|
|
latest: dict[K, A] = {}
|
|
for asset in assets:
|
|
current = latest.get(asset_key := key(asset))
|
|
if current is None or rank(asset) > rank(current):
|
|
latest[asset_key] = asset
|
|
return latest
|
|
|
|
|
|
def _state_slot_key(state: State) -> tuple[int, str | None, str]:
|
|
return (
|
|
state.rom_id,
|
|
retroarch_folder(state.emulator),
|
|
state_slot_suffix(state.file_name),
|
|
)
|
|
|
|
|
|
def group_states_by_slot(
|
|
states: Iterable[State],
|
|
) -> dict[tuple[int, str | None, str], State]:
|
|
"""The newest state in each (rom, core folder, slot) bucket, preferring one on disk."""
|
|
return newest_per_key(states, _state_slot_key, rank=on_disk_recency_key)
|
|
|
|
|
|
def canonical_state_file_name(rom: Rom, slot_suffix: str) -> str:
|
|
"""The name RetroArch gives this slot, which the manifest advertises."""
|
|
return f"{rom.fs_name_no_ext}.{slot_suffix}"
|
|
|
|
|
|
def states_in_slot(
|
|
user: User, rom: Rom, emulator: str | None, requested_file_name: str
|
|
) -> list[State]:
|
|
"""Every state, under any alias, in the slot a canonical name points at."""
|
|
key = (rom.id, retroarch_folder(emulator), state_slot_suffix(requested_file_name))
|
|
return [
|
|
state
|
|
for state in db_state_handler.get_states(user_id=user.id, rom_ids=[rom.id])
|
|
if _state_slot_key(state) == key
|
|
]
|
|
|
|
|
|
def resolve_state_by_slot(
|
|
user: User,
|
|
rom: Rom,
|
|
emulator: str | None,
|
|
requested_file_name: str,
|
|
prefer_on_disk: bool = True,
|
|
) -> State | None:
|
|
"""The state the slot a canonical name points at serves, whatever its name.
|
|
|
|
Args:
|
|
prefer_on_disk: False ranks a `missing_from_fs` row by recency alone, so
|
|
an upload revives the newest state in place.
|
|
"""
|
|
return max(
|
|
states_in_slot(user, rom, emulator, requested_file_name),
|
|
key=on_disk_recency_key if prefer_on_disk else recency_key,
|
|
default=None,
|
|
)
|
|
|
|
|
|
# RetroArch's save RAM, the one save file that goes through slots. Companions
|
|
# such as `.rtc` stay single unslotted files, since a slot holds one save.
|
|
SLOTTED_SAVE_EXTENSION = "srm"
|
|
|
|
|
|
def save_lineage(emulator: str | None, file_name: str) -> SaveLineage | None:
|
|
"""The `autosave` lineage a RetroArch save path maps to, or None for an unslotted file."""
|
|
lineage = SaveLineage.of(emulator, file_name)
|
|
return lineage if lineage.file_extension == SLOTTED_SAVE_EXTENSION else None
|
|
|
|
|
|
def advertised_save_name(save: Save) -> str:
|
|
"""A version's name without its tag when that still names its ROM, else RetroArch's name for it."""
|
|
name = strip_datetime_tag(save.file_name)
|
|
if (
|
|
game_name_from_file_name("saves", name).lower()
|
|
== save.rom.fs_name_no_ext.lower()
|
|
):
|
|
return name
|
|
return f"{save.rom.fs_name_no_ext}{os.path.splitext(name)[1]}"
|
|
|
|
|
|
def _unslotted_saves_at(
|
|
user: User, rom: Rom, emulator: str | None, file_name: str
|
|
) -> list[Save]:
|
|
"""The unslotted saves at a RetroArch path, under any of ``emulator``'s aliases."""
|
|
saves = db_save_handler.get_saves_at_paths(
|
|
user_id=user.id,
|
|
rom_id=rom.id,
|
|
file_paths=[
|
|
build_asset_file_path(user, rom, "saves", alias)
|
|
for alias in retroarch_aliases(emulator)
|
|
],
|
|
file_name=file_name,
|
|
)
|
|
return [save for save in saves if save.slot is None]
|
|
|
|
|
|
def _unslotted_save_at(
|
|
user: User, rom: Rom, emulator: str | None, file_name: str
|
|
) -> Save | None:
|
|
# Prefers a file on disk, which is the one the manifest lists.
|
|
return max(
|
|
_unslotted_saves_at(user, rom, emulator, file_name),
|
|
key=on_disk_recency_key,
|
|
default=None,
|
|
)
|
|
|
|
|
|
def _alias_lineages(lineage: SaveLineage) -> list[SaveLineage]:
|
|
"""``lineage`` under each of its emulator's aliases."""
|
|
return [
|
|
lineage._replace(emulator=alias)
|
|
for alias in retroarch_aliases(lineage.emulator)
|
|
]
|
|
|
|
|
|
def _lineage_head(user: User, rom: Rom, lineage: SaveLineage) -> Save | None:
|
|
"""The newest `autosave` version across ``lineage``'s aliases."""
|
|
return db_save_handler.get_lineage_head(
|
|
user_id=user.id,
|
|
rom_id=rom.id,
|
|
slot=AUTOSAVE_SLOT,
|
|
lineages=_alias_lineages(lineage),
|
|
)
|
|
|
|
|
|
def resolve_save(
|
|
user: User, rom: Rom, emulator: str | None, requested_file_name: str
|
|
) -> Save | None:
|
|
"""The save a RetroArch path serves: the newest `autosave` version, else the unslotted save there."""
|
|
lineage = save_lineage(emulator, requested_file_name)
|
|
head = _lineage_head(user, rom, lineage) if lineage else None
|
|
return head or _unslotted_save_at(user, rom, emulator, requested_file_name)
|
|
|
|
|
|
def saves_at_path(
|
|
user: User, rom: Rom, emulator: str | None, requested_file_name: str
|
|
) -> list[Save]:
|
|
"""Every save a RetroArch path covers, so deleting it leaves nothing to resurface."""
|
|
lineage = save_lineage(emulator, requested_file_name)
|
|
if not lineage:
|
|
return _unslotted_saves_at(user, rom, emulator, requested_file_name)
|
|
|
|
folded = requested_file_name.casefold()
|
|
return [
|
|
save
|
|
for save in db_save_handler.get_saves(
|
|
user_id=user.id,
|
|
rom_ids=[rom.id],
|
|
slot=AUTOSAVE_SLOT,
|
|
with_unslotted=True,
|
|
lineages=_alias_lineages(lineage),
|
|
)
|
|
# The manifest hides unslotted ones behind the head, so whatever their
|
|
# case they would resurface once the versions go.
|
|
if save.slot is not None or save.file_name.casefold() == folded
|
|
]
|
|
|
|
|
|
SaveUpload = Literal["created", "updated", "unchanged"]
|
|
|
|
|
|
async def store_save(
|
|
user: User,
|
|
rom: Rom,
|
|
emulator: str | None,
|
|
requested_file_name: str,
|
|
body: UploadFile,
|
|
) -> SaveUpload:
|
|
"""File an upload as a new `autosave` version, or over the unslotted file it names.
|
|
|
|
Raises:
|
|
ValueError: The timestamped version name is too long.
|
|
"""
|
|
lineage = save_lineage(emulator, requested_file_name)
|
|
if not lineage:
|
|
return await _store_unslotted_save(
|
|
user, rom, emulator, requested_file_name, body
|
|
)
|
|
|
|
head = await asyncio.to_thread(_lineage_head, user, rom, lineage)
|
|
# A re-sent file adds no version, so it can't push real history past the cap.
|
|
content_hash = hash_save_content(body.file)
|
|
if head and content_hash and content_hash == head.content_hash:
|
|
return "unchanged"
|
|
|
|
asset_path = build_asset_file_path(user, rom, "saves", emulator)
|
|
version_name = await reserve_version_name(
|
|
user.id, rom.id, asset_path, requested_file_name
|
|
)
|
|
check_filename_length(version_name)
|
|
await fs_asset_handler.write_file(file=body, path=asset_path, filename=version_name)
|
|
scanned = await scan_save(
|
|
file_name=version_name,
|
|
user=user,
|
|
platform_fs_slug=rom.platform.fs_slug,
|
|
rom_id=rom.id,
|
|
emulator=emulator,
|
|
)
|
|
scanned.rom_id = rom.id
|
|
scanned.user_id = user.id
|
|
scanned.emulator = emulator
|
|
scanned.slot = AUTOSAVE_SLOT
|
|
await asyncio.to_thread(db_save_handler.add_save, save=scanned)
|
|
if MAX_SAVES_PER_SLOT:
|
|
await prune_save_slot(
|
|
user.id, rom.id, AUTOSAVE_SLOT, MAX_SAVES_PER_SLOT, lineage=lineage
|
|
)
|
|
|
|
return "created"
|
|
|
|
|
|
async def _store_unslotted_save(
|
|
user: User,
|
|
rom: Rom,
|
|
emulator: str | None,
|
|
file_name: str,
|
|
body: UploadFile,
|
|
) -> SaveUpload:
|
|
existing = await asyncio.to_thread(
|
|
_unslotted_save_at, user, rom, emulator, file_name
|
|
)
|
|
# Written where the existing row points, so it keeps tracking the bytes.
|
|
if existing:
|
|
emulator = existing.emulator
|
|
asset_path = build_asset_file_path(user, rom, "saves", emulator)
|
|
await fs_asset_handler.write_file(file=body, path=asset_path, filename=file_name)
|
|
scanned = await scan_save(
|
|
file_name=file_name,
|
|
user=user,
|
|
platform_fs_slug=rom.platform.fs_slug,
|
|
rom_id=rom.id,
|
|
emulator=emulator,
|
|
)
|
|
if existing:
|
|
await asyncio.to_thread(
|
|
db_save_handler.update_save,
|
|
existing.id,
|
|
{
|
|
"file_size_bytes": scanned.file_size_bytes,
|
|
"content_hash": scanned.content_hash,
|
|
"missing_from_fs": False,
|
|
},
|
|
)
|
|
return "updated"
|
|
|
|
scanned.rom_id = rom.id
|
|
scanned.user_id = user.id
|
|
scanned.emulator = emulator
|
|
await asyncio.to_thread(db_save_handler.add_save, save=scanned)
|
|
return "created"
|
|
|
|
|
|
def state_screenshot_dir(user: User, rom: Rom, emulator: str | None) -> str:
|
|
"""Per core, so same-named states under two cores keep separate screenshots."""
|
|
return fs_asset_handler.build_screenshots_file_path(
|
|
user=user,
|
|
platform_fs_slug=rom.platform.fs_slug,
|
|
rom_id=rom.id,
|
|
emulator=emulator,
|
|
)
|
|
|
|
|
|
def _match_state_screenshot(
|
|
user: User, state: State, screenshots: Iterable[Screenshot]
|
|
) -> Screenshot | None:
|
|
own_dir = state_screenshot_dir(user, state.rom, state.emulator)
|
|
rom_dir = state_screenshot_dir(user, state.rom, None)
|
|
candidates = [
|
|
s
|
|
for s in screenshots
|
|
if s.file_path == own_dir or not s.file_path.startswith(f"{rom_dir}/")
|
|
]
|
|
exact_name = f"{state.file_name}.png"
|
|
exact = [s for s in candidates if s.file_name == exact_name]
|
|
if exact:
|
|
return min(exact, key=lambda s: (s.file_path != own_dir, -s.id))
|
|
|
|
# `State.screenshot` matches on the name stem, which a RetroArch slot name
|
|
# (`<rom>.state1`) shares with every other slot's and gallery shot.
|
|
if state.file_name_no_ext == state.rom.fs_name_no_ext:
|
|
return None
|
|
|
|
names = {state.file_name, state.file_name_no_ext}
|
|
stem_matches = [
|
|
s for s in candidates if s.file_name in names or s.file_name_no_ext in names
|
|
]
|
|
if not stem_matches:
|
|
return None
|
|
best = min(
|
|
stem_matches, key=lambda s: (s.file_name_no_ext != state.file_name, -s.id)
|
|
)
|
|
return None if best.is_gallery else best
|
|
|
|
|
|
def state_screenshots(user: User, states: Collection[State]) -> dict[int, Screenshot]:
|
|
"""The screenshot synced alongside each of these states, by state id."""
|
|
if not states:
|
|
return {}
|
|
|
|
by_rom: defaultdict[int, list[Screenshot]] = defaultdict(list)
|
|
for screenshot in db_screenshot_handler.get_screenshots(
|
|
user_id=user.id, rom_ids={state.rom_id for state in states}
|
|
):
|
|
by_rom[screenshot.rom_id].append(screenshot)
|
|
|
|
matches = {
|
|
state.id: _match_state_screenshot(user, state, by_rom[state.rom_id])
|
|
for state in states
|
|
}
|
|
return {state_id: shot for state_id, shot in matches.items() if shot}
|
|
|
|
|
|
def resolve_state_screenshot_by_slot(
|
|
user: User, rom: Rom, emulator: str | None, requested_file_name: str
|
|
) -> Screenshot | None:
|
|
"""The screenshot of the state a ``<slot>.png`` name resolves to."""
|
|
state = resolve_state_by_slot(
|
|
user, rom, emulator, state_name_of_screenshot(requested_file_name)
|
|
)
|
|
return state_screenshots(user, [state]).get(state.id) if state else None
|
|
|
|
|
|
def build_retroarch_sync_path(
|
|
kind: AssetKind, emulator: str | None, file_name: str
|
|
) -> str:
|
|
folder = retroarch_folder(emulator)
|
|
return f"{kind}/{folder}/{file_name}" if folder else f"{kind}/{file_name}"
|
|
|
|
|
|
def build_asset_file_path(
|
|
user: User, rom: Rom, kind: AssetKind, emulator: str | None
|
|
) -> str:
|
|
build_path = (
|
|
fs_asset_handler.build_saves_file_path
|
|
if kind == "saves"
|
|
else fs_asset_handler.build_states_file_path
|
|
)
|
|
return build_path(
|
|
user=user,
|
|
platform_fs_slug=rom.platform.fs_slug,
|
|
rom_id=rom.id,
|
|
emulator=emulator,
|
|
)
|
|
|
|
|
|
def parse_retroarch_sync_blob_path(path: str) -> str | None:
|
|
"""A blob-category client path as ``category/...``, or None if it isn't one."""
|
|
segments = split_segments(path)
|
|
if segments is None or len(segments) < 2:
|
|
return None
|
|
|
|
if segments[0] not in BLOB_CATEGORIES:
|
|
return None
|
|
|
|
return "/".join(segments)
|
|
|
|
|
|
def user_blob_path(user: User, blob_path: str) -> str:
|
|
"""Where a parsed blob path lives under the assets root, namespaced by user."""
|
|
return f"{fs_asset_handler.user_folder_path(user)}/retroarch/{blob_path}"
|
|
|
|
|
|
def _decoded(value: str | bytes | None) -> str | None:
|
|
if isinstance(value, bytes):
|
|
return value.decode()
|
|
return value or None
|
|
|
|
|
|
async def cached_hashes(
|
|
jobs: Sequence[tuple[str, Callable[[], Awaitable[str | None]]]],
|
|
) -> list[str | None]:
|
|
"""Each `(cache key, compute)` job's hash, read in one MGET with misses written back in one pipeline."""
|
|
if not jobs:
|
|
return []
|
|
|
|
digests = [
|
|
_decoded(value) for value in await async_cache.mget([k for k, _ in jobs])
|
|
]
|
|
misses = [i for i, digest in enumerate(digests) if digest is None]
|
|
semaphore = asyncio.Semaphore(HASH_CONCURRENCY)
|
|
|
|
async def compute(index: int) -> str | None:
|
|
async with semaphore:
|
|
return await jobs[index][1]()
|
|
|
|
computed = await asyncio.gather(*(compute(i) for i in misses))
|
|
fresh = {i: digest for i, digest in zip(misses, computed, strict=True) if digest}
|
|
if fresh:
|
|
async with async_cache.pipeline() as pipe:
|
|
for i, digest in fresh.items():
|
|
await pipe.set(jobs[i][0], digest, ex=HASH_CACHE_TTL_SECONDS)
|
|
await pipe.execute()
|
|
for i, digest in fresh.items():
|
|
digests[i] = digest
|
|
|
|
return digests
|
|
|
|
|
|
async def _blob_md5(disk_path: str) -> str | None:
|
|
try:
|
|
return await fs_retroarch_sync_handler.compute_file_md5(disk_path)
|
|
except (ValueError, OSError):
|
|
return None
|
|
|
|
|
|
async def build_blob_manifest_entries(user: User) -> list[dict[str, str]]:
|
|
blob_paths: list[str] = []
|
|
jobs: list[tuple[str, Callable[[], Awaitable[str | None]]]] = []
|
|
for category in BLOB_CATEGORIES:
|
|
prefix = user_blob_path(user, category)
|
|
for blob in await fs_retroarch_sync_handler.list_blob_files(prefix):
|
|
blob_path = f"{category}/{blob.relative_path}"
|
|
blob_paths.append(blob_path)
|
|
jobs.append(
|
|
(
|
|
f"romm:retroarch_sync:blob_md5:{user.id}:{blob_path}:{blob.size}:{blob.mtime}",
|
|
partial(_blob_md5, f"{prefix}/{blob.relative_path}"),
|
|
)
|
|
)
|
|
|
|
digests = await cached_hashes(jobs)
|
|
return [
|
|
{"path": blob_path, "hash": digest}
|
|
for blob_path, digest in zip(blob_paths, digests, strict=True)
|
|
if digest
|
|
]
|
|
|
|
|
|
def resolve_roms(
|
|
game_names: Iterable[str], can_see: Callable[[Rom], bool]
|
|
) -> dict[str, Rom]:
|
|
"""The ROM each game name belongs to, the first visible one by id when ambiguous."""
|
|
names = set(game_names)
|
|
exact: dict[str, Rom] = {}
|
|
# The lookup ignores case, so the client's exact spelling wins a tie.
|
|
folded: dict[str, Rom] = {}
|
|
for rom in db_rom_handler.get_roms_by_fs_names_no_ext(names):
|
|
if can_see(rom):
|
|
exact.setdefault(rom.fs_name_no_ext, rom)
|
|
folded.setdefault(rom.fs_name_no_ext.casefold(), rom)
|
|
|
|
resolved = {name: exact.get(name) or folded.get(name.casefold()) for name in names}
|
|
return {name: rom for name, rom in resolved.items() if rom}
|
|
|
|
|
|
def resolve_rom(game_name: str, can_see: Callable[[Rom], bool]) -> Rom | None:
|
|
return resolve_roms([game_name], can_see).get(game_name)
|
|
|
|
|
|
def _mark_missing_from_fs(asset: Save | State | Screenshot) -> None:
|
|
update = {"missing_from_fs": True}
|
|
# Not a write, so updated_at keeps ranking the asset.
|
|
if isinstance(asset, Save):
|
|
db_save_handler.update_save(asset.id, update, touch=False)
|
|
elif isinstance(asset, State):
|
|
db_state_handler.update_state(asset.id, update, touch=False)
|
|
else:
|
|
db_screenshot_handler.update_screenshot(asset.id, update)
|
|
asset.missing_from_fs = True
|
|
|
|
|
|
def flag_if_missing(asset: Save | State | Screenshot) -> bool:
|
|
"""Flag ``asset`` `missing_from_fs` when its file is gone.
|
|
|
|
Returns:
|
|
Whether it was gone.
|
|
"""
|
|
try:
|
|
if fs_asset_handler.validate_path(asset.full_path).is_file():
|
|
return False
|
|
except ValueError:
|
|
return False
|
|
_mark_missing_from_fs(asset)
|
|
return True
|
|
|
|
|
|
async def _asset_md5(asset: Save | State | Screenshot) -> str | None:
|
|
try:
|
|
return await fs_asset_handler.compute_file_md5(asset.full_path)
|
|
except FileNotFoundError:
|
|
_mark_missing_from_fs(asset)
|
|
except OSError as exc:
|
|
log.debug(f"Failed to compute MD5 for {asset.full_path}: {exc}")
|
|
return None
|
|
|
|
|
|
async def asset_md5s(
|
|
assets: Sequence[Save | State | Screenshot],
|
|
) -> list[str | None]:
|
|
"""Each asset's MD5, or None when unreadable; a vanished file flags its row."""
|
|
return await cached_hashes(
|
|
[
|
|
(
|
|
f"romm:retroarch_sync:md5:{asset.full_path}"
|
|
f":{asset.file_size_bytes}:{asset.updated_at.timestamp()}",
|
|
partial(_asset_md5, asset),
|
|
)
|
|
for asset in assets
|
|
]
|
|
)
|
|
|
|
|
|
class ManifestAsset(NamedTuple):
|
|
path: str
|
|
asset: Save | State | Screenshot
|
|
|
|
|
|
def _manifest_assets(
|
|
user: User, can_see: Callable[[Rom], bool], tree: AssetKind | None = None
|
|
) -> tuple[list[ManifestAsset], Sequence[Save]]:
|
|
"""The manifest's saves and states (only `tree`'s when given), plus the unslotted saves its PSP bundles come from."""
|
|
saves: Sequence[Save] = []
|
|
heads: Sequence[Save] = []
|
|
if tree != "states":
|
|
saves = db_save_handler.get_saves(user_id=user.id, slot_is_null=True)
|
|
heads = db_save_handler.get_lineage_heads(
|
|
user_id=user.id, slot=AUTOSAVE_SLOT, file_extension=SLOTTED_SAVE_EXTENSION
|
|
)
|
|
# Aliases of one core share its folder, so only the newest of them is listed.
|
|
head_by_folder = newest_per_key(
|
|
heads, lambda head: (head.rom_id, retroarch_folder(head.emulator))
|
|
)
|
|
unslotted = newest_per_key(
|
|
(
|
|
save
|
|
for save in saves
|
|
if not save.missing_from_fs
|
|
and not psp.is_psp_bundle_file_name(save.file_name)
|
|
and not (
|
|
save_lineage(save.emulator, save.file_name)
|
|
and (save.rom_id, retroarch_folder(save.emulator)) in head_by_folder
|
|
)
|
|
),
|
|
lambda save: (save.rom_id, retroarch_folder(save.emulator), save.file_name),
|
|
)
|
|
listed_saves = [
|
|
(advertised_save_name(head), head)
|
|
for head in head_by_folder.values()
|
|
if can_see(head.rom)
|
|
] + [(save.file_name, save) for save in unslotted.values() if can_see(save.rom)]
|
|
listed_states = (
|
|
[
|
|
(state.emulator, canonical_state_file_name(state.rom, slot_suffix), state)
|
|
for (_rom_id, _folder, slot_suffix), state in group_states_by_slot(
|
|
db_state_handler.get_states(user_id=user.id)
|
|
).items()
|
|
if not state.missing_from_fs and can_see(state.rom)
|
|
]
|
|
if tree != "saves"
|
|
else []
|
|
)
|
|
|
|
# A path carries no platform, so only the ROM that GET/PUT/DELETE would
|
|
# resolve it to may claim it; a same-named ROM elsewhere would shadow it.
|
|
owners = resolve_roms(
|
|
[game_name_from_file_name("saves", name) for name, _ in listed_saves]
|
|
+ [game_name_from_file_name("states", name) for _, name, _ in listed_states],
|
|
can_see,
|
|
)
|
|
|
|
def is_addressable(rom: Rom, kind: AssetKind, file_name: str) -> bool:
|
|
owner = owners.get(game_name_from_file_name(kind, file_name))
|
|
return owner is not None and owner.id == rom.id
|
|
|
|
assets = [
|
|
ManifestAsset(build_retroarch_sync_path("saves", save.emulator, name), save)
|
|
for name, save in listed_saves
|
|
if is_addressable(save.rom, "saves", name)
|
|
]
|
|
|
|
addressable_states = [
|
|
(emulator, file_name, state)
|
|
for emulator, file_name, state in listed_states
|
|
if is_addressable(state.rom, "states", file_name)
|
|
]
|
|
screenshots = state_screenshots(user, [state for _, _, state in addressable_states])
|
|
for emulator, file_name, state in addressable_states:
|
|
state_path = build_retroarch_sync_path("states", emulator, file_name)
|
|
assets.append(ManifestAsset(state_path, state))
|
|
|
|
screenshot = screenshots.get(state.id)
|
|
if screenshot and not screenshot.missing_from_fs:
|
|
assets.append(ManifestAsset(f"{state_path}.png", screenshot))
|
|
|
|
return assets, saves
|
|
|
|
|
|
async def list_manifest_paths(
|
|
user: User, can_see: Callable[[Rom], bool], tree: AssetKind
|
|
) -> list[str]:
|
|
"""`tree`'s manifest paths, sorted, without hashing or listing blobs."""
|
|
assets, saves = await asyncio.to_thread(_manifest_assets, user, can_see, tree)
|
|
paths = [entry.path for entry in assets]
|
|
paths += await psp.list_psp_member_paths(saves, can_see)
|
|
return sorted(paths)
|
|
|
|
|
|
async def build_manifest(
|
|
user: User, can_see: Callable[[Rom], bool]
|
|
) -> list[dict[str, str]]:
|
|
"""The server manifest RetroArch diffs against, sorted by path."""
|
|
# Off the event loop: on a large library the queries run for seconds and
|
|
# would stall every other request the worker is serving.
|
|
while True:
|
|
assets, saves = await asyncio.to_thread(_manifest_assets, user, can_see)
|
|
digests = await asset_md5s([entry.asset for entry in assets])
|
|
# Hashing flags a vanished file, which can unhide an older asset at its path.
|
|
if not any(entry.asset.missing_from_fs for entry in assets):
|
|
break
|
|
hashed = [
|
|
(entry, digest) for entry, digest in zip(assets, digests, strict=True) if digest
|
|
]
|
|
hashed_paths = {entry.path for entry, _ in hashed}
|
|
|
|
entries = [
|
|
{"path": entry.path, "hash": digest}
|
|
for entry, digest in hashed
|
|
# A state screenshot is listed only alongside its state.
|
|
if not isinstance(entry.asset, Screenshot)
|
|
or entry.path.removesuffix(".png") in hashed_paths
|
|
]
|
|
entries += await build_blob_manifest_entries(user)
|
|
entries += await psp.build_psp_manifest_entries(saves, can_see)
|
|
|
|
entries.sort(key=lambda entry: entry["path"])
|
|
return entries
|