🚧 WIP new location system
TODO - Locations contain the schema key - Schema key overriden at location resolve - No need for callable candidates, but need to represent "this location's key"
This commit is contained in:
@@ -25,15 +25,12 @@ import yaml
|
||||
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.location import Location
|
||||
from src.importer.sources.source import (
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
URLExecutableSource,
|
||||
)
|
||||
from src.utils.decorators import (
|
||||
replaced_by_path,
|
||||
replaced_by_schema_key,
|
||||
)
|
||||
|
||||
|
||||
class BottlesSourceIterator(SourceIterator):
|
||||
@@ -42,7 +39,7 @@ class BottlesSourceIterator(SourceIterator):
|
||||
def generator_builder(self) -> SourceIterationResult:
|
||||
"""Generator method producing games"""
|
||||
|
||||
data = (self.source.location / "library.yml").read_text("utf-8")
|
||||
data = self.source.data_location["library.yml"].read_text("utf-8")
|
||||
library: dict = yaml.safe_load(data)
|
||||
|
||||
for entry in library.values():
|
||||
@@ -65,11 +62,11 @@ class BottlesSourceIterator(SourceIterator):
|
||||
# as Cartridges can't access directories picked via Bottles' file picker portal
|
||||
bottles_location = Path(
|
||||
yaml.safe_load(
|
||||
(self.source.location / "data.yml").read_text("utf-8")
|
||||
self.source.data_location["data.yml"].read_text("utf-8")
|
||||
)["custom_bottles_path"]
|
||||
)
|
||||
except (FileNotFoundError, KeyError):
|
||||
bottles_location = self.source.location / "bottles"
|
||||
bottles_location = self.source.data_location.root / "bottles"
|
||||
|
||||
bottle_path = entry["bottle"]["path"]
|
||||
image_name = entry["thumbnail"].split(":")[1]
|
||||
@@ -88,9 +85,14 @@ class BottlesSource(URLExecutableSource):
|
||||
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
|
||||
available_on = set(("linux",))
|
||||
|
||||
@property
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/com.usebottles.bottles/data/bottles/")
|
||||
@replaced_by_path(shared.data_dir / "bottles")
|
||||
def location(self) -> Path:
|
||||
raise FileNotFoundError()
|
||||
data_location = Location(
|
||||
candidates=(
|
||||
lambda: shared.schema.get_string("bottles-location"),
|
||||
"~/.var/app/com.usebottles.bottles/data/bottles/",
|
||||
shared.data_dir / "bottles/",
|
||||
),
|
||||
paths={
|
||||
"library.yml": (False, "library.yml"),
|
||||
"data.yml": (False, "data.yml"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -22,21 +22,17 @@ import json
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
from json import JSONDecodeError
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.location import Location
|
||||
from src.importer.sources.source import (
|
||||
URLExecutableSource,
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
)
|
||||
from src.utils.decorators import (
|
||||
replaced_by_path,
|
||||
replaced_by_schema_key,
|
||||
)
|
||||
|
||||
|
||||
class HeroicLibraryEntry(TypedDict):
|
||||
@@ -103,7 +99,7 @@ class HeroicSourceIterator(SourceIterator):
|
||||
if service == "epic":
|
||||
uri += "?h=400&resize=1&w=300"
|
||||
digest = sha256(uri.encode()).hexdigest()
|
||||
image_path = self.source.location / "images-cache" / digest
|
||||
image_path = self.source.config_location.root / "images-cache" / digest
|
||||
additional_data = {"local_image_path": image_path}
|
||||
|
||||
return (game, additional_data)
|
||||
@@ -116,7 +112,7 @@ class HeroicSourceIterator(SourceIterator):
|
||||
if not shared.schema.get_boolean("heroic-import-" + sub_source["service"]):
|
||||
continue
|
||||
# Load games from JSON
|
||||
file = self.source.location.joinpath(*sub_source["path"])
|
||||
file = self.source.config_location.root.joinpath(*sub_source["path"])
|
||||
try:
|
||||
library = json.load(file.open())["library"]
|
||||
except (JSONDecodeError, OSError, KeyError):
|
||||
@@ -141,15 +137,20 @@ class HeroicSource(URLExecutableSource):
|
||||
url_format = "heroic://launch/{app_name}"
|
||||
available_on = set(("linux", "win32"))
|
||||
|
||||
config_location = Location(
|
||||
candidates=(
|
||||
lambda: shared.schema.get_string("heroic-location"),
|
||||
"~/.var/app/com.heroicgameslauncher.hgl/config/heroic/",
|
||||
shared.config_dir / "heroic/",
|
||||
"~/.config/heroic/",
|
||||
shared.appdata_dir / "heroic/",
|
||||
),
|
||||
paths={
|
||||
"config.json": (False, "config.json"),
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def game_id_format(self) -> str:
|
||||
"""The string format used to construct game IDs"""
|
||||
return self.name.lower() + "_{service}_{game_id}"
|
||||
|
||||
@property
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/com.heroicgameslauncher.hgl/config/heroic/")
|
||||
@replaced_by_path(shared.config_dir / "heroic")
|
||||
@replaced_by_path(shared.appdata_dir / "heroic")
|
||||
def location(self) -> Path:
|
||||
raise FileNotFoundError()
|
||||
|
||||
@@ -18,19 +18,18 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from sqlite3 import connect
|
||||
from time import time
|
||||
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.location import Location
|
||||
from src.importer.sources.source import (
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
URLExecutableSource,
|
||||
)
|
||||
from src.utils.decorators import replaced_by_path, replaced_by_schema_key
|
||||
from src.utils.sqlite import copy_db
|
||||
|
||||
|
||||
@@ -56,7 +55,7 @@ class ItchSourceIterator(SourceIterator):
|
||||
caves.game_id = games.id
|
||||
;
|
||||
"""
|
||||
db_path = copy_db(self.source.location / "db" / "butler.db")
|
||||
db_path = copy_db(self.source.config_location["butler.db"])
|
||||
connection = connect(db_path)
|
||||
cursor = connection.execute(db_request)
|
||||
|
||||
@@ -84,10 +83,13 @@ class ItchSource(URLExecutableSource):
|
||||
url_format = "itch://caves/{cave_id}/launch"
|
||||
available_on = set(("linux", "win32"))
|
||||
|
||||
@property
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/io.itch.itch/config/itch/")
|
||||
@replaced_by_path(shared.config_dir / "itch")
|
||||
@replaced_by_path(shared.appdata_dir / "itch")
|
||||
def location(self) -> Path:
|
||||
raise FileNotFoundError()
|
||||
config_location = Location(
|
||||
candidates=(
|
||||
lambda: shared.schema.get_string("itch-location"),
|
||||
"~/.var/app/io.itch.itch/config/itch/",
|
||||
shared.config_dir / "itch/",
|
||||
"~/.config/itch/",
|
||||
shared.appdata_dir / "itch/",
|
||||
),
|
||||
paths={"butler.db": (False, "db/butler.db")},
|
||||
)
|
||||
|
||||
@@ -26,8 +26,8 @@ from typing import Generator
|
||||
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.location import Location
|
||||
from src.importer.sources.source import Source, SourceIterationResult, SourceIterator
|
||||
from src.utils.decorators import replaced_by_path, replaced_by_schema_key
|
||||
|
||||
|
||||
class LegendarySourceIterator(SourceIterator):
|
||||
@@ -51,7 +51,7 @@ class LegendarySourceIterator(SourceIterator):
|
||||
data = {}
|
||||
|
||||
# Get additional metadata from file (optional)
|
||||
metadata_file = self.source.location / "metadata" / f"{app_name}.json"
|
||||
metadata_file = self.source.data_location["metadata"] / f"{app_name}.json"
|
||||
try:
|
||||
metadata = json.load(metadata_file.open())
|
||||
values["developer"] = metadata["metadata"]["developer"]
|
||||
@@ -67,7 +67,7 @@ class LegendarySourceIterator(SourceIterator):
|
||||
|
||||
def generator_builder(self) -> Generator[SourceIterationResult, None, None]:
|
||||
# Open library
|
||||
file = self.source.location / "installed.json"
|
||||
file = self.source.data_location["installed.json"]
|
||||
try:
|
||||
library: dict = json.load(file.open())
|
||||
except (JSONDecodeError, OSError):
|
||||
@@ -89,11 +89,17 @@ class LegendarySourceIterator(SourceIterator):
|
||||
class LegendarySource(Source):
|
||||
name = "Legendary"
|
||||
executable_format = "legendary launch {app_name}"
|
||||
iterator_class = LegendarySourceIterator
|
||||
available_on = set(("linux", "win32"))
|
||||
|
||||
@property
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path(shared.config_dir / "legendary")
|
||||
def location(self) -> Path:
|
||||
raise FileNotFoundError()
|
||||
iterator_class = LegendarySourceIterator
|
||||
data_location: Location = Location(
|
||||
candidates=(
|
||||
lambda: shared.schema.get_string("legendary-location"),
|
||||
shared.config_dir / "legendary/",
|
||||
"~/.config/legendary",
|
||||
),
|
||||
paths={
|
||||
"installed.json": (False, "installed.json"),
|
||||
"metadata": (True, "metadata"),
|
||||
},
|
||||
)
|
||||
|
||||
64
src/importer/sources/location.py
Normal file
64
src/importer/sources/location.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from pathlib import Path
|
||||
from typing import Callable, Mapping, Iterable
|
||||
from os import PathLike
|
||||
|
||||
PathSegment = str | PathLike | Path
|
||||
PathSegments = Iterable[PathSegment]
|
||||
Candidate = PathSegments | Callable[[], PathSegments]
|
||||
|
||||
|
||||
class UnresolvableLocationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Location:
|
||||
"""
|
||||
Class representing a filesystem location
|
||||
|
||||
* A location may have multiple candidate roots
|
||||
* From its root, multiple subpaths are named and should exist
|
||||
"""
|
||||
|
||||
candidates: Iterable[Candidate]
|
||||
paths: Mapping[str, tuple[bool, PathSegments]]
|
||||
root: Path = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
candidates: Iterable[Candidate],
|
||||
paths: Mapping[str, tuple[bool, PathSegments]],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.candidates = candidates
|
||||
self.paths = paths
|
||||
|
||||
def check_candidate(self, candidate: Path) -> bool:
|
||||
"""Check if a candidate root has the necessary files and directories"""
|
||||
for type_is_dir, subpath in self.paths.values():
|
||||
subpath = Path(candidate) / Path(subpath)
|
||||
if type_is_dir:
|
||||
if not subpath.is_dir():
|
||||
return False
|
||||
else:
|
||||
if not subpath.is_file():
|
||||
return False
|
||||
return True
|
||||
|
||||
def resolve(self) -> None:
|
||||
"""Choose a root path from the candidates for the location.
|
||||
If none fits, raise a UnresolvableLocationError"""
|
||||
if self.root is not None:
|
||||
return
|
||||
for candidate in self.candidates:
|
||||
if callable(candidate):
|
||||
candidate = candidate()
|
||||
candidate = Path(candidate).expanduser()
|
||||
if self.check_candidate(candidate):
|
||||
self.root = candidate
|
||||
return
|
||||
raise UnresolvableLocationError()
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
"""Get the computed path from its key for the location"""
|
||||
self.resolve()
|
||||
return self.root / self.paths[key][1]
|
||||
@@ -17,19 +17,18 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from shutil import rmtree
|
||||
from sqlite3 import connect
|
||||
from time import time
|
||||
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.location import Location
|
||||
from src.importer.sources.source import (
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
URLExecutableSource,
|
||||
)
|
||||
from src.utils.decorators import replaced_by_path, replaced_by_schema_key
|
||||
from src.utils.sqlite import copy_db
|
||||
|
||||
|
||||
@@ -52,7 +51,7 @@ class LutrisSourceIterator(SourceIterator):
|
||||
;
|
||||
"""
|
||||
params = {"import_steam": shared.schema.get_boolean("lutris-import-steam")}
|
||||
db_path = copy_db(self.source.location / "pga.db")
|
||||
db_path = copy_db(self.source.data_location["pga.db"])
|
||||
connection = connect(db_path)
|
||||
cursor = connection.execute(request, params)
|
||||
|
||||
@@ -73,7 +72,7 @@ class LutrisSourceIterator(SourceIterator):
|
||||
game = Game(values, allow_side_effects=False)
|
||||
|
||||
# Get official image path
|
||||
image_path = self.source.location / "covers" / "coverart" / f"{row[2]}.jpg"
|
||||
image_path = self.source.cache_location["coverart"] / f"{row[2]}.jpg"
|
||||
additional_data = {"local_image_path": image_path}
|
||||
|
||||
# Produce game
|
||||
@@ -91,13 +90,32 @@ class LutrisSource(URLExecutableSource):
|
||||
url_format = "lutris:rungameid/{game_id}"
|
||||
available_on = set(("linux",))
|
||||
|
||||
# FIXME possible bug: location picks ~/.var... and cache_lcoation picks ~/.local...
|
||||
|
||||
data_location = Location(
|
||||
candidates=(
|
||||
lambda: shared.schema.get_string("lutris-location"),
|
||||
"~/.var/app/net.lutris.Lutris/data/lutris/",
|
||||
shared.data_dir / "lutris/",
|
||||
"~/.local/share/lutris/",
|
||||
),
|
||||
paths={
|
||||
"pga.db": (False, "pga.db"),
|
||||
},
|
||||
)
|
||||
|
||||
cache_location = Location(
|
||||
candidates=(
|
||||
lambda: shared.schema.get_string("lutris-cache-location"),
|
||||
"~/.var/app/net.lutris.Lutris/cache/lutris/",
|
||||
shared.cache_dir / "lutris/",
|
||||
"~/.cache/lutris",
|
||||
),
|
||||
paths={
|
||||
"coverart": (True, "coverart"),
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def game_id_format(self):
|
||||
return super().game_id_format + "_{game_internal_id}"
|
||||
|
||||
@property
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/net.lutris.Lutris/data/lutris/")
|
||||
@replaced_by_path("~/.local/share/lutris/")
|
||||
def location(self):
|
||||
raise FileNotFoundError()
|
||||
|
||||
@@ -20,10 +20,9 @@
|
||||
import sys
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Generator, Any
|
||||
from typing import Generator, Any, Optional
|
||||
|
||||
from src import shared
|
||||
from src.importer.sources.location import Location
|
||||
from src.game import Game
|
||||
|
||||
# Type of the data returned by iterating on a Source
|
||||
@@ -62,9 +61,12 @@ class Source(Iterable):
|
||||
"""Source of games. E.g an installed app with a config file that lists game directories"""
|
||||
|
||||
name: str
|
||||
iterator_class: type[SourceIterator]
|
||||
variant: str = None
|
||||
available_on: set[str] = set()
|
||||
data_location: Optional[Location] = None
|
||||
cache_location: Optional[Location] = None
|
||||
config_location: Optional[Location] = None
|
||||
iterator_class: type[SourceIterator]
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
@@ -88,44 +90,21 @@ class Source(Iterable):
|
||||
return self.name.lower() + "_{game_id}"
|
||||
|
||||
@property
|
||||
def is_installed(self):
|
||||
# pylint: disable=pointless-statement
|
||||
try:
|
||||
self.location
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
def is_available(self):
|
||||
return sys.platform in self.available_on
|
||||
|
||||
@property
|
||||
def location_key(self) -> str:
|
||||
"""
|
||||
The schema key pointing to the user-set location for the source.
|
||||
May be overriden by inherinting classes.
|
||||
"""
|
||||
return f"{self.name.lower()}-location"
|
||||
|
||||
def update_location_schema_key(self):
|
||||
"""Update the schema value for this source's location if possible"""
|
||||
try:
|
||||
location = self.location
|
||||
except FileNotFoundError:
|
||||
return
|
||||
shared.schema.set_string(self.location_key, location)
|
||||
|
||||
def __iter__(self) -> SourceIterator:
|
||||
"""Get an iterator for the source"""
|
||||
return self.iterator_class(self)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def location(self) -> Path:
|
||||
"""The source's location on disk"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def executable_format(self) -> str:
|
||||
"""The executable format used to construct game executables"""
|
||||
|
||||
def __iter__(self) -> SourceIterator:
|
||||
"""Get an iterator for the source"""
|
||||
for location in (self.data_location, self.cache_location, self.config_location):
|
||||
if location is not None:
|
||||
location.resolve()
|
||||
return self.iterator_class(self)
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class URLExecutableSource(Source):
|
||||
|
||||
@@ -35,6 +35,7 @@ from src.utils.decorators import (
|
||||
replaced_by_schema_key,
|
||||
)
|
||||
from src.utils.steam import SteamFileHelper, SteamInvalidManifestError
|
||||
from src.importer.sources.location import Location
|
||||
|
||||
|
||||
class SteamSourceIterator(SourceIterator):
|
||||
@@ -42,7 +43,7 @@ class SteamSourceIterator(SourceIterator):
|
||||
|
||||
def get_manifest_dirs(self) -> Iterable[Path]:
|
||||
"""Get dirs that contain steam app manifests"""
|
||||
libraryfolders_path = self.source.location / "steamapps" / "libraryfolders.vdf"
|
||||
libraryfolders_path = self.source.data_location["libraryfolders.vdf"]
|
||||
with open(libraryfolders_path, "r", encoding="utf-8") as file:
|
||||
contents = file.read()
|
||||
return [
|
||||
@@ -101,9 +102,7 @@ class SteamSourceIterator(SourceIterator):
|
||||
|
||||
# Add official cover image
|
||||
image_path = (
|
||||
self.source.location
|
||||
/ "appcache"
|
||||
/ "librarycache"
|
||||
self.source.data_location["librarycache"]
|
||||
/ f"{appid}_library_600x900.jpg"
|
||||
)
|
||||
additional_data = {"local_image_path": image_path, "steam_appid": appid}
|
||||
@@ -114,15 +113,20 @@ class SteamSourceIterator(SourceIterator):
|
||||
|
||||
class SteamSource(URLExecutableSource):
|
||||
name = "Steam"
|
||||
available_on = set(("linux", "win32"))
|
||||
iterator_class = SteamSourceIterator
|
||||
url_format = "steam://rungameid/{game_id}"
|
||||
available_on = set(("linux", "win32"))
|
||||
|
||||
@property
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/com.valvesoftware.Steam/data/Steam/")
|
||||
@replaced_by_path(shared.data_dir / "Steam")
|
||||
@replaced_by_path("~/.steam/")
|
||||
@replaced_by_path(shared.programfiles32_dir / "Steam")
|
||||
def location(self):
|
||||
raise FileNotFoundError()
|
||||
data_location = Location(
|
||||
candidates=(
|
||||
lambda: shared.schema.get_string("steam-location"),
|
||||
"~/.var/app/com.valvesoftware.Steam/data/Steam/",
|
||||
shared.data_dir / "Steam/",
|
||||
"~/.steam/",
|
||||
shared.programfiles32_dir / "Steam",
|
||||
),
|
||||
paths={
|
||||
"libraryfolders.vdf": (False, "steamapps/libraryfolders.vdf"),
|
||||
"librarycache": (True, "appcache/librarycache"),
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user