From ce24341d1d0ebee030c8ee579793b40c6bb193fb Mon Sep 17 00:00:00 2001
From: Aaron Murgatroyd <amurgshere@gmail.com>
Date: Thu, 24 Oct 2024 00:35:51 +1000
Subject: [PATCH] Automatically remove invalid dlc and updates as part of auto
 load Fixed some minor label spacing issues in options dialog Removal of
 unused variable in input view model

---
 .../App/ApplicationLibrary.cs                 | 87 +++++++++++++++----
 src/Ryujinx/Assets/Locales/en_US.json         |  4 +-
 src/Ryujinx/Assets/Locales/fr_FR.json         |  7 ++
 .../UI/ViewModels/MainWindowViewModel.cs      | 24 +++--
 .../Views/Settings/SettingsLoggingView.axaml  |  2 +-
 .../Views/Settings/SettingsSystemView.axaml   |  2 +-
 .../UI/Views/Settings/SettingsUIView.axaml    |  7 +-
 src/Ryujinx/UI/Windows/MainWindow.axaml.cs    | 37 ++++----
 8 files changed, 117 insertions(+), 53 deletions(-)

diff --git a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs
index fbe05ed1..8a0bad18 100644
--- a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs
+++ b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs
@@ -802,17 +802,31 @@ namespace Ryujinx.UI.App.Common
 
         // Searches the provided directories for DLC NSP files that are _valid for the currently detected games in the
         // library_, and then enables those DLC.
-        public int AutoLoadDownloadableContents(List<string> appDirs)
+        public int AutoLoadDownloadableContents(List<string> appDirs, out int numDlcRemoved)
         {
             _cancellationToken = new CancellationTokenSource();
 
             List<string> dlcPaths = new();
             int newDlcLoaded = 0;
+            numDlcRemoved = 0;
 
             try
             {
+                // Remove any downloadable content which can no longer be located on disk
+                Logger.Notice.Print(LogClass.Application, $"Removing non-existing Title DLCs");
+                var dlcToRemove = _downloadableContents.Items
+                    .Where(dlc => !File.Exists(dlc.Dlc.ContainerPath))
+                    .ToList();
+                dlcToRemove.ForEach(dlc =>
+                    Logger.Warning?.Print(LogClass.Application, $"Title DLC removed: {dlc.Dlc.ContainerPath}")
+                );
+                numDlcRemoved += dlcToRemove.Distinct().Count();
+                _downloadableContents.RemoveKeys(dlcToRemove.Select(dlc => dlc.Dlc));
+
                 foreach (string appDir in appDirs)
                 {
+                    Logger.Notice.Print(LogClass.Application, $"Auto loading DLC from: {appDir}");
+
                     if (_cancellationToken.Token.IsCancellationRequested)
                     {
                         return newDlcLoaded;
@@ -901,17 +915,37 @@ namespace Ryujinx.UI.App.Common
         // Searches the provided directories for update NSP files that are _valid for the currently detected games in the
         // library_, and then applies those updates. If a newly-detected update is a newer version than the currently
         // selected update (or if no update is currently selected), then that update will be selected.
-        public int AutoLoadTitleUpdates(List<string> appDirs)
+        public int AutoLoadTitleUpdates(List<string> appDirs, out int numUpdatesRemoved)
         {
             _cancellationToken = new CancellationTokenSource();
 
             List<string> updatePaths = new();
             int numUpdatesLoaded = 0;
+            numUpdatesRemoved = 0;
 
             try
             {
+                var titleIdsToSave = new HashSet<ulong>();
+                var titleIdsToRefresh = new HashSet<ulong>();
+
+                // Remove any updates which can no longer be located on disk
+                Logger.Notice.Print(LogClass.Application, $"Removing non-existing Title Updates");
+                var updatesToRemove = _titleUpdates.Items
+                    .Where(it => !File.Exists(it.TitleUpdate.Path))
+                    .ToList();
+
+                numUpdatesRemoved += updatesToRemove.Select(it => it.TitleUpdate).Distinct().Count();
+                updatesToRemove.ForEach(ti =>
+                    Logger.Warning?.Print(LogClass.Application, $"Title update removed: {ti.TitleUpdate.Path}")
+                );
+                _titleUpdates.RemoveKeys(updatesToRemove.Select(it => it.TitleUpdate));
+                titleIdsToSave.UnionWith(updatesToRemove.Select(it => it.TitleUpdate.TitleIdBase));
+                titleIdsToRefresh.UnionWith(updatesToRemove.Where(it => it.IsSelected).Select(update => update.TitleUpdate.TitleIdBase));
+
                 foreach (string appDir in appDirs)
                 {
+                    Logger.Notice.Print(LogClass.Application, $"Auto loading updates from: {appDir}");
+
                     if (_cancellationToken.Token.IsCancellationRequested)
                     {
                         return numUpdatesLoaded;
@@ -980,27 +1014,24 @@ namespace Ryujinx.UI.App.Common
                         {
                             if (!_titleUpdates.Lookup(update).HasValue)
                             {
-                                var currentlySelected = TitleUpdates.Items.FirstOrOptional(it =>
-                                    it.TitleUpdate.TitleIdBase == update.TitleIdBase && it.IsSelected);
-
-                                var shouldSelect = !currentlySelected.HasValue ||
-                                                   currentlySelected.Value.TitleUpdate.Version < update.Version;
-                                _titleUpdates.AddOrUpdate((update, shouldSelect));
-
-                                if (currentlySelected.HasValue && shouldSelect)
-                                    _titleUpdates.AddOrUpdate((currentlySelected.Value.TitleUpdate, false));
-
-                                SaveTitleUpdatesForGame(update.TitleIdBase);
+                                bool shouldSelect = AddAndAutoSelectUpdate(update);
+                                titleIdsToSave.Add(update.TitleIdBase);
                                 numUpdatesLoaded++;
 
                                 if (shouldSelect)
                                 {
-                                    RefreshApplicationInfo(update.TitleIdBase);
+                                    titleIdsToRefresh.Add(update.TitleIdBase);
                                 }
                             }
                         }
                     }
                 }
+
+                foreach (var titleId in titleIdsToSave)
+                    SaveTitleUpdatesForGame(titleId);
+
+                foreach (var titleId in titleIdsToRefresh)
+                    RefreshApplicationInfo(titleId);
             }
             finally
             {
@@ -1011,6 +1042,24 @@ namespace Ryujinx.UI.App.Common
             return numUpdatesLoaded;
         }
 
+        private bool AddAndAutoSelectUpdate(TitleUpdateModel update)
+        {
+            var currentlySelected = TitleUpdates.Items.FirstOrOptional(it =>
+                it.TitleUpdate.TitleIdBase == update.TitleIdBase && it.IsSelected);
+
+            var shouldSelect = !currentlySelected.HasValue ||
+                               currentlySelected.Value.TitleUpdate.Version < update.Version;
+
+            _titleUpdates.AddOrUpdate((update, shouldSelect));
+
+            if (currentlySelected.HasValue && shouldSelect)
+            {
+                _titleUpdates.AddOrUpdate((currentlySelected.Value.TitleUpdate, false));
+            }
+
+            return shouldSelect;
+        }
+
         protected void OnApplicationCountUpdated(ApplicationCountUpdatedEventArgs e)
         {
             ApplicationCountUpdated?.Invoke(null, e);
@@ -1395,8 +1444,8 @@ namespace Ryujinx.UI.App.Common
                 if (TryGetTitleUpdatesFromFile(application.Path, out var bundledUpdates))
                 {
                     var savedUpdateLookup = savedUpdates.Select(update => update.Item1).ToHashSet();
+                    bool updatesChanged = false;
 
-                    bool addedNewUpdate = false;
                     foreach (var update in bundledUpdates.OrderByDescending(bundled => bundled.Version))
                     {
                         if (!savedUpdateLookup.Contains(update))
@@ -1405,17 +1454,19 @@ namespace Ryujinx.UI.App.Common
                             if (!selectedUpdate.HasValue || selectedUpdate.Value.Item1.Version < update.Version)
                             {
                                 shouldSelect = true;
-                                selectedUpdate = Optional<(TitleUpdateModel, bool IsSelected)>.Create((update, true));
+                                if (selectedUpdate.HasValue)
+                                    _titleUpdates.AddOrUpdate((selectedUpdate.Value.Item1, false));
+                                selectedUpdate = DynamicData.Kernel.Optional<(TitleUpdateModel, bool IsSelected)>.Create((update, true));
                             }
 
                             modifiedVersion = modifiedVersion || shouldSelect;
                             it.AddOrUpdate((update, shouldSelect));
 
-                            addedNewUpdate = true;
+                            updatesChanged = true;
                         }
                     }
 
-                    if (addedNewUpdate)
+                    if (updatesChanged)
                     {
                         var gameUpdates = it.Items.Where(update => update.TitleUpdate.TitleIdBase == application.IdBase).ToList();
                         TitleUpdatesHelper.SaveTitleUpdatesJson(_virtualFileSystem, application.IdBase, gameUpdates);
diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json
index 45befacb..e275d08e 100644
--- a/src/Ryujinx/Assets/Locales/en_US.json
+++ b/src/Ryujinx/Assets/Locales/en_US.json
@@ -106,6 +106,7 @@
   "SettingsTabGeneralHideCursorAlways": "Always",
   "SettingsTabGeneralGameDirectories": "Game Directories",
   "SettingsTabGeneralAutoloadDirectories": "Autoload DLC/Updates Directories",
+  "SettingsTabGeneralAutoloadNote": "DLC and Updates which refer to missing files will be unloaded automatically",
   "SettingsTabGeneralAdd": "Add",
   "SettingsTabGeneralRemove": "Remove",
   "SettingsTabSystem": "System",
@@ -725,8 +726,9 @@
   "DlcWindowHeading": "{0} Downloadable Content(s)",
   "DlcWindowDlcAddedMessage": "{0} new downloadable content(s) added",
   "AutoloadDlcAddedMessage": "{0} new downloadable content(s) added",
+  "AutoloadDlcRemovedMessage": "{0} missing downloadable content(s) removed",
   "AutoloadUpdateAddedMessage": "{0} new update(s) added",
-  "AutoloadDlcAndUpdateAddedMessage": "{0} new downloadable content(s) and {1} new update(s) added",
+  "AutoloadUpdateRemovedMessage": "{0} missing update(s) removed",
   "ModWindowHeading": "{0} Mod(s)",
   "UserProfilesEditProfile": "Edit Selected",
   "Cancel": "Cancel",
diff --git a/src/Ryujinx/Assets/Locales/fr_FR.json b/src/Ryujinx/Assets/Locales/fr_FR.json
index 99a06065..101c5b58 100644
--- a/src/Ryujinx/Assets/Locales/fr_FR.json
+++ b/src/Ryujinx/Assets/Locales/fr_FR.json
@@ -102,6 +102,8 @@
   "SettingsTabGeneralHideCursorOnIdle": "Masquer le curseur si inactif",
   "SettingsTabGeneralHideCursorAlways": "Toujours",
   "SettingsTabGeneralGameDirectories": "Dossiers des jeux",
+  "SettingsTabGeneralAutoloadDirectories": "Dossiers des mises à jour/DLC",
+  "SettingsTabGeneralAutoloadNote": "Les DLC et les mises à jour faisant référence aux fichiers manquants seront automatiquement déchargés.",
   "SettingsTabGeneralAdd": "Ajouter",
   "SettingsTabGeneralRemove": "Retirer",
   "SettingsTabSystem": "Système",
@@ -708,6 +710,11 @@
   "CheatWindowHeading": "Cheats disponibles pour {0} [{1}]",
   "BuildId": "BuildId:",
   "DlcWindowHeading": "{0} Contenu(s) téléchargeable(s)",
+  "DlcWindowDlcAddedMessage": "{0} nouveau(x) contenu(s) téléchargeable(s) ajouté(s)",
+  "AutoloadDlcAddedMessage": "{0} nouveau(x) contenu(s) téléchargeable(s) ajouté(s)",
+  "AutoloadDlcRemovedMessage": "{0} contenu(s) téléchargeable(s) manquant(s) supprimé(s)",
+  "AutoloadUpdateAddedMessage": "{0} nouvelle(s) mise(s) à jour ajoutée(s)",
+  "AutoloadUpdateRemovedMessage": "{0} mises à jour manquantes supprimées",
   "ModWindowHeading": "{0} Mod(s)",
   "UserProfilesEditProfile": "Éditer la sélection",
   "Cancel": "Annuler",
diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs
index c9b645a5..321f306c 100644
--- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs
+++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs
@@ -52,6 +52,7 @@ namespace Ryujinx.Ava.UI.ViewModels
     public class MainWindowViewModel : BaseModel
     {
         private const int HotKeyPressDelayMs = 500;
+        private delegate int LoadContentFromFolderDelegate(List<string> dirs, out int numRemoved);
 
         private ObservableCollectionExtended<ApplicationData> _applications;
         private string _aspectStatusText;
@@ -1259,7 +1260,7 @@ namespace Ryujinx.Ava.UI.ViewModels
             _rendererWaitEvent.Set();
         }
 
-        private async Task LoadContentFromFolder(LocaleKeys localeMessageKey, Func<List<string>, int> onDirsSelected)
+        private async Task LoadContentFromFolder(LocaleKeys localeMessageAddedKey, LocaleKeys localeMessageRemovedKey, LoadContentFromFolderDelegate onDirsSelected)
         {
             var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
             {
@@ -1270,14 +1271,17 @@ namespace Ryujinx.Ava.UI.ViewModels
             if (result.Count > 0)
             {
                 var dirs = result.Select(it => it.Path.LocalPath).ToList();
-                var numAdded = onDirsSelected(dirs);
+                var numAdded = onDirsSelected(dirs, out int numRemoved);
 
-                var msg = string.Format(LocaleManager.Instance[localeMessageKey], numAdded);
+                var msg = String.Join("\r\n", new string[] {
+                    string.Format(LocaleManager.Instance[localeMessageRemovedKey], numRemoved),
+                    string.Format(LocaleManager.Instance[localeMessageAddedKey], numAdded)
+                });
 
                 await Dispatcher.UIThread.InvokeAsync(async () =>
                 {
                     await ContentDialogHelper.ShowTextDialog(
-                        LocaleManager.Instance[numAdded > 0 ? LocaleKeys.RyujinxConfirm : LocaleKeys.RyujinxInfo],
+                        LocaleManager.Instance[numAdded > 0 || numRemoved > 0 ? LocaleKeys.RyujinxConfirm : LocaleKeys.RyujinxInfo],
                         msg, "", "", "", LocaleManager.Instance[LocaleKeys.InputDialogOk], (int)Symbol.Checkmark);
                 });
             }
@@ -1533,14 +1537,18 @@ namespace Ryujinx.Ava.UI.ViewModels
 
         public async Task LoadDlcFromFolder()
         {
-            await LoadContentFromFolder(LocaleKeys.AutoloadDlcAddedMessage,
-                dirs => ApplicationLibrary.AutoLoadDownloadableContents(dirs));
+            await LoadContentFromFolder(
+                LocaleKeys.AutoloadDlcAddedMessage,
+                LocaleKeys.AutoloadDlcRemovedMessage,
+                ApplicationLibrary.AutoLoadDownloadableContents);
         }
 
         public async Task LoadTitleUpdatesFromFolder()
         {
-            await LoadContentFromFolder(LocaleKeys.AutoloadUpdateAddedMessage,
-                dirs => ApplicationLibrary.AutoLoadTitleUpdates(dirs));
+            await LoadContentFromFolder(
+                LocaleKeys.AutoloadUpdateAddedMessage,
+                LocaleKeys.AutoloadUpdateRemovedMessage,
+                ApplicationLibrary.AutoLoadTitleUpdates);
         }
 
         public async Task OpenFolder()
diff --git a/src/Ryujinx/UI/Views/Settings/SettingsLoggingView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsLoggingView.axaml
index 0fc9ea1b..5d22b891 100644
--- a/src/Ryujinx/UI/Views/Settings/SettingsLoggingView.axaml
+++ b/src/Ryujinx/UI/Views/Settings/SettingsLoggingView.axaml
@@ -52,7 +52,7 @@
                     </CheckBox>
                 </StackPanel>
                 <Separator Height="1" />
-                <StackPanel Orientation="Vertical" Spacing="2">
+                <StackPanel Orientation="Vertical" Spacing="5">
                     <TextBlock Classes="h1" Text="{locale:Locale SettingsTabLoggingDeveloperOptions}" />
                     <TextBlock Foreground="{DynamicResource SecondaryTextColor}" Text="{locale:Locale SettingsTabLoggingDeveloperOptionsNote}" />
                 </StackPanel>
diff --git a/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml
index e6f7c6e4..0db0eae6 100644
--- a/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml
+++ b/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml
@@ -195,7 +195,7 @@
                 <Separator Height="1" />
                 <StackPanel
                     Orientation="Vertical"
-                    Spacing="2">
+                    Spacing="5">
                     <TextBlock
                         Classes="h1"
                         Text="{locale:Locale SettingsTabSystemHacks}" />
diff --git a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml
index ac5e8371..d91d68a3 100644
--- a/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml
+++ b/src/Ryujinx/UI/Views/Settings/SettingsUIView.axaml
@@ -129,7 +129,10 @@
                     </Grid>
                 </StackPanel>
                 <Separator Height="1" />
-                <TextBlock Classes="h1" Text="{locale:Locale SettingsTabGeneralAutoloadDirectories}" />
+               <StackPanel Orientation="Vertical" Spacing="5">
+                    <TextBlock Classes="h1" Text="{locale:Locale SettingsTabGeneralAutoloadDirectories}" />
+                    <TextBlock Foreground="{DynamicResource SecondaryTextColor}" Text="{locale:Locale SettingsTabGeneralAutoloadNote}" />
+                </StackPanel>
                 <StackPanel
                     Margin="10,0,0,0"
                     HorizontalAlignment="Stretch"
@@ -137,7 +140,7 @@
                     Spacing="10">
                     <ListBox
                         Name="AutoloadDirsList"
-                        MinHeight="120"
+                        MinHeight="100"
                         ItemsSource="{Binding AutoloadDirectories}">
                         <ListBox.Styles>
                             <Style Selector="ListBoxItem">
diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs
index 55edadcb..d0a31895 100644
--- a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs
+++ b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs
@@ -647,10 +647,10 @@ namespace Ryujinx.Ava.UI.Windows
                 var autoloadDirs = ConfigurationState.Instance.UI.AutoloadDirs.Value;
                 if (autoloadDirs.Count > 0)
                 {
-                    var updatesLoaded = ApplicationLibrary.AutoLoadTitleUpdates(autoloadDirs);
-                    var dlcLoaded = ApplicationLibrary.AutoLoadDownloadableContents(autoloadDirs);
+                    var updatesLoaded = ApplicationLibrary.AutoLoadTitleUpdates(autoloadDirs, out int updatesRemoved);
+                    var dlcLoaded = ApplicationLibrary.AutoLoadDownloadableContents(autoloadDirs, out int dlcRemoved);
 
-                    ShowNewContentAddedDialog(dlcLoaded, updatesLoaded);
+                    ShowNewContentAddedDialog(dlcLoaded, dlcRemoved, updatesLoaded, updatesRemoved);
                 }
 
                 _isLoading = false;
@@ -662,28 +662,21 @@ namespace Ryujinx.Ava.UI.Windows
             applicationLibraryThread.Start();
         }
 
-        private Task ShowNewContentAddedDialog(int numDlcAdded, int numUpdatesAdded)
+        private void ShowNewContentAddedDialog(int numDlcAdded, int numDlcRemoved, int numUpdatesAdded, int numUpdatesRemoved)
         {
-            var msg = "";
+            string[] messages = {
+                numDlcRemoved > 0 ? string.Format(LocaleManager.Instance[LocaleKeys.AutoloadDlcRemovedMessage], numDlcRemoved): null,
+                numDlcAdded > 0 ? string.Format(LocaleManager.Instance[LocaleKeys.AutoloadDlcAddedMessage], numDlcAdded): null,
+                numUpdatesRemoved > 0 ? string.Format(LocaleManager.Instance[LocaleKeys.AutoloadUpdateRemovedMessage], numUpdatesRemoved): null,
+                numUpdatesAdded > 0 ? string.Format(LocaleManager.Instance[LocaleKeys.AutoloadUpdateAddedMessage], numUpdatesAdded) : null
+            };
 
-            if (numDlcAdded > 0 && numUpdatesAdded > 0)
-            {
-                msg = string.Format(LocaleManager.Instance[LocaleKeys.AutoloadDlcAndUpdateAddedMessage], numDlcAdded, numUpdatesAdded);
-            }
-            else if (numDlcAdded > 0)
-            {
-                msg = string.Format(LocaleManager.Instance[LocaleKeys.AutoloadDlcAddedMessage], numDlcAdded);
-            }
-            else if (numUpdatesAdded > 0)
-            {
-                msg = string.Format(LocaleManager.Instance[LocaleKeys.AutoloadUpdateAddedMessage], numUpdatesAdded);
-            }
-            else
-            {
-                return Task.CompletedTask;
-            }
+            string msg = String.Join("\r\n", messages);
 
-            return Dispatcher.UIThread.InvokeAsync(async () =>
+            if (String.IsNullOrWhiteSpace(msg))
+                return;
+
+            Dispatcher.UIThread.InvokeAsync(async () =>
             {
                 await ContentDialogHelper.ShowTextDialog(LocaleManager.Instance[LocaleKeys.DialogConfirmationTitle],
                     msg, "", "", "", LocaleManager.Instance[LocaleKeys.InputDialogOk], (int)Symbol.Checkmark);