Merge branch 'main' into libadwaita-1.4

This commit is contained in:
kramo
2023-08-16 15:59:38 +02:00
24 changed files with 622 additions and 451 deletions

View File

@@ -49,8 +49,16 @@ class Importer(ErrorProducer):
n_pipelines_done: int = 0
game_pipelines: set[Pipeline] = None
removed_game_ids: set[str] = set()
imported_game_ids: set[str] = set()
def __init__(self):
super().__init__()
# TODO: make this stateful
shared.store.new_game_ids = set()
shared.store.duplicate_game_ids = set()
self.game_pipelines = set()
self.sources = set()
@@ -218,9 +226,37 @@ class Importer(ErrorProducer):
if self.finished:
self.import_callback()
def remove_games(self):
"""Set removed to True for missing games"""
if not shared.schema.get_boolean("remove-missing"):
return
for game in shared.store:
if game.removed:
continue
if game.source == "imported":
continue
if not shared.schema.get_boolean(game.base_source):
continue
if game.game_id in shared.store.duplicate_game_ids:
continue
if game.game_id in shared.store.new_game_ids:
continue
logging.debug("Removing missing game %s (%s)", game.name, game.game_id)
game.removed = True
game.save()
game.update()
self.removed_game_ids.add(game.game_id)
def import_callback(self):
"""Callback called when importing has finished"""
logging.info("Import done")
self.remove_games()
self.imported_game_ids = shared.store.new_game_ids
shared.store.new_game_ids = set()
shared.store.duplicate_game_ids = set()
self.import_dialog.close()
self.summary_toast = self.create_summary_toast()
self.create_error_dialog()
@@ -280,27 +316,60 @@ class Importer(ErrorProducer):
dialog.present()
def undo_import(self, *_args):
for game_id in self.imported_game_ids:
shared.store[game_id].removed = True
shared.store[game_id].update()
shared.store[game_id].save()
for game_id in self.removed_game_ids:
shared.store[game_id].removed = False
shared.store[game_id].update()
shared.store[game_id].save()
self.imported_game_ids = set()
self.removed_game_ids = set()
self.summary_toast.dismiss()
logging.info("Import undone")
def create_summary_toast(self):
"""N games imported toast"""
"""N games imported, removed toast"""
toast = Adw.Toast(timeout=0, priority=Adw.ToastPriority.HIGH)
if self.n_games_added == 0:
toast.set_title(_("No new games found"))
toast.set_button_label(_("Preferences"))
toast.connect(
"button-clicked",
self.dialog_response_callback,
"open_preferences",
"import",
)
if not self.n_games_added:
toast_title = _("No new games found")
if not self.removed_game_ids:
toast.set_button_label(_("Preferences"))
toast.connect(
"button-clicked",
self.dialog_response_callback,
"open_preferences",
"import",
)
elif self.n_games_added == 1:
toast.set_title(_("1 game imported"))
toast_title = _("1 game imported")
elif self.n_games_added > 1:
# The variable is the number of games
toast.set_title(_("{} games imported").format(self.n_games_added))
toast_title = _("{} games imported").format(self.n_games_added)
if (removed_length := len(self.removed_game_ids)) == 1:
# A single game removed
toast_title += ", " + _("1 removed")
elif removed_length > 1:
# The variable is the number of games removed
toast_title += ", " + _("{} removed").format(removed_length)
if self.n_games_added or self.removed_game_ids:
toast.set_button_label(_("Undo"))
toast.connect("button-clicked", self.undo_import)
toast.set_title(toast_title)
shared.win.toast_overlay.add_toast(toast)
return toast

View File

@@ -90,18 +90,22 @@ class BottlesSource(URLExecutableSource):
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
available_on = {"linux"}
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": LocationSubPath("library.yml"),
"data.yml": LocationSubPath("data.yml"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
locations: BottlesLocations
def __init__(self) -> None:
super().__init__()
self.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": LocationSubPath("library.yml"),
"data.yml": LocationSubPath("data.yml"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
)
)

View File

@@ -125,17 +125,21 @@ class FlatpakSource(Source):
executable_format = "flatpak run {flatpak_id}"
available_on = {"linux"}
locations = FlatpakLocations(
Location(
schema_key="flatpak-location",
candidates=(
"/var/lib/flatpak/",
shared.data_dir / "flatpak",
),
paths={
"applications": LocationSubPath("exports/share/applications", True),
"icons": LocationSubPath("exports/share/icons", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
locations: FlatpakLocations
def __init__(self) -> None:
super().__init__()
self.locations = FlatpakLocations(
Location(
schema_key="flatpak-location",
candidates=(
"/var/lib/flatpak/",
shared.data_dir / "flatpak",
),
paths={
"applications": LocationSubPath("exports/share/applications", True),
"icons": LocationSubPath("exports/share/icons", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
)
)

View File

@@ -21,12 +21,12 @@
import json
import logging
from abc import abstractmethod
from functools import cached_property
from hashlib import sha256
from json import JSONDecodeError
from pathlib import Path
from time import time
from typing import Iterable, NamedTuple, Optional, TypedDict
from functools import cached_property
from src import shared
from src.game import Game
@@ -108,7 +108,9 @@ class SubSourceIterable(Iterable):
"game_id": self.source.game_id_format.format(
service=self.service, game_id=app_name
),
"executable": self.source.executable_format.format(runner=runner, app_name=app_name),
"executable": self.source.executable_format.format(
runner=runner, app_name=app_name
),
"hidden": self.source_iterable.is_hidden(app_name),
}
game = Game(values)
@@ -363,27 +365,31 @@ class HeroicSource(URLExecutableSource):
url_format = "heroic://launch/{runner}/{app_name}"
available_on = {"linux", "win32"}
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": LocationSubPath("config.json"),
"store_config.json": LocationSubPath("store/config.json"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
locations: HeroicLocations
@property
def game_id_format(self) -> str:
"""The string format used to construct game IDs"""
return self.source_id + "_{service}_{game_id}"
def __init__(self) -> None:
super().__init__()
self.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": LocationSubPath("config.json"),
"store_config.json": LocationSubPath("store/config.json"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)

View File

@@ -86,18 +86,22 @@ class ItchSource(URLExecutableSource):
url_format = "itch://caves/{cave_id}/launch"
available_on = {"linux", "win32"}
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": LocationSubPath("db/butler.db"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
locations: ItchLocations
def __init__(self) -> None:
super().__init__()
self.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": LocationSubPath("db/butler.db"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
)

View File

@@ -26,7 +26,7 @@ from typing import NamedTuple
from src import shared
from src.game import Game
from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import Source, SourceIterationResult, SourceIterable
from src.importer.sources.source import Source, SourceIterable, SourceIterationResult
class LegendarySourceIterable(SourceIterable):
@@ -100,17 +100,21 @@ class LegendarySource(Source):
available_on = {"linux"}
iterable_class = LegendarySourceIterable
locations = LegendaryLocations(
Location(
schema_key="legendary-location",
candidates=(
shared.config_dir / "legendary",
shared.home / ".config" / "legendary",
),
paths={
"installed.json": LocationSubPath("installed.json"),
"metadata": LocationSubPath("metadata", True),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
locations: LegendaryLocations
def __init__(self) -> None:
super().__init__()
self.locations = LegendaryLocations(
Location(
schema_key="legendary-location",
candidates=(
shared.config_dir / "legendary",
shared.home / ".config" / "legendary",
),
paths={
"installed.json": LocationSubPath("installed.json"),
"metadata": LocationSubPath("metadata", True),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
)

View File

@@ -1,7 +1,7 @@
import logging
from pathlib import Path
from typing import Mapping, Iterable, NamedTuple
from os import PathLike
from pathlib import Path
from typing import Iterable, Mapping, NamedTuple
from src import shared
@@ -70,7 +70,7 @@ class Location:
def resolve(self) -> None:
"""Choose a root path from the candidates for the location.
If none fits, raise a UnresolvableLocationError"""
If none fits, raise an UnresolvableLocationError"""
if self.root is not None:
return

View File

@@ -100,33 +100,37 @@ class LutrisSource(URLExecutableSource):
# FIXME possible bug: config picks ~/.var... and cache picks ~/.local...
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": LocationSubPath("pga.db"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
),
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": LocationSubPath("coverart", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
),
)
locations: LutrisLocations
@property
def game_id_format(self):
return self.source_id + "_{runner}_{game_id}"
def __init__(self) -> None:
super().__init__()
self.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": LocationSubPath("pga.db"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
),
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": LocationSubPath("coverart", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
),
)

View File

@@ -145,28 +145,7 @@ class RetroarchSource(Source):
available_on = {"linux"}
iterable_class = RetroarchSourceIterable
locations = RetroarchLocations(
Location(
schema_key="retroarch-location",
candidates=[
shared.flatpak_dir / "org.libretro.RetroArch" / "config" / "retroarch",
shared.config_dir / "retroarch",
shared.home / ".config" / "retroarch",
# TODO: Windows support, waiting for executable path setting improvement
# Path("C:\\RetroArch-Win64"),
# Path("C:\\RetroArch-Win32"),
# TODO: UWP support (URL handler - https://github.com/libretro/RetroArch/pull/13563)
# shared.local_appdata_dir
# / "Packages"
# / "1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_8ngdn9a6dx1ma"
# / "LocalState",
],
paths={
"retroarch.cfg": LocationSubPath("retroarch.cfg"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
locations: RetroarchLocations
@property
def executable_format(self):
@@ -176,15 +155,6 @@ class RetroarchSource(Source):
args = '-L "{core_path}" "{rom_path}"'
return f"{base} {args}"
def __init__(self) -> None:
super().__init__()
try:
self.locations.config.candidates.append(self.get_steam_location())
except (OSError, KeyError, UnresolvableLocationError):
logging.debug("Steam isn't installed")
except ValueError as error:
logging.debug("RetroArch Steam location candiate not found", exc_info=error)
def get_steam_location(self) -> str:
"""
Get the RetroArch installed via Steam location
@@ -214,3 +184,37 @@ class RetroarchSource(Source):
return Path(f"{library_path}/steamapps/common/RetroArch")
# Not found
raise ValueError("RetroArch not found in Steam library")
def __init__(self) -> None:
super().__init__()
self.locations = RetroarchLocations(
Location(
schema_key="retroarch-location",
candidates=[
shared.flatpak_dir
/ "org.libretro.RetroArch"
/ "config"
/ "retroarch",
shared.config_dir / "retroarch",
shared.home / ".config" / "retroarch",
# TODO: Windows support, waiting for executable path setting improvement
# Path("C:\\RetroArch-Win64"),
# Path("C:\\RetroArch-Win32"),
# TODO: UWP support (URL handler - https://github.com/libretro/RetroArch/pull/13563)
# shared.local_appdata_dir
# / "Packages"
# / "1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_8ngdn9a6dx1ma"
# / "LocalState",
],
paths={
"retroarch.cfg": LocationSubPath("retroarch.cfg"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
try:
self.locations.config.candidates.append(self.get_steam_location())
except (OSError, KeyError, UnresolvableLocationError):
logging.debug("Steam isn't installed")
except ValueError as error:
logging.debug("RetroArch Steam location candiate not found", exc_info=error)

View File

@@ -56,6 +56,9 @@ class Source(Iterable):
variant: str = None
available_on: set[str] = set()
iterable_class: type[SourceIterable]
# NOTE: Locations must be set at __init__ time, not in the class definition.
# They must not be shared between source instances.
locations: Collection[Location]
@property
@@ -85,10 +88,7 @@ class Source(Iterable):
Get an iterator for the source
:raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable
"""
for location_name in ("data", "cache", "config"):
location = getattr(self, f"{location_name}_location", None)
if location is None:
continue
for location in self.locations:
location.resolve()
return iter(self.iterable_class(self))

View File

@@ -120,19 +120,25 @@ class SteamSource(URLExecutableSource):
iterable_class = SteamSourceIterable
url_format = "steam://rungameid/{game_id}"
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": LocationSubPath("steamapps/libraryfolders.vdf"),
"librarycache": LocationSubPath("appcache/librarycache", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
locations: SteamLocations
def __init__(self) -> None:
super().__init__()
self.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": LocationSubPath(
"steamapps/libraryfolders.vdf"
),
"librarycache": LocationSubPath("appcache/librarycache", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
)
)