diff --git a/RomM/api.py b/RomM/api.py index 7bf5110..d4a790d 100644 --- a/RomM/api.py +++ b/RomM/api.py @@ -1,4 +1,5 @@ import base64 +import datetime import json import math import os @@ -11,6 +12,7 @@ import platform_maps from filesystem import Filesystem +from imageutils import ImageUtils from models import Collection, Platform, Rom from PIL import Image from status import Status, View @@ -28,6 +30,7 @@ class API: def __init__(self): self.status = Status() self.file_system = Filesystem() + self.image_utils = ImageUtils() self.host = os.getenv("HOST", "").strip("/") self.username = os.getenv("USERNAME", "") @@ -37,6 +40,11 @@ def __init__(self): self._include_collections = set(self._getenv_list("INCLUDE_COLLECTIONS")) self._exclude_collections = set(self._getenv_list("EXCLUDE_COLLECTIONS")) self._collection_type = os.getenv("COLLECTION_TYPE", "collection") + self._download_assets = os.getenv("DOWNLOAD_ASSETS", "false") in ("true", "1") + self._fullscreen_assets = os.getenv("FULLSCREEN_ASSETS", "false") in ( + "true", + "1", + ) if self.username and self.password: credentials = f"{self.username}:{self.password}" @@ -234,6 +242,7 @@ def fetch_platforms(self) -> None: self.status.valid_host = False self.status.valid_credentials = False return + platforms = json.loads(response.read().decode("utf-8")) _platforms: list[Platform] = [] @@ -251,7 +260,7 @@ def fetch_platforms(self) -> None: for platform in platforms: if platform["rom_count"] > 0: - platform_slug = platform["slug"].lower() + platform_slug: str = platform["slug"].lower() if ( platform_maps._env_maps and platform_slug in platform_maps._env_platforms @@ -458,7 +467,7 @@ def fetch_roms(self) -> None: _roms = [] for rom in roms: - platform_slug = rom["platform_slug"].lower() + platform_slug: str = rom["platform_slug"].lower() if ( platform_maps._env_maps and platform_slug in platform_maps._env_platforms @@ -476,22 +485,48 @@ def fetch_roms(self) -> None: ) if mapped_folder.lower() not in roms_subfolders: continue + if view == View.PLATFORMS and platform_slug != selected_platform_slug: continue + + metadatum = rom.get("metadatum", {}) _roms.append( Rom( id=rom["id"], - name=rom["name"], - fs_name=rom["fs_name"], + platform_id=rom["platform_id"], platform_slug=rom["platform_slug"], + fs_name=rom["fs_name"], + fs_name_no_tags=rom["fs_name_no_tags"], + fs_name_no_ext=rom["fs_name_no_ext"], fs_extension=rom["fs_extension"], fs_size=self._human_readable_size(rom["fs_size_bytes"]), fs_size_bytes=rom["fs_size_bytes"], - multi=rom["multi"], - languages=rom["languages"], - regions=rom["regions"], - revision=rom["revision"], - tags=rom["tags"], + name=rom["name"], + slug=rom["slug"], + summary=rom["summary"], + youtube_video_id=rom.get("youtube_video_id", None), + path_cover_small=rom["path_cover_small"], + path_cover_large=rom["path_cover_large"], + is_identified=rom["is_identified"], + revision=rom.get("revision", None), + regions=rom.get("regions", []), + languages=rom.get("languages", []), + tags=rom.get("tags", []), + crc_hash=rom.get("crc_hash", ""), + md5_hash=rom.get("md5_hash", ""), + sha1_hash=rom.get("sha1_hash", ""), + has_simple_single_file=rom.get("has_simple_single_file", False), + has_nested_single_file=rom.get("has_nested_single_file", False), + has_multiple_files=rom.get("has_multiple_files", False), + merged_screenshots=rom.get("merged_screenshots", []), + genres=metadatum.get("genres", []), + franchises=metadatum.get("franchises", []), + collections=metadatum.get("collections", []), + companies=metadatum.get("companies", []), + game_modes=metadatum.get("game_modes", []), + age_ratings=metadatum.get("age_ratings", []), + first_release_date=metadatum.get("first_release_date", None), + average_rating=metadatum.get("average_rating", None), ) ) @@ -532,6 +567,7 @@ def download_rom(self) -> None: except ValueError: self._reset_download_status() return + try: if request.type not in ("http", "https"): self._reset_download_status() @@ -563,10 +599,11 @@ def download_rom(self) -> None: self._reset_download_status(True, True) os.remove(dest_path) return + # Handle multi-file (ZIP) ROMs - if rom.multi: + if rom.has_multiple_files: self.status.extracting_rom = True - print("Multi file rom detected. Extracting...") + print("Multi-file rom detected. Extracting...") with zipfile.ZipFile(dest_path, "r") as zip_ref: total_size = sum(file.file_size for file in zip_ref.infolist()) extracted_size = 0 @@ -608,5 +645,64 @@ def download_rom(self) -> None: except URLError: self._reset_download_status(valid_host=True) return + + # Check if the catalogue path is set and valid + catalogue_path = self.file_system.get_catalogue_platform_path( + rom.platform_slug + ) + if not catalogue_path: + continue + + filename = self._sanitize_filename(rom.fs_name_no_ext) + if rom.summary: + text_path = os.path.join( + catalogue_path, + "text", + f"{filename}.txt", + ) + os.makedirs(os.path.dirname(text_path), exist_ok=True) + with open(text_path, "w") as f: + f.write(rom.summary) + f.write("\n\n") + + if rom.first_release_date: + dt = datetime.datetime.fromtimestamp( + rom.first_release_date / 1000 + ) + formatted_date = dt.strftime("%Y-%m-%d") + f.write(f"First release date: {formatted_date}\n") + + if rom.average_rating: + f.write(f"Average rating: {rom.average_rating}\n") + + if rom.genres: + f.write(f"Genres: {', '.join(rom.genres)}\n") + + if rom.franchises: + f.write(f"Franchises: {', '.join(rom.franchises)}\n") + + if rom.companies: + f.write(f"Companies: {', '.join(rom.companies)}\n") + + # Don't download covers and previews if the user disabled the option + if not self._download_assets: + continue + + box_path = os.path.join(catalogue_path, "box", f"{filename}.png") + preview_path = os.path.join(catalogue_path, "preview", f"{filename}.png") + + # Download cover and preview images + os.makedirs(os.path.dirname(box_path), exist_ok=True) + os.makedirs(os.path.dirname(preview_path), exist_ok=True) + + self.image_utils.process_assets( + fullscreen=self._fullscreen_assets, + cover_url=rom.path_cover_small, + screenshot_urls=rom.merged_screenshots, + box_path=box_path, + preview_path=preview_path, + headers=self.headers, + ) + # End of download self._reset_download_status(valid_host=True, valid_credentials=True) diff --git a/RomM/env.template b/RomM/env.template index 96d0b7b..7e39b18 100644 --- a/RomM/env.template +++ b/RomM/env.template @@ -16,6 +16,10 @@ COLLECTION_TYPE=collection # Do not display collections with these names (comma separated) # EXCLUDE_COLLECTIONS="" +# Download cover images and screenshots +DOWNLOAD_ASSETS=1 +FULLSCREEN_ASSETS=1 + # Map RomM slugs to filesystem directories # For example, if your PlayStation directory is called "psx": # CUSTOM_MAPS='{"ps": "psx"}' diff --git a/RomM/filesystem.py b/RomM/filesystem.py index 033b5b0..4bf9f7f 100644 --- a/RomM/filesystem.py +++ b/RomM/filesystem.py @@ -16,7 +16,9 @@ class Filesystem: # Storage paths for ROMs _sd1_roms_storage_path: str - _sd2_roms_storage_path: str | None + _sd2_roms_storage_path: str | None = None + _sd1_catalogue_path: str | None = None + _sd2_catalogue_path: str | None = None # Resources path: Use current working directory + "resources" resources_path = os.path.join(os.getcwd(), "resources") @@ -35,15 +37,15 @@ def __init__(self) -> None: if self.is_muos: self._sd1_roms_storage_path = "/mnt/mmc/ROMS" self._sd2_roms_storage_path = "/mnt/sdcard/ROMS" + self._sd1_catalogue_path = "/mnt/mmc/MUOS/info/catalogue" + self._sd2_catalogue_path = "/mnt/sdcard/MUOS/info/catalogue" elif self.is_spruceos: self._sd1_roms_storage_path = "/mnt/SDCARD/Roms" - self._sd2_roms_storage_path = None else: # Go up two levels from the script's directory (e.g., from roms/ports/romm to roms/) base_path = os.path.abspath(os.path.join(os.getcwd(), "..", "..")) # Default to the ROMs directory, overridable via environment variable self._sd1_roms_storage_path = os.environ.get("ROMS_STORAGE_PATH", base_path) - self._sd2_roms_storage_path = None # Ensure the ROMs storage path exists if self._sd2_roms_storage_path and not os.path.exists( @@ -114,6 +116,20 @@ def _get_sd2_platforms_storage_path(self, platform: str) -> Optional[str]: return os.path.join(self._sd2_roms_storage_path, platforms_dir) return None + def get_sd1_catalogue_platform_path(self, platform: str) -> str: + if not self._sd1_catalogue_path: + raise ValueError("SD1 catalogue path is not set.") + + platforms_dir = self._get_platform_storage_dir_from_mapping(platform) + return os.path.join(self._sd1_catalogue_path, platforms_dir) + + def get_sd2_catalogue_platform_path(self, platform: str) -> str: + if not self._sd2_catalogue_path: + raise ValueError("SD2 catalogue path is not set.") + + platforms_dir = self._get_platform_storage_dir_from_mapping(platform) + return os.path.join(self._sd2_catalogue_path, platforms_dir) + ### # PUBLIC METHODS ### @@ -141,10 +157,17 @@ def get_platforms_storage_path(self, platform: str) -> str: return self._get_sd1_platforms_storage_path(platform) + def get_catalogue_platform_path(self, platform: str) -> str: + """Return the catalogue path for a specific platform.""" + if self._current_sd == 2: + return self.get_sd2_catalogue_platform_path(platform) + + return self.get_sd1_catalogue_platform_path(platform) + def is_rom_in_device(self, rom: Rom) -> bool: """Check if a ROM exists in the storage path.""" rom_path = os.path.join( self.get_platforms_storage_path(rom.platform_slug), - rom.fs_name if not rom.multi else f"{rom.fs_name}.m3u", + rom.fs_name if not rom.has_multiple_files else f"{rom.fs_name}.m3u", ) return os.path.exists(rom_path) diff --git a/RomM/imageutils.py b/RomM/imageutils.py new file mode 100644 index 0000000..cc09853 --- /dev/null +++ b/RomM/imageutils.py @@ -0,0 +1,120 @@ +import os +from io import BytesIO +from typing import Optional +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from PIL import Image, ImageDraw + + +class ImageUtils: + _instance: Optional["ImageUtils"] = None + _initialized: bool = False + + screen_width = 640 + screen_height = 480 + + def __new__(cls): + if cls._instance is None: + cls._instance = super(ImageUtils, cls).__new__(cls) + return cls._instance + + def __init__(self) -> None: + if self._initialized: + return + + self.host = os.getenv("HOST", "").strip("/") + self.fade_mask = self.generate_fade_mask() + self._initialized = True + + def generate_fade_mask(self) -> Image.Image: + fade_mask = Image.new("L", (self.screen_width, self.screen_height), 0) + draw = ImageDraw.Draw(fade_mask) + x_crit = self.screen_width / 3.0 + + for x in range(self.screen_width): + if x < x_crit: + t = x / x_crit + alpha = int((t**2) * (255 / 3)) # a x = x_crit, alpha = 255/3 ≈ 85 + else: + t = (x - x_crit) / (self.screen_width - x_crit) + alpha = int(85 + t * (255 - 85)) + draw.line([(x, 0), (x, self.screen_height)], fill=alpha) + + return fade_mask + + def add_rounded_corners(self, image: Image.Image, radius: int = 20): + rounded_mask = Image.new("L", image.size, 0) + draw = ImageDraw.Draw(rounded_mask) + draw.rounded_rectangle( + (0, 0, image.size[0], image.size[1]), radius=radius, fill=255 + ) + image.putalpha(rounded_mask) + return image + + def load_image_from_url(self, url: str, headers: dict) -> Image.Image | None: + try: + req = Request(url.split("?")[0], headers=headers) + with urlopen(req, timeout=60) as response: # trunk-ignore(bandit/B310) + data = response.read() + return Image.open(BytesIO(data)).convert("RGBA") + except (URLError, HTTPError, IOError) as e: + print(f"Error loading image from URL {url}: {e}") + return None + + def process_assets( + self, + fullscreen: bool, + cover_url: str | None, + screenshot_urls: list[str], + box_path: str, + preview_path: str, + headers: dict, + ) -> None: + if not cover_url and not screenshot_urls: + return + + final_width, final_height = self.screen_width, self.screen_height + background = None + preview = ( + self.load_image_from_url(screenshot_urls[0], headers) + if len(screenshot_urls) > 0 + else None + ) + + if preview: + preview = preview.resize((final_width, final_height)) + preview.save(preview_path) + + if fullscreen: + if preview: + background = preview + else: + background = Image.new( + "RGBA", (final_width, final_height), (0, 0, 0, 0) + ) + background.putalpha(self.fade_mask) + + foreground = self.load_image_from_url(cover_url, headers) if cover_url else None + + if foreground: + max_cover_width = 215 + max_cover_height = int(final_height * 3 / 5) + scale_w = max_cover_width / foreground.width + scale_h = max_cover_height / foreground.height + scale = min(scale_w, scale_h) + new_cover_width = int(foreground.width * scale) + new_cover_height = int(foreground.height * scale) + foreground = foreground.resize((new_cover_width, new_cover_height)) + foreground = self.add_rounded_corners(foreground) + + fg_x = final_width - new_cover_width - 20 + fg_y = (final_height - new_cover_height) // 2 + + if background: + background.paste(foreground, (fg_x, fg_y), foreground) + else: + background = foreground + + if background: + background.save(box_path) diff --git a/RomM/models.py b/RomM/models.py index fab7557..d40c682 100644 --- a/RomM/models.py +++ b/RomM/models.py @@ -4,17 +4,40 @@ "Rom", [ "id", - "name", - "fs_name", + "platform_id", "platform_slug", + "fs_name", + "fs_name_no_tags", + "fs_name_no_ext", "fs_extension", "fs_size", "fs_size_bytes", - "multi", - "languages", - "regions", + "name", + "slug", + "summary", + "youtube_video_id", + "path_cover_small", + "path_cover_large", + "is_identified", "revision", + "regions", + "languages", "tags", + "crc_hash", + "md5_hash", + "sha1_hash", + "has_simple_single_file", + "has_nested_single_file", + "has_multiple_files", + "merged_screenshots", + "genres", + "franchises", + "collections", + "companies", + "game_modes", + "age_ratings", + "first_release_date", + "average_rating", ], ) Collection = namedtuple("Collection", ["id", "name", "rom_count", "virtual"]) diff --git a/RomM/platform_maps.py b/RomM/platform_maps.py index 2881ac5..3326039 100644 --- a/RomM/platform_maps.py +++ b/RomM/platform_maps.py @@ -10,9 +10,9 @@ # "slug": ("es-system", "icon"), "ngc": ("gamecube", "ngc"), # Nintendo GameCube "n3ds": ("3ds", "3ds"), # Nintendo 3DS - "genesis": ("genesis", "genesis-slash-megadrive"), # Sega Genesis / Megadrive - "megadrive": ("megadrive", "genesis-slash-megadrive"), # Sega Genesis / Megadrive - "mastersystem": ("mastersystem", "sega-master-system"), # Sega Mastersystem + "genesis": ("genesis", "genesis"), # Sega Genesis / Megadrive + "megadrive": ("megadrive", "genesis"), # Sega Genesis / Megadrive + "mastersystem": ("mastersystem", "sms"), # Sega Mastersystem } # Manual mapping of RomM slugs for MuOS default platforms diff --git a/RomM/romm.py b/RomM/romm.py index da68be7..c7f4df8 100644 --- a/RomM/romm.py +++ b/RomM/romm.py @@ -875,7 +875,7 @@ def update(self): def _remove_rom_files(self, rom: Rom): storage_path = self.fs.get_platforms_storage_path(rom.platform_slug) - if rom.multi: + if rom.has_multiple_files: # Read the m3u file to get the list of ROMs under the .hidden folder rom_list_path = os.path.join(storage_path, rom.fs_name + ".m3u") if os.path.isfile(rom_list_path):