mirror of
https://github.com/rommapp/romm.git
synced 2026-05-04 00:01:30 +08:00
548 lines
16 KiB
Python
548 lines
16 KiB
Python
from pathlib import Path
|
|
|
|
from utils.database import safe_str_to_bool
|
|
|
|
from .types import (
|
|
LAUNCHBOX_IMAGES_DIR,
|
|
LAUNCHBOX_MANUALS_DIR,
|
|
LaunchboxImage,
|
|
LaunchboxMetadata,
|
|
LaunchboxRom,
|
|
LocalMediaContext,
|
|
MediaRequest,
|
|
)
|
|
from .utils import (
|
|
coalesce,
|
|
dedupe_words,
|
|
file_uri_for_local_path,
|
|
parse_list,
|
|
parse_playmode,
|
|
parse_release_date,
|
|
parse_videourl,
|
|
sanitize_filename,
|
|
)
|
|
|
|
|
|
def local_media_req(
|
|
*,
|
|
platform_name: str | None,
|
|
fs_name: str,
|
|
local: dict[str, str] | None,
|
|
remote: dict | None,
|
|
remote_images: list[dict] | None,
|
|
remote_enabled: bool,
|
|
) -> MediaRequest:
|
|
title = ((local or {}).get("Title") or "").strip()
|
|
region_hint = ((local or {}).get("Region") or "").strip() or None
|
|
return MediaRequest(
|
|
platform_name,
|
|
fs_name,
|
|
title,
|
|
region_hint,
|
|
remote_images,
|
|
remote_enabled,
|
|
)
|
|
|
|
|
|
def remote_media_req(
|
|
*,
|
|
remote: dict | None,
|
|
remote_images: list[dict] | None,
|
|
remote_enabled: bool,
|
|
) -> MediaRequest:
|
|
title = ((remote or {}).get("Name") or "").strip()
|
|
return MediaRequest(
|
|
None,
|
|
"",
|
|
title,
|
|
None,
|
|
remote_images,
|
|
remote_enabled,
|
|
)
|
|
|
|
|
|
def _build_local_media_context(
|
|
req: MediaRequest,
|
|
base_dir: Path,
|
|
*,
|
|
include_region_hints: bool = True,
|
|
) -> LocalMediaContext | None:
|
|
if not req.platform_name:
|
|
return None
|
|
|
|
if not base_dir.exists():
|
|
return None
|
|
base = (base_dir / req.platform_name).resolve()
|
|
if not base.is_dir():
|
|
return None
|
|
|
|
stems: list[str] = []
|
|
if req.fs_name:
|
|
stems.append(Path(req.fs_name).stem)
|
|
if req.title:
|
|
stems.append(req.title)
|
|
|
|
out: list[str] = []
|
|
for s in stems:
|
|
clean = sanitize_filename(s)
|
|
if clean and clean not in out:
|
|
out.append(clean)
|
|
stems = out
|
|
if not stems:
|
|
return None
|
|
|
|
preferred_regions: list[str] = []
|
|
if include_region_hints and req.region_hint:
|
|
region_hint = req.region_hint.strip()
|
|
if region_hint:
|
|
preferred_regions.append(region_hint)
|
|
if "," in region_hint:
|
|
preferred_regions.extend(
|
|
[r.strip() for r in region_hint.split(",") if r.strip()]
|
|
)
|
|
|
|
return {
|
|
"base": base,
|
|
"stems": stems,
|
|
"preferred_regions": preferred_regions,
|
|
}
|
|
|
|
|
|
def _find_local_media_candidates(
|
|
ctx: LocalMediaContext,
|
|
category_name: str,
|
|
*,
|
|
exts: tuple[str, ...] = (".png", ".jpg", ".jpeg", ".webp"),
|
|
indexed_preference: tuple[int, ...] | None = None,
|
|
indexed_only_preferred: bool = False,
|
|
) -> tuple[list[Path], str]:
|
|
category_dir = ctx["base"] / category_name
|
|
if not category_dir.is_dir():
|
|
return [], ""
|
|
|
|
search_dirs: list[Path] = []
|
|
|
|
for region in ctx["preferred_regions"]:
|
|
p = category_dir / region
|
|
if p.exists() and p.is_dir() and p not in search_dirs:
|
|
search_dirs.append(p)
|
|
|
|
for p in sorted(
|
|
[p for p in category_dir.iterdir() if p.is_dir()],
|
|
key=lambda p: p.name.lower(),
|
|
):
|
|
if p not in search_dirs:
|
|
search_dirs.append(p)
|
|
|
|
if category_dir not in search_dirs:
|
|
search_dirs.append(category_dir)
|
|
|
|
if not search_dirs:
|
|
return [], ""
|
|
allowed_exts = {e.lower() for e in exts}
|
|
|
|
def _candidates(d: Path, stem: str) -> list[Path]:
|
|
if not stem:
|
|
return []
|
|
|
|
plain: Path | None = None
|
|
indexed: list[tuple[int, Path]] = []
|
|
prefix = f"{stem}-"
|
|
|
|
try:
|
|
for p in d.iterdir():
|
|
if not (p.is_file() and p.suffix.lower() in allowed_exts):
|
|
continue
|
|
|
|
stem_name = p.stem
|
|
if stem_name == stem:
|
|
plain = p
|
|
continue
|
|
|
|
if stem_name.startswith(prefix):
|
|
suffix = stem_name[len(prefix) :]
|
|
if suffix.isdigit():
|
|
indexed.append((int(suffix), p))
|
|
except (OSError, PermissionError):
|
|
return []
|
|
|
|
if indexed:
|
|
indexed.sort(key=lambda t: (t[0], t[1].name.lower()))
|
|
if indexed_preference:
|
|
indexed_by_num: dict[int, Path] = {n: p for n, p in indexed}
|
|
preferred_hits = [
|
|
indexed_by_num[n] for n in indexed_preference if n in indexed_by_num
|
|
]
|
|
if preferred_hits:
|
|
return preferred_hits
|
|
if indexed_only_preferred:
|
|
return [plain] if plain else []
|
|
|
|
return [p for _, p in indexed]
|
|
|
|
return [plain] if plain else []
|
|
|
|
for d in search_dirs:
|
|
region = "" if d == category_dir else d.name
|
|
for stem in ctx["stems"]:
|
|
candidate_files = _candidates(d, stem)
|
|
if candidate_files:
|
|
return candidate_files, region
|
|
|
|
return [], ""
|
|
|
|
|
|
def _get_cover(req: MediaRequest) -> str | None:
|
|
cover: str | None = None
|
|
|
|
cover_priority_types = (
|
|
"Box - Front",
|
|
"Box - Front - Reconstructed",
|
|
"Fanart - Box - Front",
|
|
"Box - 3D",
|
|
"Amazon Poster",
|
|
"Epic Games Poster",
|
|
"GOG Poster",
|
|
"Steam Poster",
|
|
)
|
|
|
|
# Remote media (overridden by local if available)
|
|
if req.remote_enabled and req.remote_images:
|
|
best_cover: dict | None = None
|
|
for image_type in cover_priority_types:
|
|
for image in req.remote_images:
|
|
if image.get("Type") == image_type and image.get("FileName"):
|
|
best_cover = image
|
|
break
|
|
if best_cover is not None:
|
|
break
|
|
|
|
if best_cover and best_cover.get("FileName"):
|
|
cover = f"https://images.launchbox-app.com/{best_cover.get('FileName')}"
|
|
|
|
ctx = _build_local_media_context(
|
|
req, LAUNCHBOX_IMAGES_DIR, include_region_hints=True
|
|
)
|
|
if ctx is not None:
|
|
for category in cover_priority_types:
|
|
candidate_files, _region = _find_local_media_candidates(
|
|
ctx,
|
|
category,
|
|
indexed_preference=(1,),
|
|
indexed_only_preferred=True,
|
|
)
|
|
if not candidate_files:
|
|
continue
|
|
|
|
cover_path = candidate_files[0]
|
|
url = file_uri_for_local_path(cover_path)
|
|
if url:
|
|
cover = url
|
|
break
|
|
|
|
return cover
|
|
|
|
|
|
def _get_screenshots(req: MediaRequest) -> list[str]:
|
|
screenshots: list[str] = []
|
|
|
|
# Remote media (overridden by local if available)
|
|
if req.remote_enabled and req.remote_images:
|
|
screenshots = [
|
|
f"https://images.launchbox-app.com/{image.get('FileName')}"
|
|
for image in req.remote_images
|
|
if image.get("FileName") and "Screenshot" in image.get("Type", "")
|
|
]
|
|
|
|
ctx = _build_local_media_context(
|
|
req, LAUNCHBOX_IMAGES_DIR, include_region_hints=True
|
|
)
|
|
if ctx is not None:
|
|
local_screens: list[str] = []
|
|
seen: set[str] = set()
|
|
for dir_name in (
|
|
"Amazon Screenshot",
|
|
"Epic Games Screenshot",
|
|
"GOG Screenshot",
|
|
"Origin Screenshot",
|
|
"Screenshot - Game Title",
|
|
"Screenshot - Game Select",
|
|
"Screenshot - Gameplay",
|
|
"Screenshot - High Scores",
|
|
"Screenshot - Game Over",
|
|
"Steam Screenshot",
|
|
):
|
|
candidate_files, _region = _find_local_media_candidates(ctx, dir_name)
|
|
for p in candidate_files:
|
|
url = file_uri_for_local_path(p)
|
|
if url and url not in seen:
|
|
seen.add(url)
|
|
local_screens.append(url)
|
|
|
|
if local_screens:
|
|
screenshots = local_screens
|
|
|
|
return screenshots
|
|
|
|
|
|
def _get_manuals(req: MediaRequest) -> str | None:
|
|
manual: str | None = None
|
|
|
|
ctx = _build_local_media_context(
|
|
req, LAUNCHBOX_MANUALS_DIR, include_region_hints=False
|
|
)
|
|
if ctx is None:
|
|
return manual
|
|
|
|
pdfs: list[Path] = [
|
|
p for p in ctx["base"].iterdir() if p.is_file() and p.suffix.lower() == ".pdf"
|
|
]
|
|
if not pdfs:
|
|
return manual
|
|
|
|
def _key(p: Path) -> str:
|
|
return sanitize_filename(p.stem).lower()
|
|
|
|
pdfs_sorted = sorted(pdfs, key=lambda p: (len(p.name), p.name.lower()))
|
|
|
|
stems_lower = [s.lower() for s in ctx["stems"]]
|
|
|
|
for stem in stems_lower:
|
|
for p in pdfs_sorted:
|
|
if _key(p) == stem:
|
|
url = file_uri_for_local_path(p)
|
|
if url:
|
|
return url
|
|
|
|
for stem in stems_lower:
|
|
for p in pdfs_sorted:
|
|
if _key(p).startswith(stem):
|
|
url = file_uri_for_local_path(p)
|
|
if url:
|
|
return url
|
|
|
|
return manual
|
|
|
|
|
|
def _get_images(req: MediaRequest) -> list[LaunchboxImage]:
|
|
images: list[LaunchboxImage] = []
|
|
|
|
# Remote media (overridden by local if available)
|
|
if req.remote_enabled and req.remote_images:
|
|
images = [
|
|
LaunchboxImage(
|
|
{
|
|
"url": f"https://images.launchbox-app.com/{image['FileName']}",
|
|
"type": image.get("Type", ""),
|
|
"region": image.get("Region", ""),
|
|
}
|
|
)
|
|
for image in req.remote_images
|
|
if image.get("FileName")
|
|
]
|
|
|
|
ctx = _build_local_media_context(
|
|
req, LAUNCHBOX_IMAGES_DIR, include_region_hints=True
|
|
)
|
|
if ctx is not None:
|
|
local_images: list[LaunchboxImage] = []
|
|
for dir_name in (
|
|
"Advertisement Flyer - Back",
|
|
"Advertisement Flyer - Front",
|
|
"Box - Back",
|
|
"Box - Back - Reconstructed",
|
|
"Box - Full",
|
|
"Box - Spine",
|
|
"Cart - Front",
|
|
"Cart - 3D",
|
|
"Clear Logo",
|
|
"Fanart - Box - Back",
|
|
"Fanart - Background", # Later separate in new category for rom header
|
|
"Amazon Background", # Later separate in new category for rom header
|
|
"Epic Games Background", # Later separate in new category for rom header
|
|
"Origin Background", # Later separate in new category for rom header
|
|
"Uplay Background", # Later separate in new category for rom header
|
|
):
|
|
candidate_files, region = _find_local_media_candidates(ctx, dir_name)
|
|
for p in candidate_files:
|
|
url = file_uri_for_local_path(p)
|
|
if not url:
|
|
continue
|
|
local_images.append(
|
|
LaunchboxImage(
|
|
{
|
|
"url": url,
|
|
"type": dir_name,
|
|
"region": region,
|
|
}
|
|
)
|
|
)
|
|
|
|
if local_images:
|
|
images = local_images
|
|
|
|
seen_images: dict[str, LaunchboxImage] = {}
|
|
for img in images:
|
|
if img["url"] not in seen_images:
|
|
seen_images[img["url"]] = img
|
|
|
|
return list(seen_images.values())
|
|
|
|
|
|
def build_launchbox_metadata(
|
|
*,
|
|
local: dict[str, str] | None = None,
|
|
remote: dict | None = None,
|
|
images: list[LaunchboxImage],
|
|
) -> LaunchboxMetadata:
|
|
local_release_date = local.get("ReleaseDate") if local else None
|
|
remote_release_date = remote.get("ReleaseDate") if remote else None
|
|
release_date_raw = coalesce(local_release_date, remote_release_date)
|
|
first_release_date = parse_release_date(release_date_raw)
|
|
|
|
max_players_raw = coalesce(
|
|
local.get("MaxPlayers") if local else None,
|
|
remote.get("MaxPlayers") if remote else None,
|
|
)
|
|
try:
|
|
max_players = int(max_players_raw or 0)
|
|
except (TypeError, ValueError):
|
|
max_players = 0
|
|
|
|
release_type = (
|
|
coalesce(
|
|
local.get("ReleaseType") if local else None,
|
|
remote.get("ReleaseType") if remote else None,
|
|
)
|
|
or ""
|
|
)
|
|
|
|
if local and coalesce(local.get("PlayMode")):
|
|
cooperative = parse_playmode(local.get("PlayMode"))
|
|
else:
|
|
cooperative = safe_str_to_bool(
|
|
(remote.get("Cooperative") if remote else None) or "false"
|
|
)
|
|
|
|
video_url = coalesce(
|
|
(local.get("VideoUrl") if local else None),
|
|
(remote.get("VideoURL") if remote else None),
|
|
)
|
|
|
|
community_rating_raw = coalesce(
|
|
local.get("CommunityStarRating") if local else None,
|
|
remote.get("CommunityRating") if remote else None,
|
|
)
|
|
try:
|
|
community_rating = float(community_rating_raw or 0.0)
|
|
except (TypeError, ValueError):
|
|
community_rating = 0.0
|
|
|
|
community_rating_count_raw = coalesce(
|
|
local.get("CommunityStarRatingTotalVotes") if local else None,
|
|
remote.get("CommunityRatingCount") if remote else None,
|
|
)
|
|
try:
|
|
community_rating_count = int(community_rating_count_raw or 0)
|
|
except (TypeError, ValueError):
|
|
community_rating_count = 0
|
|
|
|
wikipedia_url = (
|
|
coalesce(
|
|
local.get("WikipediaURL") if local else None,
|
|
remote.get("WikipediaURL") if remote else None,
|
|
)
|
|
or ""
|
|
)
|
|
|
|
esrb_raw = coalesce(
|
|
(local.get("Rating") if local else None),
|
|
(remote.get("ESRB") if remote else None),
|
|
)
|
|
esrb = (esrb_raw or "").split(" - ")[0].strip()
|
|
|
|
genres_raw = coalesce(
|
|
local.get("Genre") if local else None,
|
|
remote.get("Genres") if remote else None,
|
|
)
|
|
genres = parse_list(genres_raw)
|
|
|
|
publisher = coalesce(
|
|
local.get("Publisher") if local else None,
|
|
remote.get("Publisher") if remote else None,
|
|
)
|
|
developer = coalesce(
|
|
local.get("Developer") if local else None,
|
|
remote.get("Developer") if remote else None,
|
|
)
|
|
companies = dedupe_words([publisher, developer])
|
|
|
|
return LaunchboxMetadata(
|
|
{
|
|
"first_release_date": first_release_date,
|
|
"max_players": max_players,
|
|
"release_type": release_type,
|
|
"cooperative": cooperative,
|
|
"youtube_video_id": parse_videourl(video_url),
|
|
"community_rating": community_rating,
|
|
"community_rating_count": community_rating_count,
|
|
"wikipedia_url": wikipedia_url,
|
|
"esrb": esrb,
|
|
"genres": genres,
|
|
"companies": companies,
|
|
"images": images,
|
|
}
|
|
)
|
|
|
|
|
|
def build_rom(
|
|
*,
|
|
local: dict[str, str] | None,
|
|
remote: dict | None,
|
|
launchbox_id: int | None,
|
|
media_req: MediaRequest | None = None,
|
|
) -> LaunchboxRom:
|
|
images: list[LaunchboxImage] = (
|
|
_get_images(media_req) if media_req is not None else []
|
|
)
|
|
|
|
url_cover: str | None = None
|
|
url_screenshots: list[str] = []
|
|
url_manual: str | None = None
|
|
if media_req is not None:
|
|
url_cover = _get_cover(media_req)
|
|
url_screenshots = _get_screenshots(media_req)
|
|
url_manual = _get_manuals(media_req)
|
|
url_screenshots = url_screenshots or []
|
|
|
|
name = (
|
|
coalesce(
|
|
(local.get("Title") if local else None),
|
|
(remote.get("Name") if remote else None),
|
|
)
|
|
or ""
|
|
).strip()
|
|
|
|
summary = (
|
|
coalesce(
|
|
(local.get("Notes") if local else None),
|
|
(remote.get("Overview") if remote else None),
|
|
)
|
|
or ""
|
|
).strip()
|
|
|
|
launchbox_id = int(launchbox_id) if launchbox_id is not None else None
|
|
return LaunchboxRom(
|
|
launchbox_id=launchbox_id,
|
|
name=name,
|
|
summary=summary,
|
|
url_cover=url_cover or "",
|
|
url_screenshots=url_screenshots,
|
|
url_manual=url_manual or "",
|
|
launchbox_metadata=build_launchbox_metadata(
|
|
local=local,
|
|
remote=remote,
|
|
images=images,
|
|
),
|
|
)
|