mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
* feat(converto): integrate rom-converto for title id extraction and download conversion * fix(docker): ship rom-converto in the production image Only the dev Dockerfile installed the binary, so the integration could never enable on a released image. Fetch the same pinned, checksummed musl build in a dedicated stage and copy it next to RAHasher. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(converto): skip the update check and batch info calls The CLI asks api.github.com for a newer release on every run unless told not to, so pass --no-update-check on each invocation. Inspect a rom's files in one `info --paths-file` run instead of one subprocess per file, and read the inspectable extensions from the capabilities manifest so scans skip files info cannot recognize. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(scan): keep sigil authoritative for title ids rom-converto ran first and overrode the ids sigil read. Sigil now runs first and records its per-file ids; rom-converto only fills files sigil left unset, on new or changed files, in one batch. Restore the base-id selection for sigil extractions on every platform, as on master. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(downloads): make conversion opt-in and bound the cache The download endpoint returned a converted file to every client once a platform had a target, including the browser player and apps that check hashes against RomFile. Conversion now needs ?converted=true. A pre-warmed copy is served whatever its size; the in-request size cap only gates starting a conversion. ROM_CONVERTO_CACHE_MAX_SIZE_GB (default 20, 0 unbounded) caps the cache: conversions and the pre-warm task stop when it is full, and cleanup evicts the least recently served copies. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * chore(types): regenerate frontend types for the converto schema Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(converto): address code-review findings (pr-ready step 2) - Fall back to rom-converto's stored ids only when it ran this pass, in disc order, so a rescan that skipped extraction keeps sigil's save target - Persist per-file title ids that extraction writes onto reused rows - Only authenticated callers may start an in-request conversion; past a 240s deadline serve the original and let the conversion finish into the cache instead of nginx returning 504 - Treat a probe OSError or timeout as a failed probe; kill the subprocess when the caller is cancelled - Give the pre-warm task the scan timeout and report progress in the ConversionStats shape the task UI reads - Reuse zip_cache constants, walk the cache off the event loop, tolerate files vanishing mid-cleanup, and trim long comments Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): simplify conversion code (pr-ready step 3) - Move the converted-download policy (target lookup, cached-first, sync size cap, deadline) from the endpoint into utils/conversion_cache and share conversion_target with the pre-warm task - Share one path lookup between get_cached_converted and get_or_convert; the pre-warm walks the cache once and tracks a running byte budget instead of walking it twice per rom - One normalize_platform_formats beside TARGETS_BY_PLATFORM for the config loader (via _validated_platform_map) and the settings endpoint; update_converto_settings takes a ConvertoConfig and is written back with dataclasses.asdict - read_infos owns its never-raise contract, so the scan drops its blanket except; drop the unused RomConvertoInfo.kind and error attributes - Sort files for the rom-converto identity only when it is used; stat each cached file once during cleanup Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * chore(converto): trim comments (pr-ready step 4) Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): convert on first download instead of pre-warming A library pre-warm fought the TTL: cleanup dropped every copy nobody downloaded within a day. Remove the Convert library task and convert on demand only. Files over ROM_CONVERTO_MAX_SYNC_SIZE_MB now start their conversion in the background while the original is served, so the next download within the TTL gets the converted copy at any size. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): drop decrypt and encrypt download targets Downloads now only change a rom's container or compression (CHD, RVZ, CSO, NSZ, ...); serving decrypted files is left for a separate decision. NDS and PS3 have no remaining targets. Also note that the conversion concurrency limit applies per process. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): move the cache limits into converto.* cache_max_size_gb and max_sync_size_mb join cache_ttl_hours in config.yml and PUT /api/config/converto_settings, replacing the ROM_CONVERTO_CACHE_MAX_SIZE_GB and ROM_CONVERTO_MAX_SYNC_SIZE_MB env vars, so every eviction knob is edited in one place at runtime. The loader takes its defaults from ConvertoConfig and one table holds each integer's minimum for both the loader and the payload. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): lock conversions with flock instead of a sentinel A conversion now holds a non-blocking flock on its key dir, which the kernel releases if the worker dies. That replaces the .partial sentinel, its 6-hour staleness guess (which a long ROM_CONVERTO_TIMEOUT could outlive), and the rename-aside takeover. Cleanup skips locked dirs, and partial output gets a hidden name so it never counts as a served copy. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * test(converto): parametrize repeated test cases Collapse the per-platform info parsing, operation resolution and converto_active cases into parametrized tests, so a new platform or target is one row. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(scan): run rom-converto first with sigil as the fallback rom-converto reads each rom's new or changed files first, in one batch, and its title ids win. sigil still reads what it can, since it alone provides save targets and Switch content types, and fills the ids rom-converto left unset. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * feat(converto): store per-file metadata read by rom-converto during scans * fix(scan): keep the stored save target when sigil reads nothing * fix(converto): read Xbox 360 disc ids and Wii title versions, store content type as text * feat(converto): save file icons, banners and backgrounds read by rom-converto * chore(converto): bump rom-converto to v0.23.0 * fix(converto): address review findings in the conversion cache, scan metadata and settings * refactor(converto): move per-file metadata out of the conversion PR Scan-time metadata (title ids, the 17 rom_files columns, artwork and converto.scan_metadata) moves to its own PR so its columns land with the UI that reads them. This PR keeps conversion only. The conversion cache no longer has uncompressed_size_bytes to budget with, so a conversion to an uncompressed target reserves a fixed multiple of its input and the cleanup pass after it evicts any overshoot. Co-Authored-By: DevYukine <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017BuuZVVtVPNLRaCjTN9bgQ * feat(converto): let the client pick the download format with ?format= `?format=zso,iso` lists the formats a client can read. The stored file is served whenever its format is listed, even with conversion off, so most emulator clients never trigger a conversion. Otherwise the first listed format with a cached copy is served, then the first one rom-converto can produce. A conversion that outlasts a short wait answers 202 with Retry-After instead of falling back to the original, so a URL never serves two different files and a client never gets bytes it can't read. 406 means no listed format can be served. HEAD reports the same without starting a conversion. This replaces `?converted=true`, the per-platform download target and `max_sync_size_mb`; `cache_ttl_hours` stays config.yml-only. Co-Authored-By: DevYukine <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017BuuZVVtVPNLRaCjTN9bgQ * feat(converto): convert the library in place with a manual task `converto.platform_formats` now names the format a platform's library is stored in, and the new "Convert library" task brings every matched rom to it. Each output is staged in a `.romm_tmp_` directory beside the original, moved into place, and only then are the originals (and a cue's tracks) deleted. Single-file roms take the new name, folder roms keep theirs and get their .m3u entries rewritten, and the file rows refresh, so a rom keeps its id, saves and collections. Only lossless conversions qualify, so xbox (xiso trims the dump) has no library format. Unmatched roms are skipped because a converted file no longer hash-matches a DAT. `/api/config` now carries the library targets and, per input extension, the formats a download can be converted to. Co-Authored-By: DevYukine <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017BuuZVVtVPNLRaCjTN9bgQ * fix(converto): address Greptile and Copilot review findings - Publish a converted library file with a no-replace link, so a file that appears during conversion is never overwritten. - Delete originals only after the rom row or its playlists point at the new file, and keep them if that update fails. Playlists are rewritten atomically. - Refuse a cue conversion whose tracks another rom or another cue holds. - Remove stage dirs a killed conversion left behind. - Try later ?format= targets when a conversion fails, treat any locked key dir as converting, and cap how many conversions downloads can start. - Drop the unsupported iso target for CD platforms from the example config and list the ROM_CONVERTO_* variables in env.template. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(converto): address code-review findings (pr-ready step 2) Serve converted outputs at 0644, read cue sheets and playlists with a BOM, stage folder rom conversions outside the rom folder, keep one rom's failure from stopping the convert library task, accept alias extensions as the stored format, probe running conversions with a shared lock, and bound the timeout. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): simplify conversion code (pr-ready step 3) Check and chmod rom-converto output once in the adapter, canonicalize requested format aliases, reuse LINK_FALLBACK_ERRNOS and the handler's atomic write, make the download format table a constant, skip the probe lock once cached, and fold duplicated name checks and cache stat passes. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * chore(converto): trim comments (pr-ready step 4) Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): find rom-converto on PATH instead of ROM_CONVERTO_PATH Both images install the binary on PATH, the variable was undocumented, and RAHasher is already resolved the same way. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> --------- Co-authored-by: Yukine <[email protected]> Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]> Co-authored-by: DevYukine <[email protected]>
416 lines
15 KiB
Python
416 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import enum
|
|
import fcntl
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import time
|
|
from collections.abc import Generator, Sequence
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from adapters.services.rom_converto import (
|
|
UNCOMPRESSED_TARGETS,
|
|
Operation,
|
|
RomConvertoOperationError,
|
|
RomConvertoTimeoutError,
|
|
canonical_format,
|
|
file_format,
|
|
resolve_operation,
|
|
rom_converto_service,
|
|
)
|
|
from config import (
|
|
LIBRARY_BASE_PATH,
|
|
ROM_CONVERTO_CACHE_PATH,
|
|
ROM_CONVERTO_MAX_CONCURRENCY,
|
|
ROMM_BASE_PATH,
|
|
)
|
|
from config.config_manager import config_manager as cm
|
|
from logger.formatter import highlight as hl
|
|
from logger.logger import log
|
|
from utils.background_tasks import fire_and_forget
|
|
from utils.zip_cache import CACHE_KEY_LENGTH, SECONDS_PER_HOUR
|
|
|
|
if TYPE_CHECKING:
|
|
from models.rom import Rom, RomFile
|
|
|
|
BYTES_PER_GB = 1024**3
|
|
# How long a download waits on a conversion it started before answering 202.
|
|
CONVERSION_WAIT_SECONDS: Final = 15
|
|
RETRY_AFTER_SECONDS: Final = 30
|
|
PARTIAL_DIR: Final = ".partial"
|
|
FAILED_FILE: Final = ".failed"
|
|
# Give nginx time to open a copy after the X-Accel-Redirect response.
|
|
SERVE_GRACE_SECONDS: Final = 60
|
|
# Room a conversion to an uncompressed target reserves per input byte; the
|
|
# cleanup pass after it evicts any overshoot.
|
|
EXPANSION_RESERVE_FACTOR: Final = 4
|
|
# Downloads may start conversions until this many wait for or hold the semaphore.
|
|
MAX_STARTED_CONVERSIONS: Final = ROM_CONVERTO_MAX_CONCURRENCY * 4
|
|
|
|
# Each conversion reads and writes whole disc images.
|
|
_convert_semaphore = asyncio.Semaphore(ROM_CONVERTO_MAX_CONCURRENCY)
|
|
_started_conversions: set[asyncio.Task[Path | None]] = set()
|
|
|
|
|
|
def converted_file_path(
|
|
rom_id: int, rom_file: RomFile, operation: Operation, input_ext: str
|
|
) -> Path:
|
|
"""Deterministic cache path for a converted ROM file."""
|
|
key = hashlib.sha1(
|
|
f"{rom_file.file_path}/{rom_file.file_name}|{rom_file.last_modified}|{rom_file.file_size_bytes}|{operation.target}".encode(),
|
|
usedforsecurity=False,
|
|
).hexdigest()[:CACHE_KEY_LENGTH]
|
|
key_dir = Path(ROM_CONVERTO_CACHE_PATH) / f"{rom_id}-{key}"
|
|
return key_dir / operation.output_name(Path(rom_file.file_name), input_ext)
|
|
|
|
|
|
def get_redirect_path(converted_path: Path) -> Path:
|
|
"""The nginx-internal path for a converted file (`/cache/` aliases `${ROMM_BASE_PATH}/cache/`)."""
|
|
return Path("/") / converted_path.relative_to(ROMM_BASE_PATH)
|
|
|
|
|
|
def _dir_size(path: Path) -> int:
|
|
total = 0
|
|
for root, _, files in os.walk(path):
|
|
for name in files:
|
|
with contextlib.suppress(OSError):
|
|
total += os.stat(os.path.join(root, name)).st_size
|
|
return total
|
|
|
|
|
|
def _files(key_dir: Path) -> list[Path]:
|
|
return [p for p in key_dir.iterdir() if p.is_file()]
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _try_lock(key_dir: Path, *, shared: bool = False) -> Generator[bool]:
|
|
"""Lock the key dir without blocking, yielding False if it is busy, removed or replaced."""
|
|
fd = os.open(key_dir, os.O_RDONLY)
|
|
try:
|
|
try:
|
|
mode = fcntl.LOCK_SH if shared else fcntl.LOCK_EX
|
|
fcntl.flock(fd, mode | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
yield False
|
|
return
|
|
try:
|
|
current_dir = os.path.samestat(os.fstat(fd), os.stat(key_dir))
|
|
except FileNotFoundError:
|
|
current_dir = False
|
|
yield current_dir
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _stat_files(key_dir: Path) -> tuple[dict[str, os.stat_result], float]:
|
|
"""The stats of `key_dir`'s files and the newest mtime among them."""
|
|
stats = {p.name: p.stat() for p in _files(key_dir)}
|
|
return stats, max((st.st_mtime for st in stats.values()), default=0.0)
|
|
|
|
|
|
def cache_size_bytes() -> int:
|
|
"""Bytes every key dir holds on disk, partial output included."""
|
|
return _dir_size(Path(ROM_CONVERTO_CACHE_PATH))
|
|
|
|
|
|
def has_room_for(size_bytes: int) -> bool:
|
|
"""Whether adding `size_bytes` keeps the cache under `converto.cache_max_size_gb`."""
|
|
max_bytes = cm.get_config().CONVERTO.cache_max_size_gb * BYTES_PER_GB
|
|
return not max_bytes or cache_size_bytes() + size_bytes <= max_bytes
|
|
|
|
|
|
def _lookup(
|
|
rom_id: int, rom_file: RomFile, platform_slug: str, target: str
|
|
) -> tuple[Operation, Path] | None:
|
|
"""The operation bringing `rom_file` to `target` and its cache path."""
|
|
resolved = resolve_operation(platform_slug, target, rom_file.file_name)
|
|
if resolved is None:
|
|
return None
|
|
operation, input_ext = resolved
|
|
return operation, converted_file_path(rom_id, rom_file, operation, input_ext)
|
|
|
|
|
|
def _serve_cached(final_path: Path, *, touch: bool) -> Path | None:
|
|
"""`final_path` if it is cached, or None while cleanup or a conversion holds its dir."""
|
|
with (
|
|
contextlib.suppress(OSError),
|
|
_try_lock(final_path.parent, shared=True) as locked,
|
|
):
|
|
if locked and final_path.exists():
|
|
if touch:
|
|
# Keep a served file fresh so TTL cleanup measures demand.
|
|
with contextlib.suppress(OSError):
|
|
os.utime(final_path)
|
|
return final_path
|
|
return None
|
|
|
|
|
|
def get_cached_converted(
|
|
rom_id: int,
|
|
rom_file: RomFile,
|
|
platform_slug: str,
|
|
target: str,
|
|
*,
|
|
touch: bool = False,
|
|
) -> Path | None:
|
|
"""The converted file if it is already cached, without converting.
|
|
|
|
Args:
|
|
touch: Refresh its mtime so TTL cleanup measures demand.
|
|
"""
|
|
found = _lookup(rom_id, rom_file, platform_slug, target)
|
|
return _serve_cached(found[1], touch=touch) if found else None
|
|
|
|
|
|
class FormatOutcome(enum.StrEnum):
|
|
ORIGINAL = "original"
|
|
CONVERTED = "converted"
|
|
PENDING = "pending"
|
|
UNAVAILABLE = "unavailable"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FormatResolution:
|
|
outcome: FormatOutcome
|
|
# The cached copy to serve when `outcome` is CONVERTED.
|
|
path: Path | None = None
|
|
|
|
|
|
_PENDING: Final = FormatResolution(FormatOutcome.PENDING)
|
|
_UNAVAILABLE: Final = FormatResolution(FormatOutcome.UNAVAILABLE)
|
|
|
|
|
|
def parse_formats(raw: str) -> tuple[str, ...]:
|
|
"""The comma-separated `?format=` list, canonical, lowercased and deduplicated in order."""
|
|
formats = (canonical_format(f.strip().lower()) for f in raw.split(","))
|
|
return tuple(dict.fromkeys(f for f in formats if f))
|
|
|
|
|
|
def _is_converting(final_path: Path) -> bool:
|
|
"""Whether a worker holds `final_path`'s key dir, which it locks before converting."""
|
|
# A shared probe leaves downloads serving a cached copy undisturbed.
|
|
with (
|
|
contextlib.suppress(OSError),
|
|
_try_lock(final_path.parent, shared=True) as locked,
|
|
):
|
|
return not locked
|
|
return False
|
|
|
|
|
|
async def resolve_format_download(
|
|
rom: Rom,
|
|
file: RomFile,
|
|
formats: Sequence[str],
|
|
*,
|
|
allowed: bool,
|
|
start: bool,
|
|
touch: bool,
|
|
) -> FormatResolution:
|
|
"""What a `?format=` download serves: the stored file if listed, else the first cached, then the first convertible format.
|
|
|
|
Args:
|
|
allowed: The caller may start a conversion.
|
|
start: Start it now, rather than only report that one would run.
|
|
touch: Refresh a served copy's mtime so TTL cleanup measures demand.
|
|
"""
|
|
if file_format(file.file_name) in formats:
|
|
return FormatResolution(FormatOutcome.ORIGINAL)
|
|
|
|
converto = cm.get_config().CONVERTO
|
|
if (
|
|
not converto.download_conversion_enabled
|
|
or not await rom_converto_service.is_enabled()
|
|
):
|
|
return _UNAVAILABLE
|
|
|
|
candidates = [
|
|
(target, found[1])
|
|
for target in formats
|
|
if (found := _lookup(rom.id, file, rom.platform_slug, target))
|
|
]
|
|
for _, final_path in candidates:
|
|
if cached := _serve_cached(final_path, touch=touch):
|
|
return FormatResolution(FormatOutcome.CONVERTED, cached)
|
|
if not allowed:
|
|
return _UNAVAILABLE
|
|
|
|
for target, final_path in candidates:
|
|
if (final_path.parent / FAILED_FILE).exists():
|
|
continue
|
|
if (
|
|
not start
|
|
or _is_converting(final_path)
|
|
or len(_started_conversions) >= MAX_STARTED_CONVERSIONS
|
|
):
|
|
return _PENDING
|
|
# The conversion finishes into the cache even when this request stops waiting.
|
|
conversion = fire_and_forget(
|
|
get_or_convert(rom.id, file, rom.platform_slug, target)
|
|
)
|
|
_started_conversions.add(conversion)
|
|
conversion.add_done_callback(_started_conversions.discard)
|
|
done, _ = await asyncio.wait({conversion}, timeout=CONVERSION_WAIT_SECONDS)
|
|
if done and (converted := conversion.result()):
|
|
return FormatResolution(FormatOutcome.CONVERTED, converted)
|
|
if not done or _is_converting(final_path):
|
|
return _PENDING
|
|
return _UNAVAILABLE
|
|
|
|
|
|
async def get_or_convert(
|
|
rom_id: int,
|
|
rom_file: RomFile,
|
|
platform_slug: str,
|
|
target: str,
|
|
) -> Path | None:
|
|
"""The converted file, converting it under a lock on its key dir, or None when it can't be."""
|
|
found = _lookup(rom_id, rom_file, platform_slug, target)
|
|
if found is None:
|
|
return None
|
|
operation, final_path = found
|
|
|
|
try:
|
|
if cached := _serve_cached(final_path, touch=True):
|
|
return cached
|
|
|
|
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with _try_lock(final_path.parent) as locked:
|
|
if not locked:
|
|
# Another worker is converting it.
|
|
return None
|
|
# A conversion may have finished between the check above and the lock.
|
|
if final_path.exists():
|
|
with contextlib.suppress(OSError):
|
|
os.utime(final_path)
|
|
return final_path
|
|
if (final_path.parent / FAILED_FILE).exists():
|
|
return None
|
|
async with _convert_semaphore:
|
|
await asyncio.to_thread(
|
|
shutil.rmtree, final_path.parent / PARTIAL_DIR, True
|
|
)
|
|
size_bytes = (rom_file.file_size_bytes or 0) * (
|
|
EXPANSION_RESERVE_FACTOR
|
|
if operation.target in UNCOMPRESSED_TARGETS
|
|
else 1
|
|
)
|
|
await asyncio.to_thread(cleanup_stale_conversions, size_bytes)
|
|
if not await asyncio.to_thread(has_room_for, size_bytes):
|
|
log.info(
|
|
f"Conversion cache is full, not converting ROM {rom_id} (target {hl(target)})"
|
|
)
|
|
return None
|
|
converted = await _convert(
|
|
rom_id, rom_file, target, operation, final_path
|
|
)
|
|
if converted:
|
|
try:
|
|
await asyncio.to_thread(cleanup_stale_conversions)
|
|
except Exception as e:
|
|
log.warning(
|
|
f"Conversion cache cleanup failed for ROM {rom_id}: {e}; serving converted copy"
|
|
)
|
|
return converted
|
|
except Exception as e:
|
|
log.warning(
|
|
f"Conversion cache unavailable for ROM {rom_id} (target {hl(target)}): {e}"
|
|
)
|
|
return None
|
|
|
|
|
|
async def _convert(
|
|
rom_id: int, rom_file: RomFile, target: str, operation: Operation, final_path: Path
|
|
) -> Path | None:
|
|
"""Convert into a partial file and rename it into place; the caller holds the key dir lock."""
|
|
partial_dir = final_path.parent / PARTIAL_DIR
|
|
produced = partial_dir / final_path.name
|
|
try:
|
|
partial_dir.mkdir()
|
|
await rom_converto_service.convert(
|
|
operation, src=Path(LIBRARY_BASE_PATH) / rom_file.full_path, out=produced
|
|
)
|
|
os.replace(produced, final_path)
|
|
except Exception as e:
|
|
log.warning(f"Conversion failed for ROM {rom_id} (target {hl(target)}): {e}")
|
|
if isinstance(e, (RomConvertoOperationError, RomConvertoTimeoutError)):
|
|
(final_path.parent / FAILED_FILE).touch()
|
|
return None
|
|
finally:
|
|
await asyncio.to_thread(shutil.rmtree, partial_dir, True)
|
|
return final_path
|
|
|
|
|
|
def cleanup_stale_conversions(reserve_bytes: int = 0) -> int:
|
|
"""Remove expired or leaked key dirs, then evict least recently served copies to make room."""
|
|
cache_root = Path(ROM_CONVERTO_CACHE_PATH)
|
|
if not cache_root.exists():
|
|
return 0
|
|
|
|
converto = cm.get_config().CONVERTO
|
|
now = time.time()
|
|
expired_before = now - converto.cache_ttl_hours * SECONDS_PER_HOUR
|
|
deleted = 0
|
|
# (last served, size) of the final copies in each dir that survives the TTL pass.
|
|
kept: list[tuple[float, int, Path]] = []
|
|
|
|
for key_dir in cache_root.iterdir():
|
|
if not key_dir.is_dir():
|
|
continue
|
|
with contextlib.suppress(FileNotFoundError):
|
|
stats, last_served = _stat_files(key_dir)
|
|
partial_dir = key_dir / PARTIAL_DIR
|
|
if last_served < expired_before or partial_dir.exists():
|
|
with _try_lock(key_dir) as locked:
|
|
if locked:
|
|
stats, last_served = _stat_files(key_dir)
|
|
if last_served < expired_before:
|
|
shutil.rmtree(key_dir, ignore_errors=True)
|
|
deleted += 1
|
|
continue
|
|
shutil.rmtree(partial_dir, ignore_errors=True)
|
|
# A failure marker frees nothing, and evicting it would retry the conversion.
|
|
stats.pop(FAILED_FILE, None)
|
|
if stats:
|
|
kept.append(
|
|
(
|
|
max(st.st_mtime for st in stats.values()),
|
|
sum(st.st_size for st in stats.values()),
|
|
key_dir,
|
|
)
|
|
)
|
|
|
|
max_bytes = converto.cache_max_size_gb * BYTES_PER_GB
|
|
if not max_bytes:
|
|
return deleted
|
|
used = cache_size_bytes()
|
|
candidates = sorted(c for c in kept if c[0] < now - SERVE_GRACE_SECONDS)
|
|
if reserve_bytes and used + reserve_bytes > max_bytes:
|
|
evictable = 0
|
|
for _, size, key_dir in candidates:
|
|
# A dir a download or conversion holds right now can't be evicted.
|
|
with contextlib.suppress(FileNotFoundError), _try_lock(key_dir) as locked:
|
|
evictable += size if locked else 0
|
|
if used + reserve_bytes - evictable > max_bytes:
|
|
# Evicting every candidate would still not make room, so keep them.
|
|
return deleted
|
|
for _, _, key_dir in candidates:
|
|
if used + reserve_bytes <= max_bytes:
|
|
break
|
|
with contextlib.suppress(FileNotFoundError), _try_lock(key_dir) as locked:
|
|
# A download may have touched the copy since the pass above.
|
|
if not locked or any(
|
|
p.stat().st_mtime >= now - SERVE_GRACE_SECONDS for p in _files(key_dir)
|
|
):
|
|
continue
|
|
size = _dir_size(key_dir)
|
|
shutil.rmtree(key_dir, ignore_errors=True)
|
|
used -= size
|
|
deleted += 1
|
|
|
|
return deleted
|