diff --git a/sgbackup/epic.py b/sgbackup/epic.py index 49d419e..0d24ea3 100644 --- a/sgbackup/epic.py +++ b/sgbackup/epic.py @@ -24,24 +24,24 @@ from .settings import settings import json import logging -from i18n import gettext as _ +from .i18n import gettext as _ +from .game import GameManager logger = logging.getLogger(__name__) -PLATFORM_WINDOWS = sys.platform.lower().startswith('win') class EpicGameInfo(GObject): def __init__(self, name:str, installdir:str, - appname:str, - main_appname:str): + catalog_item_id:str, + main_catalog_item_id:str): GObject.__init__(self) self.__name = name self.__installdir = installdir - self.__appname = appname - self.__main_appname = main_appname + self.__catalog_item_id = catalog_item_id + self.__main_catalog_item_id = main_catalog_item_id @Property(type=str) @@ -53,27 +53,27 @@ class EpicGameInfo(GObject): return self.__installdir @Property(type=str) - def appname(self)->str: - return self.__appname + def catalog_item_id(self)->str: + return self.__catalog_item_id @Property(type=str) - def main_appname(self)->str: - return self.__main_appname + def main_catalog_item_id(self)->str: + return self.__main_catalog_item_id @Property(type=bool,default=False) def is_main(self)->bool: - return (self.appname == self.main_appname) + return (self.catalog_item_id == self.main_catalog_item_id) class EpicIgnoredApp(GObject): - def __init__(self,appname:str,name:str,reason:str): + def __init__(self,catalog_item_id:str,name:str,reason:str): GObject.__init__(self) - self.__appname = appname + self.__catalog_item_id = catalog_item_id self.__name = name self.__reason = reason @Property(type=str) - def appname(self)->str: - return self.__appname + def catalog_item_id(self)->str: + return self.__catalog_item_id @Property(type=str) def name(self)->str: @@ -85,7 +85,7 @@ class EpicIgnoredApp(GObject): def serialize(self): return { - 'appname':self.appname, + 'catalog_item_id':self.catalog_item_id, 'name':self.name, 'reason':self.reason, } @@ -107,11 +107,11 @@ class Epic(GObject): def __parse_ignore_file(self)->dict[str:EpicIgnoredApp]: ret = {} if os.path.isfile(self.ignore_file): - with open(self.ignore_file,'r',encoding="urf-8") as ifile: + with open(self.ignore_file,'r',encoding="utf-8") as ifile: data = json.loads(ifile.read()) for i in data: - ret[i['appname']] = EpicIgnoredApp(i['appname'],i['name'],i['reason']) + ret[i['catalog_item_id']] = EpicIgnoredApp(i['catalog_item_id'],i['name'],i['reason']) return ret @@ -126,24 +126,24 @@ class Epic(GObject): if not isinstance(app,EpicIgnoredApp): raise TypeError('app is not an EpicIgnoredApp instance!') - self.__ignored_apps[app.appname] = app + self.__ignored_apps[app.catalog_item_id] = app self.__write_ignore_file() def remove_ignored_apps(self,app:str|EpicIgnoredApp): if isinstance(app,str): - appname = app + item_id = app elif isinstance(app,EpicIgnoredApp): - appname = app.appname + item_id = app.catalog_item_id else: raise TypeError("app is not a string and not an EpicIgnoredApp instance!") - if appname in self.__ignored_apps: - del self.__ignored_apps[appname] + if item_id in self.__ignored_apps: + del self.__ignored_apps[item_id] self.__write_ignore_file() @Property def ignored_apps(self)->dict[str:EpicIgnoredApp]: - return self.__ignored_apps + return dict(self.__ignored_apps) @Property(type=str) def datadir(self): @@ -169,8 +169,8 @@ class Epic(GObject): return EpicGameInfo( name=data['DisplayName'], installdir=data['InstallLocation'], - appname=data['AppName'], - main_appname=data['MainGameAppName'] + catalog_item_id=data['CatalogItemId'], + main_catalog_item_id=data['MainGameCatalogItemId'] ) return None @@ -181,13 +181,15 @@ class Epic(GObject): manifest_file = os.path.join(manifest_dir,item) info = self.parse_manifest(manifest_file) if info is not None: - ret += info + ret.append(info) return ret - def get_apps(self)->list[EpicGameInfo]: - return [i for i in self.parse_all_manifests() if i.appname == i.main_appname] + def find_apps(self)->list[EpicGameInfo]: + return [i for i in self.parse_all_manifests() if i.is_main] - def get_new_apps(self)->list[EpicGameInfo]: - return [] + def find_new_apps(self)->list[EpicGameInfo]: + gm = GameManager.get_global() + return [i for i in self.find_apps() + if not gm.has_epic_game(i.catalog_item_id) and not i.catalog_item_id in self.__ignored_apps] \ No newline at end of file diff --git a/sgbackup/game.py b/sgbackup/game.py index 87018d4..1e4bfb1 100644 --- a/sgbackup/game.py +++ b/sgbackup/game.py @@ -1207,19 +1207,21 @@ class EpicWindowsData(EpicPlatformData): class EpicGameData(GObject): - def __init__(self,appname:str,windows:EpicWindowsData|None): + def __init__(self, + catalog_item_id:str|None=None, + windows:EpicWindowsData|None=None): GObject.__init__(self) - self.__appname = appname + self.__catalog_item_id = catalog_item_id self.windows = windows @Property(type=str) - def appname(self)->str: - return self.__appname + def catalog_item_id(self)->str: + return self.__catalog_item_id if self.__catalog_item_id else "" - @appname.setter - def appname(self,appname:str): - self.__appname = appname + @catalog_item_id.setter + def catalog_item_id(self,catalog_item_id:str): + self.__catalog_item_id = catalog_item_id @Property def windows(self)->EpicWindowsData|None: @@ -1236,7 +1238,7 @@ class EpicGameData(GObject): def serialize(self): ret = { - "appname": self.appname + "catalog_item_id": self.catalog_item_id } if self.windows and self.windows.is_valid: ret["windows"] = self.windows.serialize() @@ -1299,7 +1301,7 @@ class Game(GObject): librarydir=data['librarydir'] if ('librarydir' in data and data['librarydir']) else None ) - if ('steam' not in conf or not 'appid' in conf['steam']): + if ('steam' not in conf): return None steam=conf['steam'] @@ -1322,7 +1324,7 @@ class Game(GObject): if windows is None and linux is None and macos is None: return None - return SteamGameData(steam['appid'], + return SteamGameData(steam['appid'] if 'appid' in steam else -1, windows=windows, linux=linux, macos=macos) @@ -1344,7 +1346,7 @@ class Game(GObject): librarydir=data['librarydir'] if ('librarydir' in data and data['librarydir']) else None ) - if not "epic" in conf or not "appname" in conf["epic"]: + if not "epic" in conf: return None if ("windows" in conf['epic']): @@ -1352,7 +1354,7 @@ class Game(GObject): else: windows = None - return EpicGameData(conf['epic']['appname'], + return EpicGameData(conf['epic']['catalog_item_id'] if 'catalog_item_id' in conf['epic'] else "", windows=windows) # new_epic_data() @@ -1811,8 +1813,17 @@ class GameManager(GObject): @Property def steam_games(self)->dict[int:Game]: - return self.__steam_games + return dict(self.__steam_games) + def has_steam_game(self,appid:int)->bool: + return (appid in self.__steam_games) + @Property + def epic_games(self)->dict[str:Game]: + return dict(self.__epic_games) + + def has_epic_game(self,catalog_item_id:str)->bool: + return (catalog_item_id in self.__epic_games) + def load(self): if self.__games: self.__games = {} @@ -1844,7 +1855,7 @@ class GameManager(GObject): if game.steam: self.__steam_games[game.steam.appid] = game if game.epic: - self.__epic_games[game.epic.appname] = game + self.__epic_games[game.epic.catalog_item_id] = game def remove_game(self,game:Game|str): if isinstance(game,str): diff --git a/sgbackup/gui/_app.py b/sgbackup/gui/_app.py index 72850ed..2636cac 100644 --- a/sgbackup/gui/_app.py +++ b/sgbackup/gui/_app.py @@ -46,8 +46,9 @@ from ._steam import ( SteamNoIgnoredAppsDialog, SteamIgnoreAppsDialog, ) - from ..steam import Steam +from ..epic import Epic +from ._epic import EpicNewAppsDialog from ._backupdialog import BackupSingleDialog,BackupManyDialog from ..archiver import ArchiverManager from ._dialogs import ( @@ -392,7 +393,7 @@ class GameView(Gtk.Box): def do_game_live_changed(self,game:Game): pass - def _on_new_steamapps_dialog_response(self,dialog,response): + def _on_new_apps_dialog_response(self,dialog,response): self.refresh() def _on_add_game_button_clicked(self,button): @@ -404,15 +405,28 @@ class GameView(Gtk.Box): steam = Steam() if steam.find_new_steamapps(): dialog = NewSteamAppsDialog(parent=self.get_root()) - dialog.connect_after('response',self._on_new_steamapps_dialog_response) + dialog.connect_after('response',self._on_new_apps_dialog_response) dialog.present() else: dialog = SteamNoNewAppsDialog(parent=self.get_root()) dialog.present() def _on_new_epic_games_button_clicked(self,button): - # TODO - pass + epic = Epic() + if not epic.find_new_apps(): + dialog = Gtk.MessageDialog( + transient_for=self.get_root(), + message="No new Epic-Games applications found!", + buttons=Gtk.ButtonsType.OK, + modal=False + ) + dialog.connect('response',lambda d,r: d.destroy()) + dialog.present() + return + + dialog = EpicNewAppsDialog(self.get_root()) + dialog.connect_after('response',self._on_new_apps_dialog_response) + dialog.present() def _on_backup_active_live_button_clicked(self,button): backup_games = [] diff --git a/sgbackup/gui/_epic.py b/sgbackup/gui/_epic.py new file mode 100644 index 0000000..f40fb89 --- /dev/null +++ b/sgbackup/gui/_epic.py @@ -0,0 +1,229 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024,2025 Christian Moser # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +############################################################################### + +from gi.repository import Gtk,GLib,Gio +from gi.repository.GObject import GObject,Property,Signal,SignalFlags + +from ..i18n import gettext as _,gettext_noop as N_ +from ..game import GameManager,Game,EpicGameData,EpicWindowsData +from ..epic import Epic,EpicGameInfo,EpicIgnoredApp +from ..utility import PLATFORM_WINDOWS + +from ._gamedialog import GameDialog + +class EpicNewAppsDialogSorter(Gtk.Sorter): + def do_compare(self,item1:EpicGameInfo,item2:EpicGameInfo): + name1 = item1.name.lower() + name2 = item2.name.lower() + + if name1 > name2: + return Gtk.Ordering.LARGER + elif name1 < name2: + return Gtk.Ordering.SMALLER + return Gtk.Ordering.EQUAL + +class EpicNewAppsDialog(Gtk.Dialog): + def __init__(self,parent:Gtk.Window|None=None): + Gtk.Dialog.__init__(self, + transient_for=parent, + title=_("SGBackup: Manage new Epic-Games apps"), + modal=False) + + self.set_default_size(800,600) + scrolled = Gtk.ScrolledWindow() + + epic = Epic() + self.__liststore = Gio.ListStore.new(EpicGameInfo) + for info in epic.find_new_apps(): + self.__liststore.append(info) + + sort_model = Gtk.SortListModel(model=self.__liststore, + sorter=EpicNewAppsDialogSorter()) + selection = Gtk.SingleSelection(model=sort_model, + autoselect=False, + can_unselect=True) + + factory = Gtk.SignalListItemFactory() + factory.connect('setup',self._on_listview_setup) + factory.connect('bind',self._on_listview_bind) + + self.__listview = Gtk.ListView(model=selection,factory=factory,hexpand=True,vexpand=True) + scrolled.set_child(self.__listview) + self.get_content_area().append(scrolled) + self.__close_button = self.add_button(_("Close"),Gtk.ResponseType.OK) + + + def _on_listview_setup(self,factory,item): + child = Gtk.Grid(column_spacing=4,row_spacing=2) + + child.name_label = Gtk.Label(xalign=0.0,hexpand=True) + child.attach(child.name_label,1,0,1,1) + + label = Gtk.Label(label=_("CatalogItemId:"), + use_markup=False, + xalign=0.0) + child.catalogitemid_label = Gtk.Label(xalign=0.0, + use_markup=False, + hexpand=True) + child.attach(label,0,1,1,1) + child.attach(child.catalogitemid_label,1,1,1,1) + + label = Gtk.Label(label=_("Installation Directory:"), + use_markup=False, + xalign=0.0) + child.installdir_label = Gtk.Label(xalign=0.0, + use_markup=False, + hexpand=True) + child.attach(label,0,2,1,1) + child.attach(child.installdir_label,1,2,1,1) + + actiongrid = Gtk.Grid(row_spacing=2,column_spacing=2) + + icon = Gtk.Image.new_from_icon_name('list-add-symbolic') + icon.set_pixel_size(16) + child.new_button = Gtk.Button() + child.new_button.set_child(icon) + child.new_button.set_tooltip_text(_("Add Epic-Games-app as a new game.")) + actiongrid.attach(child.new_button,0,0,1,1) + + icon = Gtk.Image.new_from_icon_name('edit-delete-symbolic') + icon.set_pixel_size(16) + child.ignore_button = Gtk.Button() + child.ignore_button.set_child(icon) + child.ignore_button.set_tooltip_text(_("Add Epic-Games-app to the ignore list.")) + actiongrid.attach(child.ignore_button,1,0,1,1) + + icon = Gtk.Image.new_from_icon_name('edit-find-symbolic') + icon.set_pixel_size(16) + child.lookup_button = Gtk.Button() + child.lookup_button.set_child(icon) + child.lookup_button.set_tooltip_text(_("Lookup Epic-Games-app for already registered game.")) + actiongrid.attach(child.lookup_button,0,1,1,1) + + icon = Gtk.Image.new_from_icon_name('folder-download-symbolic') + icon.set_pixel_size(16) + child.online_button = Gtk.Button() + child.online_button.set_child(icon) + child.online_button.set_tooltip_text(_("Lookup Epic-Games-app online.")) + actiongrid.attach(child.online_button,1,1,1,1) + + child.attach(actiongrid,2,0,1,3) + item.set_child(child) + + + def _on_listview_bind(self,factory,item): + child = item.get_child() + data = item.get_item() + + child.name_label.set_markup("{}".format( + GLib.markup_escape_text(data.name))) + child.catalogitemid_label.set_text(data.catalog_item_id) + child.installdir_label.set_text(data.installdir) + + if hasattr(child.new_button,'_signal_clicked_connector'): + child.new_button.disconnect(child.new_button._signal_clicked_connector) + child.new_button._signal_clicked_connector = child.new_button.connect('clicked', + self._on_listview_new_button_clicked, + data) + + if hasattr(child.ignore_button,'_signal_clicked_connector'): + child.ignore_button.disconnect(child.ignore_button._signal_clicked_connector) + child.ignore_button._signal_clicked_connector = child.ignore_button.connect('clicked', + self._on_listview_ignore_button_clicked, + data) + + if hasattr(child.lookup_button,'_signal_clicked_connector'): + child.lookup_button.disconnect(child.lookup_button._signal_clicked_connector) + child.lookup_button._signal_clicked_connector = child.lookup_button.connect('clicked', + self._on_listview_lookup_button_clicked, + data) + child.lookup_button.set_sensitive(False) + + if hasattr(child.online_button,'_signal_clicked_connector'): + child.online_button.disconnect(child.online_button._signal_clicked_connector) + child.online_button._signal_clicked_connector = child.online_button.connect('clicked', + self._on_listview_online_button_clicked, + data) + child.online_button.set_sensitive(False) + + def _on_game_dialog_response(self,dialog,response,info:EpicGameInfo): + if response == Gtk.ResponseType.APPLY: + for i in range(self.__liststore.get_n_items()): + item = self.__liststore.get_item(i) + if item.catalog_item_id == info.catalog_item_id: + self.__liststore.remove(i) + break + + + def _on_listview_new_button_clicked(self,button:Gtk.Button,info:EpicGameInfo): + game = Game("",info.name,"") + if PLATFORM_WINDOWS: + windows = EpicWindowsData("","",installdir=info.installdir) + else: + windows = None + + game.epic = EpicGameData(catalog_item_id=info.catalog_item_id, + windows=windows) + + dialog = GameDialog(parent=self,game=game) + dialog.connect_after('response',self._on_dialog_reponse,info) + dialog.present() + + + def _on_ignore_dialog_response(self,dialog,response,info:EpicGameInfo): + if response == Gtk.ResponseType.YES: + epic = Epic() + epic.add_ignored_app(EpicIgnoredApp(info.catalog_item_id, + info.name, + dialog.reason_entry.get_text())) + for i in range(self.__liststore.get_n_items()): + item = self.__liststore.get_item() + if item.catalog_item_id == info.catalog_item_id: + self.__liststore.remove(i) + break + + dialog.hide() + dialog.destroy() + + def _on_listview_ignore_button_clicked(self,button:Gtk.Button,info:EpicGameInfo): + dialog = Gtk.MessageDialog(transient_for=self, + text=_("Do you really won to add the game {game} to the ignore list?").format( + game=GLib.markup_escape_text(info.name)), + use_markup=True, + secondary_text=_("Please enter a reason below."), + secondary_use_markup=True, + buttons=Gtk.ButtonsType.YES_NO, + modal=False) + dialog.reason_entry = Gtk.Entry() + dialog.reason_entry.set_hexpand(True) + dialog.get_content_area().append(dialog.reason_entry) + dialog.connect('response',self._on_ignore_dialog_response,info) + dialog.present() + + + + def _on_listview_lookup_button_clicked(self,button:Gtk.Button,info:EpicGameInfo): + pass + + def _on_listview_online_button_clicked(self,button:Gtk.Button,info:EpicGameInfo): + pass + + def do_response(self,response): + self.hide() + self.destroy() + \ No newline at end of file diff --git a/sgbackup/gui/_gamedialog.py b/sgbackup/gui/_gamedialog.py index 96e3d91..c145bdf 100644 --- a/sgbackup/gui/_gamedialog.py +++ b/sgbackup/gui/_gamedialog.py @@ -755,9 +755,9 @@ class GameDialog(Gtk.Dialog): page = Gtk.Box.new(Gtk.Orientation.VERTICAL,2) grid = Gtk.Grid() - label = self.__create_label(_("AppName:")) - page.appname_entry = Gtk.Entry() - page.appname_entry.set_hexpand(True) + label = self.__create_label(_("CatalogIemID:")) + page.catalogitemid_entry = Gtk.Entry() + page.catalogitemid_entry.set_hexpand(True) grid.attach(label,0,0,1,1) grid.attach(page.appname_entry,1,0,1,1) page.append(grid) @@ -1015,9 +1015,11 @@ class GameDialog(Gtk.Dialog): else "") # Epic Games - set_game_widget_data(self.__epic.windows,self.__game.epic.windows if self.has_game and self.__game.epic else None) + set_game_widget_data(self.__epic.windows,self.__game.epic.windows + if self.has_game and self.__game.epic else None) - self.__epic.appname_entry.set_text(self.__game.epic.appname if self.has_game and self.__game.epic else "") + self.__epic.catalogitemid_entry.set_text(self.__game.epic.catalog_item_id + if self.has_game and self.__game.epic else "") self.__epic.windows.installdir_entry.set_text(self.__game.epic.windows.installdir if self.has_game and self.__game.epic @@ -1254,9 +1256,9 @@ class GameDialog(Gtk.Dialog): data = get_epic_data(self.__epic.windows) if self.__game.epic: - self.__game.epic.appname = self.__epic.appname_entry.get_text() + self.__game.epic.catalog_item_id = self.__epic.catalogitemid_entry.get_text() else: - self.__game.epic = EpicGameData(appname=self.__epic.appname_entry.get_text()) + self.__game.epic = EpicGameData(appname=self.__epic.catalogitemid_entry.get_text()) if self.__game.epic.windows: self.__game.epic.windows.savegame_root = data['sgroot'] diff --git a/sgbackup/gui/_steam.py b/sgbackup/gui/_steam.py index 03ec92c..e9c7d5f 100644 --- a/sgbackup/gui/_steam.py +++ b/sgbackup/gui/_steam.py @@ -238,7 +238,7 @@ class SteamGameLookupDialog(GameSearchDialog): def do_prepare_game(self, game): game = super().do_prepare_game(game) if game.steam: - if not game.steam.appid != self.steam_app.appid: + if not game.steam.appid != self.steam_app.appid and game.steam_appid >= 0: raise ValueError("Steam appid error") if PLATFORM_WINDOWS: @@ -494,6 +494,9 @@ class SteamNoNewAppsDialog(Gtk.MessageDialog): self.hide() self.destroy() + +### SteamIgnoreApps ########################################################### + class SteamNoIgnoredAppsDialog(Gtk.MessageDialog): def __init__(self,parent:Gtk.Window|None=None): Gtk.MessageDialog.__init__(self,buttons=Gtk.ButtonsType.OK) @@ -506,7 +509,8 @@ class SteamNoIgnoredAppsDialog(Gtk.MessageDialog): def do_response(self,response): self.hide() self.destroy() - + + class SteamIgnoreAppsSorter(Gtk.Sorter): def do_compare(self,item1:IgnoreSteamApp,item2:IgnoreSteamApp): s1 = item1.name.lower() diff --git a/sgbackup/steam.py b/sgbackup/steam.py index eb01279..9ed32e1 100644 --- a/sgbackup/steam.py +++ b/sgbackup/steam.py @@ -350,7 +350,7 @@ class Steam(GObject): new_apps = [] for lib in self.libraries: for app in lib.steam_apps: - if not app.appid in GameManager.get_global().steam_games and not app.appid in self.ignore_apps: + if not GameManager.get_global().has_steam_game(app.appid) and not app.appid in self.ignore_apps: new_apps.append(app) return sorted(new_apps) diff --git a/sgbackup/utility.py b/sgbackup/utility.py index d044ae1..3ba735d 100644 --- a/sgbackup/utility.py +++ b/sgbackup/utility.py @@ -17,15 +17,29 @@ ############################################################################### import sys -if sys.platform.lower() == 'win32': - PLATFORM_WINDOWS=True -else: - PLATFORM_WINDOWS=False - -if sys.platform.lower() in ['linux','freebsd','netbsd','openbsd','dragonfly','macos','cygwin']: - PLATFORM_UNIX = True -else: - PLATFORM_UNIX = False +def _platform_is_linux(): + if sys.platform == 'linux': + return True + for i in ('freebsd','netbsd','openbsd','dragonfly'): + if sys.platform.startswith(i): + return True + return False + +def _platform_is_unix(): + if sys.platform in ('linux','darwin','aix'): + return True + for i in ('freebsd','netbsd','openbsd','dragonfly'): + if sys.platform.startswith(i): + return True + return False + +PLATFORM_WINDOWS = (sys.platform == 'win32') +PLATFORM_LINUX = _platform_is_linux() +PLATFORM_MACOS = (sys.platform == 'darwin') +PLATFORM_UNIX = _platform_is_unix() + +del _platform_is_unix +del _platform_is_linux def sanitize_windows_path(path:str)->str: return path.replace('/','\\')