diff --git a/src/importer/sources/bottles_source.py b/src/importer/sources/bottles_source.py index 8829023..42eebaa 100644 --- a/src/importer/sources/bottles_source.py +++ b/src/importer/sources/bottles_source.py @@ -20,6 +20,7 @@ from pathlib import Path from time import time +from typing import NamedTuple import yaml @@ -35,7 +36,7 @@ class BottlesSourceIterable(SourceIterable): def __iter__(self): """Generator method producing games""" - data = self.source.data_location["library.yml"].read_text("utf-8") + data = self.source.locations.data["library.yml"].read_text("utf-8") library: dict = yaml.safe_load(data) added_time = int(time()) @@ -58,11 +59,11 @@ class BottlesSourceIterable(SourceIterable): # as Cartridges can't access directories picked via Bottles' file picker portal bottles_location = Path( yaml.safe_load( - self.source.data_location["data.yml"].read_text("utf-8") + self.source.locations.data["data.yml"].read_text("utf-8") )["custom_bottles_path"] ) except (FileNotFoundError, KeyError): - bottles_location = self.source.data_location.root / "bottles" + bottles_location = self.source.locations.data.root / "bottles" bottle_path = entry["bottle"]["path"] @@ -76,6 +77,10 @@ class BottlesSourceIterable(SourceIterable): yield (game, additional_data) +class BottlesLocations(NamedTuple): + data: Location + + class BottlesSource(URLExecutableSource): """Generic Bottles source""" @@ -84,15 +89,17 @@ class BottlesSource(URLExecutableSource): url_format = 'bottles:run/"{bottle_name}"/"{game_name}"' available_on = {"linux"} - data_location = Location( - schema_key="bottles-location", - candidates=( - shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles", - shared.data_dir / "bottles/", - shared.home / ".local" / "share" / "bottles", - ), - paths={ - "library.yml": (False, "library.yml"), - "data.yml": (False, "data.yml"), - }, + locations = BottlesLocations( + Location( + schema_key="bottles-location", + candidates=( + shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles", + shared.data_dir / "bottles/", + shared.home / ".local" / "share" / "bottles", + ), + paths={ + "library.yml": (False, "library.yml"), + "data.yml": (False, "data.yml"), + }, + ) ) diff --git a/src/importer/sources/flatpak_source.py b/src/importer/sources/flatpak_source.py index 2c9a4aa..f29028c 100644 --- a/src/importer/sources/flatpak_source.py +++ b/src/importer/sources/flatpak_source.py @@ -19,6 +19,7 @@ from pathlib import Path from time import time +from typing import NamedTuple from gi.repository import GLib, Gtk @@ -37,7 +38,7 @@ class FlatpakSourceIterable(SourceIterable): added_time = int(time()) icon_theme = Gtk.IconTheme.new() - icon_theme.add_search_path(str(self.source.data_location["icons"])) + icon_theme.add_search_path(str(self.source.locations.data["icons"])) blacklist = ( {"hu.kramo.Cartridges", "hu.kramo.Cartridges.Devel"} @@ -53,7 +54,7 @@ class FlatpakSourceIterable(SourceIterable): } ) - for entry in (self.source.data_location["applications"]).iterdir(): + for entry in (self.source.locations.data["applications"]).iterdir(): if entry.suffix != ".desktop": continue @@ -111,6 +112,10 @@ class FlatpakSourceIterable(SourceIterable): yield (game, additional_data) +class FlatpakLocations(NamedTuple): + data: Location + + class FlatpakSource(Source): """Generic Flatpak source""" @@ -119,14 +124,16 @@ class FlatpakSource(Source): executable_format = "flatpak run {flatpak_id}" available_on = {"linux"} - data_location = Location( - schema_key="flatpak-location", - candidates=( - "/var/lib/flatpak/", - shared.data_dir / "flatpak", - ), - paths={ - "applications": (True, "exports/share/applications"), - "icons": (True, "exports/share/icons"), - }, + locations = FlatpakLocations( + Location( + schema_key="flatpak-location", + candidates=( + "/var/lib/flatpak/", + shared.data_dir / "flatpak", + ), + paths={ + "applications": (True, "exports/share/applications"), + "icons": (True, "exports/share/icons"), + }, + ) ) diff --git a/src/importer/sources/heroic_source.py b/src/importer/sources/heroic_source.py index d169129..1c28f14 100644 --- a/src/importer/sources/heroic_source.py +++ b/src/importer/sources/heroic_source.py @@ -25,7 +25,7 @@ from hashlib import sha256 from json import JSONDecodeError from pathlib import Path from time import time -from typing import Iterable, Optional, TypedDict +from typing import Iterable, NamedTuple, Optional, TypedDict from functools import cached_property from src import shared @@ -87,7 +87,7 @@ class SubSourceIterable(Iterable): @cached_property def library_path(self) -> Path: - path = self.source.config_location.root / self.relative_library_path + path = self.source.locations.config.root / self.relative_library_path logging.debug("Using Heroic %s library.json path %s", self.name, path) return path @@ -116,7 +116,7 @@ class SubSourceIterable(Iterable): # Filenames are derived from the URL that heroic used to get the file uri: str = entry["art_square"] + self.image_uri_params digest = sha256(uri.encode()).hexdigest() - image_path = self.source.config_location.root / "images-cache" / digest + image_path = self.source.locations.config.root / "images-cache" / digest additional_data = {"local_image_path": image_path} return (game, additional_data) @@ -159,7 +159,7 @@ class StoreSubSourceIterable(SubSourceIterable): @cached_property def installed_path(self) -> Path: - path = self.source.config_location.root / self.relative_installed_path + path = self.source.locations.config.root / self.relative_installed_path logging.debug("Using Heroic %s installed.json path %s", self.name, path) return path @@ -226,7 +226,7 @@ class LegendaryIterable(StoreSubSourceIterable): and remove this property override. """ - heroic_config_path = self.source.config_location.root + heroic_config_path = self.source.locations.config.root # Heroic >= 2.9 if (path := heroic_config_path / "legendaryConfig").is_dir(): logging.debug("Using Heroic >= 2.9 legendary file") @@ -308,7 +308,7 @@ class HeroicSourceIterable(SourceIterable): """ try: - store = path_json_load(self.source.config_location["store_config.json"]) + store = path_json_load(self.source.locations.config["store_config.json"]) self.hidden_app_names = { app_name for game in store["games"]["hidden"] @@ -349,6 +349,10 @@ class HeroicSourceIterable(SourceIterable): continue +class HeroicLocations(NamedTuple): + config: Location + + class HeroicSource(URLExecutableSource): """Generic Heroic Games Launcher source""" @@ -357,18 +361,23 @@ class HeroicSource(URLExecutableSource): url_format = "heroic://launch/{app_name}" available_on = {"linux", "win32"} - config_location = Location( - schema_key="heroic-location", - candidates=( - shared.config_dir / "heroic", - shared.home / ".config" / "heroic", - shared.flatpak_dir / "com.heroicgameslauncher.hgl" / "config" / "heroic", - shared.appdata_dir / "heroic", - ), - paths={ - "config.json": (False, "config.json"), - "store_config.json": (False, Path("store") / "config.json"), - }, + locations = HeroicLocations( + Location( + schema_key="heroic-location", + candidates=( + shared.config_dir / "heroic", + shared.home / ".config" / "heroic", + shared.flatpak_dir + / "com.heroicgameslauncher.hgl" + / "config" + / "heroic", + shared.appdata_dir / "heroic", + ), + paths={ + "config.json": (False, "config.json"), + "store_config.json": (False, Path("store") / "config.json"), + }, + ) ) @property diff --git a/src/importer/sources/itch_source.py b/src/importer/sources/itch_source.py index a6d8990..dc4d4f9 100644 --- a/src/importer/sources/itch_source.py +++ b/src/importer/sources/itch_source.py @@ -21,6 +21,7 @@ from shutil import rmtree from sqlite3 import connect from time import time +from typing import NamedTuple from src import shared from src.game import Game @@ -51,7 +52,7 @@ class ItchSourceIterable(SourceIterable): caves.game_id = games.id ; """ - db_path = copy_db(self.source.config_location["butler.db"]) + db_path = copy_db(self.source.locations.config["butler.db"]) connection = connect(db_path) cursor = connection.execute(db_request) @@ -74,19 +75,25 @@ class ItchSourceIterable(SourceIterable): rmtree(str(db_path.parent)) +class ItchLocations(NamedTuple): + config: Location + + class ItchSource(URLExecutableSource): name = _("itch") iterable_class = ItchSourceIterable url_format = "itch://caves/{cave_id}/launch" available_on = {"linux", "win32"} - config_location = Location( - schema_key="itch-location", - candidates=( - shared.flatpak_dir / "io.itch.itch" / "config" / "itch", - shared.config_dir / "itch", - shared.home / ".config" / "itch", - shared.appdata_dir / "itch", - ), - paths={"butler.db": (False, "db/butler.db")}, + locations = ItchLocations( + Location( + schema_key="itch-location", + candidates=( + shared.flatpak_dir / "io.itch.itch" / "config" / "itch", + shared.config_dir / "itch", + shared.home / ".config" / "itch", + shared.appdata_dir / "itch", + ), + paths={"butler.db": (False, "db/butler.db")}, + ) ) diff --git a/src/importer/sources/legendary_source.py b/src/importer/sources/legendary_source.py index c7e06de..529bd8c 100644 --- a/src/importer/sources/legendary_source.py +++ b/src/importer/sources/legendary_source.py @@ -21,6 +21,7 @@ import json import logging from json import JSONDecodeError from time import time +from typing import NamedTuple from src import shared from src.game import Game @@ -50,7 +51,7 @@ class LegendarySourceIterable(SourceIterable): data = {} # Get additional metadata from file (optional) - metadata_file = self.source.config_location["metadata"] / f"{app_name}.json" + metadata_file = self.source.locations.config["metadata"] / f"{app_name}.json" try: metadata = json.load(metadata_file.open()) values["developer"] = metadata["metadata"]["developer"] @@ -66,7 +67,7 @@ class LegendarySourceIterable(SourceIterable): def __iter__(self): # Open library - file = self.source.config_location["installed.json"] + file = self.source.locations.config["installed.json"] try: library: dict = json.load(file.open()) except (JSONDecodeError, OSError): @@ -88,20 +89,26 @@ class LegendarySourceIterable(SourceIterable): yield result +class LegendaryLocations(NamedTuple): + config: Location + + class LegendarySource(Source): name = _("Legendary") executable_format = "legendary launch {app_name}" available_on = {"linux"} - iterable_class = LegendarySourceIterable - config_location: Location = Location( - schema_key="legendary-location", - candidates=( - shared.config_dir / "legendary", - shared.home / ".config" / "legendary", - ), - paths={ - "installed.json": (False, "installed.json"), - "metadata": (True, "metadata"), - }, + + locations = LegendaryLocations( + Location( + schema_key="legendary-location", + candidates=( + shared.config_dir / "legendary", + shared.home / ".config" / "legendary", + ), + paths={ + "installed.json": (False, "installed.json"), + "metadata": (True, "metadata"), + }, + ) ) diff --git a/src/importer/sources/lutris_source.py b/src/importer/sources/lutris_source.py index 7c100a8..5a26fc1 100644 --- a/src/importer/sources/lutris_source.py +++ b/src/importer/sources/lutris_source.py @@ -20,6 +20,7 @@ from shutil import rmtree from sqlite3 import connect from time import time +from typing import NamedTuple from src import shared from src.game import Game @@ -51,7 +52,7 @@ class LutrisSourceIterable(SourceIterable): "import_steam": shared.schema.get_boolean("lutris-import-steam"), "import_flatpak": shared.schema.get_boolean("lutris-import-flatpak"), } - db_path = copy_db(self.source.data_location["pga.db"]) + db_path = copy_db(self.source.locations.config["pga.db"]) connection = connect(db_path) cursor = connection.execute(request, params) @@ -73,7 +74,7 @@ class LutrisSourceIterable(SourceIterable): game = Game(values) # Get official image path - image_path = self.source.cache_location["coverart"] / f"{row[2]}.jpg" + image_path = self.source.locations.cache["coverart"] / f"{row[2]}.jpg" additional_data = {"local_image_path": image_path} # Produce game @@ -83,6 +84,11 @@ class LutrisSourceIterable(SourceIterable): rmtree(str(db_path.parent)) +class LutrisLocations(NamedTuple): + config: Location + cache: Location + + class LutrisSource(URLExecutableSource): """Generic Lutris source""" @@ -91,30 +97,31 @@ class LutrisSource(URLExecutableSource): url_format = "lutris:rungameid/{game_id}" available_on = {"linux"} - # FIXME possible bug: location picks ~/.var... and cache_lcoation picks ~/.local... + # FIXME possible bug: config picks ~/.var... and cache picks ~/.local... - data_location = Location( - schema_key="lutris-location", - candidates=( - shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris", - shared.data_dir / "lutris", - shared.home / ".local" / "share" / "lutris", + locations = LutrisLocations( + Location( + schema_key="lutris-location", + candidates=( + shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris", + shared.data_dir / "lutris", + shared.home / ".local" / "share" / "lutris", + ), + paths={ + "pga.db": (False, "pga.db"), + }, ), - paths={ - "pga.db": (False, "pga.db"), - }, - ) - - cache_location = Location( - schema_key="lutris-cache-location", - candidates=( - shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris", - shared.cache_dir / "lutris", - shared.home / ".cache" / "lutris", + Location( + schema_key="lutris-cache-location", + candidates=( + shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris", + shared.cache_dir / "lutris", + shared.home / ".cache" / "lutris", + ), + paths={ + "coverart": (True, "coverart"), + }, ), - paths={ - "coverart": (True, "coverart"), - }, ) @property diff --git a/src/importer/sources/source.py b/src/importer/sources/source.py index d7ba467..164a792 100644 --- a/src/importer/sources/source.py +++ b/src/importer/sources/source.py @@ -19,8 +19,8 @@ import sys from abc import abstractmethod -from collections.abc import Iterable, Iterator -from typing import Any, Generator, Optional +from collections.abc import Iterable +from typing import Any, Generator, Optional, Collection from src.game import Game from src.importer.sources.location import Location @@ -54,10 +54,8 @@ class Source(Iterable): name: str variant: str = None available_on: set[str] = set() - data_location: Optional[Location] = None - cache_location: Optional[Location] = None - config_location: Optional[Location] = None iterable_class: type[SourceIterable] + locations: Collection[Location] @property def full_name(self) -> str: diff --git a/src/importer/sources/steam_source.py b/src/importer/sources/steam_source.py index 7e65e5b..e4e9cfb 100644 --- a/src/importer/sources/steam_source.py +++ b/src/importer/sources/steam_source.py @@ -22,7 +22,7 @@ import logging import re from pathlib import Path from time import time -from typing import Iterable +from typing import Iterable, NamedTuple from src import shared from src.game import Game @@ -36,7 +36,7 @@ class SteamSourceIterable(SourceIterable): def get_manifest_dirs(self) -> Iterable[Path]: """Get dirs that contain steam app manifests""" - libraryfolders_path = self.source.data_location["libraryfolders.vdf"] + libraryfolders_path = self.source.locations.data["libraryfolders.vdf"] with open(libraryfolders_path, "r", encoding="utf-8") as file: contents = file.read() return [ @@ -100,7 +100,7 @@ class SteamSourceIterable(SourceIterable): # Add official cover image image_path = ( - self.source.data_location["librarycache"] + self.source.locations.data["librarycache"] / f"{appid}_library_600x900.jpg" ) additional_data = {"local_image_path": image_path, "steam_appid": appid} @@ -109,22 +109,28 @@ class SteamSourceIterable(SourceIterable): yield (game, additional_data) +class SteamLocations(NamedTuple): + data: Location + + class SteamSource(URLExecutableSource): name = _("Steam") available_on = {"linux", "win32"} iterable_class = SteamSourceIterable url_format = "steam://rungameid/{game_id}" - data_location = Location( - schema_key="steam-location", - candidates=( - shared.home / ".steam" / "steam", - shared.data_dir / "Steam", - shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam", - shared.programfiles32_dir / "Steam", - ), - paths={ - "libraryfolders.vdf": (False, "steamapps/libraryfolders.vdf"), - "librarycache": (True, "appcache/librarycache"), - }, + locations = SteamLocations( + Location( + schema_key="steam-location", + candidates=( + shared.home / ".steam" / "steam", + shared.data_dir / "Steam", + shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam", + shared.programfiles32_dir / "Steam", + ), + paths={ + "libraryfolders.vdf": (False, "steamapps/libraryfolders.vdf"), + "librarycache": (True, "appcache/librarycache"), + }, + ) ) diff --git a/src/preferences.py b/src/preferences.py index fdafd69..7300523 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -262,19 +262,19 @@ class PreferencesWindow(Adw.PreferencesWindow): subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path)) action_row.set_subtitle(subtitle) - def resolve_locations(self, source): + def resolve_locations(self, source: Source): """Resolve locations and add a warning if location cannot be found""" def clear_warning_selection(_widget, label): label.select_region(-1, -1) - for location_name in ("data", "config", "cache"): + for location_name, location in source.locations._asdict().items(): action_row = getattr(self, f"{source.id}_{location_name}_action_row", None) if not action_row: continue try: - getattr(source, f"{location_name}_location", None).resolve() + location.resolve() except UnresolvableLocationError: popover = Gtk.Popover( @@ -325,10 +325,14 @@ class PreferencesWindow(Adw.PreferencesWindow): return # Good picked location - location = getattr(source, f"{location_name}_location") + location = getattr(source.locations, location_name) if location.check_candidate(path): # Set the schema - infix = "-cache" if location_name == "cache" else "" + match location_name: + case "config" | "data": + infix = "" + case _: + infix = f"-{location_name}" key = f"{source.id}{infix}-location" value = str(path) shared.schema.set_string(key, value) @@ -381,10 +385,12 @@ class PreferencesWindow(Adw.PreferencesWindow): ) # Connect dir picker buttons - for location in ("data", "config", "cache"): - button = getattr(self, f"{source.id}_{location}_file_chooser_button", None) + for location_name in source.locations._asdict(): + button = getattr( + self, f"{source.id}_{location_name}_file_chooser_button", None + ) if button is not None: - button.connect("clicked", self.choose_folder, set_dir, location) + button.connect("clicked", self.choose_folder, set_dir, location_name) # Set the source row subtitles self.resolve_locations(source)