Browse Source

Merge branch 'main' into installed-checkpoints-page

pull/109/head
JT 1 year ago committed by GitHub
parent
commit
fc059fb628
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      CHANGELOG.md
  2. 1
      StabilityMatrix.Avalonia/App.axaml
  3. 7
      StabilityMatrix.Avalonia/App.axaml.cs
  4. 12
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  5. 65
      StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs
  6. 13
      StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs
  7. 22
      StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs
  8. 42
      StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml
  9. 347
      StabilityMatrix.Avalonia/Styles/ToggleButtonStyles.axaml
  10. 49
      StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs
  11. 16
      StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs
  12. 271
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs
  13. 86
      StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs
  14. 8
      StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
  15. 12
      StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs
  16. 78
      StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs
  17. 31
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  18. 206
      StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml
  19. 10
      StabilityMatrix.Avalonia/Views/SettingsPage.axaml
  20. 34
      StabilityMatrix.Core/Converters/Json/StringJsonConverter.cs
  21. 4
      StabilityMatrix.Core/Helper/FileHash.cs
  22. 44
      StabilityMatrix.Core/Models/CivitPostDownloadContextAction.cs
  23. 8
      StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs
  24. 22
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
  25. 9
      StabilityMatrix.Core/Models/IContextAction.cs
  26. 3
      StabilityMatrix.Core/Models/Progress/ProgressState.cs
  27. 1
      StabilityMatrix.Core/Models/Progress/ProgressType.cs
  28. 317
      StabilityMatrix.Core/Models/TrackedDownload.cs
  29. 183
      StabilityMatrix.Core/Services/DownloadService.cs
  30. 18
      StabilityMatrix.Core/Services/IDownloadService.cs
  31. 8
      StabilityMatrix.Core/Services/ISettingsManager.cs
  32. 15
      StabilityMatrix.Core/Services/ITrackedDownloadService.cs
  33. 23
      StabilityMatrix.Core/Services/SettingsManager.cs
  34. 247
      StabilityMatrix.Core/Services/TrackedDownloadService.cs

7
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2
### Added ### Added
- New installable Package - [Fooocus](https://github.com/lllyasviel/Fooocus) - New installable Package - [Fooocus](https://github.com/lllyasviel/Fooocus)
- Added "Select New Data Directory" button to Settings - Added "Select New Data Directory" button to Settings
- Pause/Resume/Cancel buttons on downloads popup. Paused downloads persists and may be resumed after restarting the app
### Fixed ### Fixed
- Fixed issue where model version wouldn't be selected in the "All Versions" section of the Model Browser - Fixed issue where model version wouldn't be selected in the "All Versions" section of the Model Browser
- Improved Checkpoints page indexing performance - Improved Checkpoints page indexing performance
@ -21,7 +22,13 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2
### Fixed ### Fixed
- Fixed SD.Next shared folders config not working with new config format, reverted to Junctions / Symlinks - Fixed SD.Next shared folders config not working with new config format, reverted to Junctions / Symlinks
## v2.2.1
### Fixed
- Fixed SD.Next shared folders config not working with new config format, reverted to Junctions / Symlinks
## v2.2.0 ## v2.2.0
### Added ### Added
- Added option to search by Base Model in the Model Browser - Added option to search by Base Model in the Model Browser
- Animated page transitions - Animated page transitions

1
StabilityMatrix.Avalonia/App.axaml

@ -28,5 +28,6 @@
<StyleInclude Source="Styles/ProgressRing.axaml"/> <StyleInclude Source="Styles/ProgressRing.axaml"/>
<StyleInclude Source="Styles/ButtonStyles.axaml"/> <StyleInclude Source="Styles/ButtonStyles.axaml"/>
<StyleInclude Source="Styles/SplitButtonStyles.axaml"/> <StyleInclude Source="Styles/SplitButtonStyles.axaml"/>
<StyleInclude Source="Styles/ToggleButtonStyles.axaml"/>
</Application.Styles> </Application.Styles>
</Application> </Application>

7
StabilityMatrix.Avalonia/App.axaml.cs

@ -211,7 +211,8 @@ public sealed class App : Application
services.AddSingleton<MainWindowViewModel>(provider => services.AddSingleton<MainWindowViewModel>(provider =>
new MainWindowViewModel(provider.GetRequiredService<ISettingsManager>(), new MainWindowViewModel(provider.GetRequiredService<ISettingsManager>(),
provider.GetRequiredService<IDiscordRichPresenceService>(), provider.GetRequiredService<IDiscordRichPresenceService>(),
provider.GetRequiredService<ServiceManager<ViewModelBase>>()) provider.GetRequiredService<ServiceManager<ViewModelBase>>(),
provider.GetRequiredService<ITrackedDownloadService>())
{ {
Pages = Pages =
{ {
@ -336,6 +337,10 @@ public sealed class App : Application
services.AddSingleton<IUpdateHelper, UpdateHelper>(); services.AddSingleton<IUpdateHelper, UpdateHelper>();
services.AddSingleton<INavigationService, NavigationService>(); services.AddSingleton<INavigationService, NavigationService>();
services.AddSingleton<ITrackedDownloadService, TrackedDownloadService>();
services.AddSingleton<IDisposable>(provider =>
(IDisposable) provider.GetRequiredService<ITrackedDownloadService>());
// Rich presence // Rich presence
services.AddSingleton<IDiscordRichPresenceService, DiscordRichPresenceService>(); services.AddSingleton<IDiscordRichPresenceService, DiscordRichPresenceService>();
services.AddSingleton<IDisposable>(provider => services.AddSingleton<IDisposable>(provider =>

12
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -91,7 +91,8 @@ public static class DesignData
.AddSingleton<ISharedFolders, MockSharedFolders>() .AddSingleton<ISharedFolders, MockSharedFolders>()
.AddSingleton<IDownloadService, MockDownloadService>() .AddSingleton<IDownloadService, MockDownloadService>()
.AddSingleton<IHttpClientFactory, MockHttpClientFactory>() .AddSingleton<IHttpClientFactory, MockHttpClientFactory>()
.AddSingleton<IDiscordRichPresenceService, MockDiscordRichPresenceService>(); .AddSingleton<IDiscordRichPresenceService, MockDiscordRichPresenceService>()
.AddSingleton<ITrackedDownloadService, MockTrackedDownloadService>();
// Placeholder services that nobody should need during design time // Placeholder services that nobody should need during design time
services services
@ -257,13 +258,12 @@ public static class DesignData
} }
}; };
ProgressManagerViewModel.ProgressItems = new ObservableCollection<ProgressItemViewModel> ProgressManagerViewModel.ProgressItems.AddRange(new ProgressItemViewModelBase[]
{ {
new(new ProgressItem(Guid.NewGuid(), "Test File.exe", new ProgressItemViewModel(new ProgressItem(Guid.NewGuid(), "Test File.exe",
new ProgressReport(0.5f, "Downloading..."))), new ProgressReport(0.5f, "Downloading..."))),
new(new ProgressItem(Guid.NewGuid(), "Test File 2.uwu", new MockDownloadProgressItemViewModel("Test File 2.exe"),
new ProgressReport(0.25f, "Extracting..."))) });
};
UpdateViewModel = Services.GetRequiredService<UpdateViewModel>(); UpdateViewModel = Services.GetRequiredService<UpdateViewModel>();
UpdateViewModel.UpdateText = UpdateViewModel.UpdateText =

65
StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs

@ -0,0 +1,65 @@
using System.Threading;
using System.Threading.Tasks;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockDownloadProgressItemViewModel : PausableProgressItemViewModelBase
{
private Task? dummyTask;
private CancellationTokenSource? cts;
public MockDownloadProgressItemViewModel(string fileName)
{
Name = fileName;
Progress.Value = 5;
Progress.IsIndeterminate = false;
Progress.Text = "Downloading...";
}
/// <inheritdoc />
public override Task Cancel()
{
// Cancel the task that updates progress
cts?.Cancel();
cts = null;
dummyTask = null;
State = ProgressState.Cancelled;
Progress.Text = "Cancelled";
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Pause()
{
// Cancel the task that updates progress
cts?.Cancel();
cts = null;
dummyTask = null;
State = ProgressState.Inactive;
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Resume()
{
// Start a task that updates progress every 100ms
cts = new CancellationTokenSource();
dummyTask = Task.Run(async () =>
{
while (State != ProgressState.Success)
{
await Task.Delay(100, cts.Token);
Progress.Value += 1;
}
}, cts.Token);
State = ProgressState.Working;
return Task.CompletedTask;
}
}

13
StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs

@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
@ -8,8 +9,16 @@ namespace StabilityMatrix.Avalonia.DesignData;
public class MockDownloadService : IDownloadService public class MockDownloadService : IDownloadService
{ {
public Task DownloadToFileAsync(string downloadUrl, string downloadPath, public Task DownloadToFileAsync(string downloadUrl, string downloadPath, IProgress<ProgressReport>? progress = null,
IProgress<ProgressReport>? progress = null, string? httpClientName = null) string? httpClientName = null, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task ResumeDownloadToFileAsync(string downloadUrl, string downloadPath, long existingFileSize,
IProgress<ProgressReport>? progress = null, string? httpClientName = null,
CancellationToken cancellationToken = default)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }

22
StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs

@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockTrackedDownloadService : ITrackedDownloadService
{
/// <inheritdoc />
public IEnumerable<TrackedDownload> Downloads => Array.Empty<TrackedDownload>();
/// <inheritdoc />
public event EventHandler<TrackedDownload>? DownloadAdded;
/// <inheritdoc />
public TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath)
{
throw new NotImplementedException();
}
}

42
StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml

@ -11,6 +11,7 @@
<Button Classes="info" Content="Info Button" Margin="8" HorizontalAlignment="Center" /> <Button Classes="info" Content="Info Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="transparent-info" Content="Semi-Transparent Info Button" Margin="8" HorizontalAlignment="Center" /> <Button Classes="transparent-info" Content="Semi-Transparent Info Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="transparent" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" /> <Button Classes="transparent" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="transparent-full" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<Button Content="Disabled Button" Margin="8" IsEnabled="False" HorizontalAlignment="Center" /> <Button Content="Disabled Button" Margin="8" IsEnabled="False" HorizontalAlignment="Center" />
</StackPanel> </StackPanel>
</Border> </Border>
@ -302,4 +303,45 @@
</Style> </Style>
</Style> </Style>
</Style> </Style>
<!-- Full Transparent -->
<Style Selector="Button.transparent-full">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemControlTransparentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
</Styles> </Styles>

347
StabilityMatrix.Avalonia/Styles/ToggleButtonStyles.axaml

@ -0,0 +1,347 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
<Design.PreviewWith>
<Border Padding="20">
<StackPanel>
<ToggleButton Classes="success" Content="Success Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="accent" Content="FA Accent Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="systemaccent" Content="System Accent Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="danger" Content="Danger Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="info" Content="Info Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="transparent-info" Content="Semi-Transparent Info Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="transparent" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Classes="transparent-full" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<ToggleButton Content="Disabled Button" Margin="8" IsEnabled="False" HorizontalAlignment="Center" />
</StackPanel>
</Border>
</Design.PreviewWith>
<!-- Success -->
<Style Selector="ToggleButton.success">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeGreenColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeGreenColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkGreenColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkGreenColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkDarkGreenColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkDarkGreenColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Danger -->
<Style Selector="ToggleButton.danger">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeRedColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeRedColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkRedColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkRedColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkDarkRedColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkDarkRedColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Info -->
<Style Selector="ToggleButton.info">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeLightBlueColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeLightBlueColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeBlueColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeBlueColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!--Accent Button-->
<Style Selector="ToggleButton.accent">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundPointerOver}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushPointerOver}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- SystemAccent -->
<Style Selector="ToggleButton.systemaccent">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorDark1}"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorDark1}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorDark2}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorDark2}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Transparent -->
<Style Selector="ToggleButton.transparent">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemControlTransparentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Semi-Transparent Info -->
<Style Selector="ToggleButton.transparent-info">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeLightBlueColorTransparent}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeLightBlueColorTransparent}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeBlueColorTransparent}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeBlueColorTransparent}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColorTransparent}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColorTransparent}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Full Transparent -->
<Style Selector="ToggleButton.transparent-full">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemControlTransparentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
</Styles>

49
StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs

@ -0,0 +1,49 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")]
public abstract partial class PausableProgressItemViewModelBase : ProgressItemViewModelBase
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPaused), nameof(IsCompleted), nameof(CanPauseResume), nameof(CanCancel))]
private ProgressState state = ProgressState.Inactive;
/// <summary>
/// Whether the progress is paused
/// </summary>
public bool IsPaused => State == ProgressState.Inactive;
/// <summary>
/// Whether the progress has succeeded, failed or was cancelled
/// </summary>
public override bool IsCompleted => State is ProgressState.Success or ProgressState.Failed or ProgressState.Cancelled;
public virtual bool SupportsPauseResume => true;
public virtual bool SupportsCancel => true;
public bool CanPauseResume => SupportsPauseResume && !IsCompleted;
public bool CanCancel => SupportsCancel && !IsCompleted;
private AsyncRelayCommand? pauseCommand;
public IAsyncRelayCommand PauseCommand => pauseCommand ??= new AsyncRelayCommand(Pause);
public virtual Task Pause() => Task.CompletedTask;
private AsyncRelayCommand? resumeCommand;
public IAsyncRelayCommand ResumeCommand => resumeCommand ??= new AsyncRelayCommand(Resume);
public virtual Task Resume() => Task.CompletedTask;
private AsyncRelayCommand? cancelCommand;
public IAsyncRelayCommand CancelCommand => cancelCommand ??= new AsyncRelayCommand(Cancel);
public virtual Task Cancel() => Task.CompletedTask;
[RelayCommand]
private Task TogglePauseResume()
{
return IsPaused ? Resume() : Pause();
}
}

16
StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs

@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
public abstract partial class ProgressItemViewModelBase : ViewModelBase
{
[ObservableProperty] private Guid id;
[ObservableProperty] private string? name;
[ObservableProperty] private bool failed;
public virtual bool IsCompleted => Progress.Value >= 100 || Failed;
public ProgressViewModel Progress { get; } = new();
}

271
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs

@ -32,10 +32,10 @@ using Notification = Avalonia.Controls.Notifications.Notification;
namespace StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; namespace StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
{ {
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IDownloadService downloadService; private readonly IDownloadService downloadService;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly ISettingsManager settingsManager; private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory; private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly INotificationService notificationService; private readonly INotificationService notificationService;
@ -63,11 +63,13 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
public CheckpointBrowserCardViewModel( public CheckpointBrowserCardViewModel(
IDownloadService downloadService, IDownloadService downloadService,
ITrackedDownloadService trackedDownloadService,
ISettingsManager settingsManager, ISettingsManager settingsManager,
ServiceManager<ViewModelBase> dialogFactory, ServiceManager<ViewModelBase> dialogFactory,
INotificationService notificationService) INotificationService notificationService)
{ {
this.downloadService = downloadService; this.downloadService = downloadService;
this.trackedDownloadService = trackedDownloadService;
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory; this.dialogFactory = dialogFactory;
this.notificationService = notificationService; this.notificationService = notificationService;
@ -197,6 +199,53 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
await DoImport(model, selectedVersion, selectedFile); await DoImport(model, selectedVersion, selectedFile);
} }
private static async Task<FilePath> SaveCmInfo(
CivitModel model,
CivitModelVersion modelVersion,
CivitFile modelFile,
DirectoryPath downloadDirectory)
{
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Name);
var modelInfo =
new ConnectedModelInfo(model, modelVersion, modelFile, DateTime.UtcNow);
await modelInfo.SaveJsonToDirectory(downloadDirectory, modelFileName);
var jsonName = $"{modelFileName}.cm-info.json";
return downloadDirectory.JoinFile(jsonName);
}
/// <summary>
/// Saves the preview image to the same directory as the model file
/// </summary>
/// <param name="modelVersion"></param>
/// <param name="modelFilePath"></param>
/// <returns>The file path of the saved preview image</returns>
private async Task<FilePath?> SavePreviewImage(CivitModelVersion modelVersion, FilePath modelFilePath)
{
// Skip if model has no images
if (modelVersion.Images == null || modelVersion.Images.Count == 0)
{
return null;
}
var image = modelVersion.Images[0];
var imageExtension = Path.GetExtension(image.Url).TrimStart('.');
if (imageExtension is "jpg" or "jpeg" or "png")
{
var imageDownloadPath =
modelFilePath.Directory!.JoinFile($"{modelFilePath.Name}.preview.{imageExtension}");
var imageTask =
downloadService.DownloadToFileAsync(image.Url, imageDownloadPath);
await notificationService.TryAsync(imageTask, "Could not download preview image");
return imageDownloadPath;
}
return null;
}
private async Task DoImport(CivitModel model, CivitModelVersion? selectedVersion = null, private async Task DoImport(CivitModel model, CivitModelVersion? selectedVersion = null,
CivitFile? selectedFile = null) CivitFile? selectedFile = null)
{ {
@ -204,164 +253,96 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
Text = "Downloading..."; Text = "Downloading...";
OnDownloadStart?.Invoke(this); OnDownloadStart?.Invoke(this);
// Get latest version
var modelVersion = selectedVersion ?? model.ModelVersions?.FirstOrDefault();
if (modelVersion is null)
{
notificationService.Show(new Notification("Model has no versions available",
"This model has no versions available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
}
// Holds files to be deleted on errors // Get latest version file
var filesForCleanup = new HashSet<FilePath>(); var modelFile = selectedFile ??
modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model);
// Set Text when exiting, finally block will set 100 and delay clear progress if (modelFile is null)
try
{ {
// Get latest version notificationService.Show(new Notification("Model has no files available",
var modelVersion = selectedVersion ?? model.ModelVersions?.FirstOrDefault(); "This model has no files available for download", NotificationType.Warning));
if (modelVersion is null) Text = "Unable to Download";
{ return;
notificationService.Show(new Notification("Model has no versions available", }
"This model has no versions available for download", NotificationType.Warning));
Text = "Unable to Download"; var rootModelsDirectory = new DirectoryPath(settingsManager.ModelsDirectory);
return;
} var downloadDirectory =
rootModelsDirectory.JoinDir(model.Type.ConvertTo<SharedFolderType>()
.GetStringValue());
// Folders might be missing if user didn't install any packages yet
downloadDirectory.Create();
var downloadPath = downloadDirectory.JoinFile(modelFile.Name);
// Get latest version file // Download model info and preview first
var modelFile = selectedFile ?? var cmInfoPath = await SaveCmInfo(model, modelVersion, modelFile, downloadDirectory);
modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model); var previewImagePath = await SavePreviewImage(modelVersion, downloadPath);
if (modelFile is null)
// Create tracked download
var download = trackedDownloadService.NewDownload(modelFile.DownloadUrl, downloadPath);
// Add hash info
download.ExpectedHashSha256 = modelFile.Hashes.SHA256;
// Add files to cleanup list
download.ExtraCleanupFileNames.Add(cmInfoPath);
if (previewImagePath is not null)
{
download.ExtraCleanupFileNames.Add(previewImagePath);
}
// Attach for progress updates
download.ProgressUpdate += (s, e) =>
{
Value = e.Percentage;
if (e.Type == ProgressType.Hashing)
{ {
notificationService.Show(new Notification("Model has no files available", Text = $"Validating... {e.Percentage}%";
"This model has no files available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
} }
else
var downloadFolder = Path.Combine(settingsManager.ModelsDirectory,
model.Type.ConvertTo<SharedFolderType>().GetStringValue());
// Folders might be missing if user didn't install any packages yet
Directory.CreateDirectory(downloadFolder);
var downloadPath = Path.GetFullPath(Path.Combine(downloadFolder, modelFile.Name));
filesForCleanup.Add(downloadPath);
// Do the download
var progressId = Guid.NewGuid();
var downloadTask = downloadService.DownloadToFileAsync(modelFile.DownloadUrl,
downloadPath,
new Progress<ProgressReport>(report =>
{
if (Math.Abs(report.Percentage - Value) > 0.1)
{
Dispatcher.UIThread.Post(() =>
{
Value = report.Percentage;
Text = $"Downloading... {report.Percentage}%";
});
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, report));
}
}));
var downloadResult =
await notificationService.TryAsync(downloadTask, "Could not download file");
// Failed download handling
if (downloadResult.Exception is not null)
{ {
// For exceptions other than ApiException or TaskCanceledException, log error Text = $"Downloading... {e.Percentage}%";
var logLevel = downloadResult.Exception switch
{
HttpRequestException or ApiException or TaskCanceledException => LogLevel.Warn,
_ => LogLevel.Error
};
Logger.Log(logLevel, downloadResult.Exception, "Error during model download");
Text = "Download Failed";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(0f), true));
return;
} }
};
// When sha256 is available, validate the downloaded file download.ProgressStateChanged += (s, e) =>
var fileExpectedSha256 = modelFile.Hashes.SHA256; {
if (!string.IsNullOrEmpty(fileExpectedSha256)) if (e == ProgressState.Success)
{ {
var hashProgress = new Progress<ProgressReport>(progress => Text = "Import Complete";
{
Value = progress.Percentage; IsIndeterminate = false;
Text = $"Validating... {progress.Percentage}%"; Value = 100;
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId, CheckIfInstalled();
modelFile.Name, progress)); DelayedClearProgress(TimeSpan.FromMilliseconds(800));
});
var sha256 = await FileHash.GetSha256Async(downloadPath, hashProgress);
if (sha256 != fileExpectedSha256.ToLowerInvariant())
{
Text = "Import Failed!";
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
notificationService.Show(new Notification("Download failed hash validation",
"This may be caused by network or server issues from CivitAI, please try again in a few minutes.",
NotificationType.Error));
Text = "Download Failed";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(0f), true));
return;
}
settingsManager.Transaction(
s => s.InstalledModelHashes.Add(modelFile.Hashes.BLAKE3));
notificationService.Show(new Notification("Import complete",
$"{model.Type} {model.Name} imported successfully!", NotificationType.Success));
} }
else if (e == ProgressState.Cancelled)
IsIndeterminate = true;
// Save connected model info
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Name);
var modelInfo =
new ConnectedModelInfo(CivitModel, modelVersion, modelFile, DateTime.UtcNow);
var modelInfoPath = Path.GetFullPath(Path.Combine(
downloadFolder, modelFileName + ConnectedModelInfo.FileExtension));
filesForCleanup.Add(modelInfoPath);
await modelInfo.SaveJsonToDirectory(downloadFolder, modelFileName);
// If available, save a model image
if (modelVersion.Images != null && modelVersion.Images.Any())
{ {
var image = modelVersion.Images[0]; Text = "Cancelled";
var imageExtension = Path.GetExtension(image.Url).TrimStart('.'); DelayedClearProgress(TimeSpan.FromMilliseconds(500));
if (imageExtension is "jpg" or "jpeg" or "png")
{
var imageDownloadPath = Path.GetFullPath(Path.Combine(downloadFolder,
$"{modelFileName}.preview.{imageExtension}"));
filesForCleanup.Add(imageDownloadPath);
var imageTask =
downloadService.DownloadToFileAsync(image.Url, imageDownloadPath);
await notificationService.TryAsync(imageTask, "Could not download preview image");
}
} }
else if (e == ProgressState.Failed)
// Successful - clear cleanup list
filesForCleanup.Clear();
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(1f, "Import complete")));
Text = "Import complete!";
}
catch (Exception e)
{
Debug.WriteLine(e);
}
finally
{
foreach (var file in filesForCleanup.Where(file => file.Exists))
{ {
file.Delete(); Text = "Download Failed";
Logger.Info($"Download cleanup: Deleted file {file}"); DelayedClearProgress(TimeSpan.FromMilliseconds(800));
} }
};
IsIndeterminate = false;
Value = 100; // Add hash context action
CheckIfInstalled(); download.ContextAction = CivitPostDownloadContextAction.FromCivitFile(modelFile);
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
} download.Start();
} }
private void DelayedClearProgress(TimeSpan delay) private void DelayedClearProgress(TimeSpan delay)

86
StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs

@ -0,0 +1,86 @@
using System;
using System.Threading.Tasks;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels;
public class DownloadProgressItemViewModel : PausableProgressItemViewModelBase
{
private readonly TrackedDownload download;
public DownloadProgressItemViewModel(TrackedDownload download)
{
this.download = download;
Id = download.Id;
Name = download.FileName;
State = download.ProgressState;
OnProgressStateChanged(State);
// If initial progress provided, load it
if (download is {TotalBytes: > 0, DownloadedBytes: > 0})
{
var current = download.DownloadedBytes / (double) download.TotalBytes;
Progress.Value = (float) Math.Ceiling(Math.Clamp(current, 0, 1) * 100);
}
download.ProgressUpdate += (s, e) =>
{
Progress.Value = e.Percentage;
Progress.IsIndeterminate = e.IsIndeterminate;
};
download.ProgressStateChanged += (s, e) =>
{
State = e;
OnProgressStateChanged(e);
};
}
private void OnProgressStateChanged(ProgressState state)
{
if (state == ProgressState.Inactive)
{
Progress.Text = "Paused";
}
else if (state == ProgressState.Working)
{
Progress.Text = "Downloading...";
}
else if (state == ProgressState.Success)
{
Progress.Text = "Completed";
}
else if (state == ProgressState.Cancelled)
{
Progress.Text = "Cancelled";
}
else if (state == ProgressState.Failed)
{
Progress.Text = "Failed";
}
}
/// <inheritdoc />
public override Task Cancel()
{
download.Cancel();
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Pause()
{
download.Pause();
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Resume()
{
download.Resume();
return Task.CompletedTask;
}
}

8
StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs

@ -24,6 +24,7 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
private readonly ISettingsManager settingsManager; private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory; private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly IDiscordRichPresenceService discordRichPresenceService; private readonly IDiscordRichPresenceService discordRichPresenceService;
public string Greeting => "Welcome to Avalonia!"; public string Greeting => "Welcome to Avalonia!";
@ -45,11 +46,13 @@ public partial class MainWindowViewModel : ViewModelBase
public MainWindowViewModel( public MainWindowViewModel(
ISettingsManager settingsManager, ISettingsManager settingsManager,
IDiscordRichPresenceService discordRichPresenceService, IDiscordRichPresenceService discordRichPresenceService,
ServiceManager<ViewModelBase> dialogFactory) ServiceManager<ViewModelBase> dialogFactory,
ITrackedDownloadService trackedDownloadService)
{ {
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory; this.dialogFactory = dialogFactory;
this.discordRichPresenceService = discordRichPresenceService; this.discordRichPresenceService = discordRichPresenceService;
this.trackedDownloadService = trackedDownloadService;
ProgressManagerViewModel = dialogFactory.Get<ProgressManagerViewModel>(); ProgressManagerViewModel = dialogFactory.Get<ProgressManagerViewModel>();
UpdateViewModel = dialogFactory.Get<UpdateViewModel>(); UpdateViewModel = dialogFactory.Get<UpdateViewModel>();
@ -81,6 +84,9 @@ public partial class MainWindowViewModel : ViewModelBase
// Initialize Discord Rich Presence (this needs LibraryDir so is set here) // Initialize Discord Rich Presence (this needs LibraryDir so is set here)
discordRichPresenceService.UpdateState(); discordRichPresenceService.UpdateState();
// Load in-progress downloads
ProgressManagerViewModel.AddDownloads(trackedDownloadService.Downloads);
// Index checkpoints if we dont have // Index checkpoints if we dont have
Task.Run(() => settingsManager.IndexCheckpoints()).SafeFireAndForget(); Task.Run(() => settingsManager.IndexCheckpoints()).SafeFireAndForget();

12
StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs

@ -6,21 +6,19 @@ using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels; namespace StabilityMatrix.Avalonia.ViewModels;
public partial class ProgressItemViewModel : ViewModelBase public partial class ProgressItemViewModel : ProgressItemViewModelBase
{ {
[ObservableProperty] private Guid id; [ObservableProperty] private Guid id;
[ObservableProperty] private string name; [ObservableProperty] private string name;
[ObservableProperty] private ProgressReport progress;
[ObservableProperty] private bool failed; [ObservableProperty] private bool failed;
[ObservableProperty] private string? progressText;
public ProgressItemViewModel(ProgressItem progressItem) public ProgressItemViewModel(ProgressItem progressItem)
{ {
Id = progressItem.ProgressId; Id = progressItem.ProgressId;
Name = progressItem.Name; Name = progressItem.Name;
Progress = progressItem.Progress; Progress.Value = progressItem.Progress.Percentage;
Failed = progressItem.Failed; Failed = progressItem.Failed;
ProgressText = GetProgressText(Progress); Progress.Text = GetProgressText(progressItem.Progress);
EventManager.Instance.ProgressChanged += OnProgressChanged; EventManager.Instance.ProgressChanged += OnProgressChanged;
} }
@ -30,9 +28,9 @@ public partial class ProgressItemViewModel : ViewModelBase
if (e.ProgressId != Id) if (e.ProgressId != Id)
return; return;
Progress = e.Progress; Progress.Value = e.Progress.Percentage;
Failed = e.Failed; Failed = e.Failed;
ProgressText = GetProgressText(Progress); Progress.Text = GetProgressText(e.Progress);
} }
private string GetProgressText(ProgressReport report) private string GetProgressText(ProgressReport report)

78
StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs

@ -1,13 +1,21 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using Avalonia.Collections;
using Avalonia.Controls.Notifications;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using Polly;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol; using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -16,17 +24,72 @@ namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(ProgressManagerPage))] [View(typeof(ProgressManagerPage))]
public partial class ProgressManagerViewModel : PageViewModelBase public partial class ProgressManagerViewModel : PageViewModelBase
{ {
private readonly INotificationService notificationService;
public override string Title => "Download Manager"; public override string Title => "Download Manager";
public override IconSource IconSource => new SymbolIconSource {Symbol = Symbol.ArrowCircleDown, IsFilled = true}; public override IconSource IconSource => new SymbolIconSource {Symbol = Symbol.ArrowCircleDown, IsFilled = true};
[ObservableProperty] public AvaloniaList<ProgressItemViewModelBase> ProgressItems { get; } = new();
private ObservableCollection<ProgressItemViewModel> progressItems;
public ProgressManagerViewModel(
ITrackedDownloadService trackedDownloadService,
INotificationService notificationService)
{
this.notificationService = notificationService;
// Attach to the event
trackedDownloadService.DownloadAdded += TrackedDownloadService_OnDownloadAdded;
}
public ProgressManagerViewModel() private void TrackedDownloadService_OnDownloadAdded(object? sender, TrackedDownload e)
{ {
ProgressItems = new ObservableCollection<ProgressItemViewModel>(); var vm = new DownloadProgressItemViewModel(e);
// Attach notification handlers
e.ProgressStateChanged += (s, state) =>
{
var download = s as TrackedDownload;
switch (state)
{
case ProgressState.Success:
notificationService.Show("Download Completed", $"Download of {e.FileName} completed successfully.", NotificationType.Success);
break;
case ProgressState.Failed:
var msg = "";
if (download?.Exception is { } exception)
{
msg = $"({exception.GetType().Name}) {exception.Message}";
}
notificationService.ShowPersistent("Download Failed", $"Download of {e.FileName} failed: {msg}", NotificationType.Error);
break;
case ProgressState.Cancelled:
notificationService.Show("Download Cancelled", $"Download of {e.FileName} was cancelled.", NotificationType.Warning);
break;
}
};
ProgressItems.Add(vm);
} }
public void AddDownloads(IEnumerable<TrackedDownload> downloads)
{
foreach (var download in downloads)
{
if (ProgressItems.Any(vm => vm.Id == download.Id))
{
continue;
}
var vm = new DownloadProgressItemViewModel(download);
ProgressItems.Add(vm);
}
}
private void ShowFailedNotification(string title, string message)
{
notificationService.ShowPersistent(title, message, NotificationType.Error);
}
public void StartEventListener() public void StartEventListener()
{ {
EventManager.Instance.ProgressChanged += OnProgressChanged; EventManager.Instance.ProgressChanged += OnProgressChanged;
@ -34,12 +97,7 @@ public partial class ProgressManagerViewModel : PageViewModelBase
public void ClearDownloads() public void ClearDownloads()
{ {
if (!ProgressItems.Any(p => Math.Abs(p.Progress.Percentage - 100) < 0.01f || p.Failed)) ProgressItems.RemoveAll(ProgressItems.Where(x => x.IsCompleted));
return;
var itemsInProgress = ProgressItems
.Where(p => p.Progress.Percentage < 100 && !p.Failed).ToList();
ProgressItems = new ObservableCollection<ProgressItemViewModel>(itemsInProgress);
} }
private void OnProgressChanged(object? sender, ProgressItem e) private void OnProgressChanged(object? sender, ProgressItem e)

31
StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs

@ -46,6 +46,7 @@ public partial class SettingsViewModel : PageViewModelBase
private readonly IPrerequisiteHelper prerequisiteHelper; private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly IPyRunner pyRunner; private readonly IPyRunner pyRunner;
private readonly ServiceManager<ViewModelBase> dialogFactory; private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ITrackedDownloadService trackedDownloadService;
public SharedState SharedState { get; } public SharedState SharedState { get; }
@ -104,13 +105,15 @@ public partial class SettingsViewModel : PageViewModelBase
IPrerequisiteHelper prerequisiteHelper, IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner, IPyRunner pyRunner,
ServiceManager<ViewModelBase> dialogFactory, ServiceManager<ViewModelBase> dialogFactory,
SharedState sharedState) SharedState sharedState,
ITrackedDownloadService trackedDownloadService)
{ {
this.notificationService = notificationService; this.notificationService = notificationService;
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.prerequisiteHelper = prerequisiteHelper; this.prerequisiteHelper = prerequisiteHelper;
this.pyRunner = pyRunner; this.pyRunner = pyRunner;
this.dialogFactory = dialogFactory; this.dialogFactory = dialogFactory;
this.trackedDownloadService = trackedDownloadService;
SharedState = sharedState; SharedState = sharedState;
@ -451,6 +454,32 @@ public partial class SettingsViewModel : PageViewModelBase
// Use try-catch to generate traceback information // Use try-catch to generate traceback information
throw new OperationCanceledException("Example Message"); throw new OperationCanceledException("Example Message");
} }
[RelayCommand]
private async Task DebugTrackedDownload()
{
var textFields = new TextBoxField[]
{
new()
{
Label = "Url",
},
new()
{
Label = "File path"
}
};
var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields);
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
var url = textFields[0].Text;
var filePath = textFields[1].Text;
var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath));
download.Start();
}
}
#endregion #endregion
#region Info Section #region Info Section

206
StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml

@ -1,46 +1,156 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui" <controls:UserControlBase
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="StabilityMatrix.Avalonia.Views.ProgressManagerPage"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns="https://github.com/avaloniaui"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
d:DataContext="{x:Static mocks:DesignData.ProgressManagerViewModel}" xmlns:icons="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
x:DataType="vm:ProgressManagerViewModel" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
MaxHeight="250" xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="250" xmlns:ui="using:FluentAvalonia.UI.Controls"
x:Class="StabilityMatrix.Avalonia.Views.ProgressManagerPage"> xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmBase="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Base"
MaxHeight="250"
d:DataContext="{x:Static mocks:DesignData.ProgressManagerViewModel}"
d:DesignHeight="250"
d:DesignWidth="300"
x:DataType="vm:ProgressManagerViewModel"
mc:Ignorable="d">
<ScrollViewer> <ScrollViewer>
<Grid RowDefinitions="Auto, *"> <Grid RowDefinitions="Auto, *">
<TextBlock Grid.Row="0" Text="Downloads" <TextBlock
VerticalAlignment="Center" Grid.Row="0"
TextDecorations="Underline" VerticalAlignment="Center"
FontSize="16" FontSize="16"
TextAlignment="Left" /> Text="Downloads"
<Button Grid.Row="0" TextAlignment="Left"
Content="Clear Finished" TextDecorations="Underline" />
Padding="4" <Button
Classes="transparent" Grid.Row="0"
Margin="0,0,8,0" Margin="0,0,8,0"
IsVisible="{Binding !!ProgressItems.Count}" Padding="4"
Command="{Binding ClearDownloads}" HorizontalAlignment="Right"
HorizontalAlignment="Right" /> Classes="transparent"
Command="{Binding ClearDownloads}"
Content="Clear Finished"
IsVisible="{Binding !!ProgressItems.Count}" />
<ItemsControl
Grid.Row="1"
Margin="0,4,0,0"
ItemsSource="{Binding ProgressItems, Mode=OneWay}">
<ItemsControl.DataTemplates>
<DataTemplate DataType="{x:Type vmBase:PausableProgressItemViewModelBase}">
<Border
Margin="4"
Padding="8"
Background="#22000000"
BorderBrush="#33000000"
BorderThickness="2"
CornerRadius="8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Margin="0,0" Text="{Binding Name, Mode=OneWay}" />
<!-- non-indeterminate progress -->
<TextBlock
Margin="0,0"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding Progress.Text, Mode=OneWay}" />
<Run Text="{Binding Progress.Value, Mode=OneWay}" /><Run Text="%" />
</TextBlock>
<!-- indeterminate progress -->
<TextBlock
Margin="0,4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding Progress.Text, Mode=OneWay}" />
</StackPanel>
<!-- Buttons -->
<UniformGrid
Grid.Row="0"
Grid.Column="1"
Columns="2">
<ToggleButton
Name="PauseResumeToggleButton"
Classes="transparent-full"
Command="{Binding TogglePauseResumeCommand}"
IsChecked="{Binding IsPaused}"
IsVisible="{Binding CanPauseResume}">
<Panel>
<ui:SymbolIcon IsVisible="{Binding ElementName=PauseResumeToggleButton, Path=!IsChecked}" Symbol="Pause" />
<ui:SymbolIcon IsVisible="{Binding ElementName=PauseResumeToggleButton, Path=IsChecked}" Symbol="Play" />
</Panel>
</ToggleButton>
<Button
Command="{Binding CancelCommand}"
IsVisible="{Binding CanCancel}"
Classes="transparent-full">
<ui:SymbolIcon Symbol="Cancel" />
</Button>
</UniformGrid>
<ProgressBar
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,8,0,4"
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Value="{Binding Progress.Value, Mode=OneWay}">
<ProgressBar.Transitions>
<Transitions>
<DoubleTransition Property="Value" Duration="00:00:00.150">
<DoubleTransition.Easing>
<SineEaseInOut />
</DoubleTransition.Easing>
</DoubleTransition>
</Transitions>
</ProgressBar.Transitions>
</ProgressBar>
</Grid>
</Border>
</DataTemplate>
<ItemsRepeater Margin="0,4,0,0" Grid.Row="1" ItemsSource="{Binding ProgressItems, Mode=OneWay}">
<ItemsRepeater.ItemTemplate>
<DataTemplate DataType="{x:Type vm:ProgressItemViewModel}"> <DataTemplate DataType="{x:Type vm:ProgressItemViewModel}">
<Border BorderBrush="#33000000" <Border
Background="#22000000" Margin="4"
BorderThickness="2" Padding="8"
CornerRadius="8" Background="#22000000"
Padding="8" BorderBrush="#33000000"
Margin="4"> BorderThickness="2"
<StackPanel> CornerRadius="8">
<TextBlock Text="{Binding Name, Mode=OneWay}" <Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
Margin="0, 4" /> <StackPanel Grid.Row="0" Grid.Column="0">
<ProgressBar Value="{Binding Progress.Percentage, Mode=OneWay}" <TextBlock Margin="0,0" Text="{Binding Name, Mode=OneWay}" />
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Margin="0, 4"> <!-- non-indeterminate progress -->
<TextBlock
Margin="0,0"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding Progress.Text, Mode=OneWay}" />
<Run Text="{Binding Progress.Value, Mode=OneWay}" /><Run Text="%" />
</TextBlock>
<!-- indeterminate progress -->
<TextBlock
Margin="0,4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding Progress.Text, Mode=OneWay}" />
</StackPanel>
<ProgressBar
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,8,0,4"
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Value="{Binding Progress.Value, Mode=OneWay}">
<ProgressBar.Transitions> <ProgressBar.Transitions>
<Transitions> <Transitions>
<DoubleTransition Property="Value" Duration="00:00:00.150"> <DoubleTransition Property="Value" Duration="00:00:00.150">
@ -51,23 +161,11 @@
</Transitions> </Transitions>
</ProgressBar.Transitions> </ProgressBar.Transitions>
</ProgressBar> </ProgressBar>
</Grid>
<!-- non-indeterminate progress -->
<TextBlock Margin="0, 4"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding ProgressText, Mode=OneWay}" />
<Run Text="{Binding Progress.Percentage, Mode=OneWay}" /><Run Text="%" />
</TextBlock>
<!-- indeterminate progress -->
<TextBlock Margin="0, 4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding ProgressText, Mode=OneWay}" />
</StackPanel>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsRepeater.ItemTemplate> </ItemsControl.DataTemplates>
</ItemsRepeater> </ItemsControl>
</Grid> </Grid>
</ScrollViewer> </ScrollViewer>
</controls:UserControlBase> </controls:UserControlBase>

10
StabilityMatrix.Avalonia/Views/SettingsPage.axaml

@ -267,6 +267,16 @@
</ui:SettingsExpanderItem.Footer> </ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem> </ui:SettingsExpanderItem>
<ui:SettingsExpanderItem Content="Download Manager tests" IconSource="Flag"
Margin="4,0,4,4">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0, 8"
Command="{Binding DebugTrackedDownloadCommand}"
Content="Add Tracked Download"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander> </ui:SettingsExpander>
</Grid> </Grid>

34
StabilityMatrix.Core/Converters/Json/StringJsonConverter.cs

@ -0,0 +1,34 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StabilityMatrix.Core.Converters.Json;
/// <summary>
/// Json converter for types that serialize to string by `ToString()` and
/// can be created by `Activator.CreateInstance(Type, string)`
/// </summary>
public class StringJsonConverter<T> : JsonConverter<T>
{
/// <inheritdoc />
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException();
}
var value = reader.GetString();
if (value is null)
{
throw new JsonException();
}
return (T?) Activator.CreateInstance(typeToConvert, value);
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
{
writer.WriteStringValue(value?.ToString());
}
}

4
StabilityMatrix.Core/Helper/FileHash.cs

@ -46,8 +46,8 @@ public static class FileHash
var hash = await GetHashAsync(SHA256.Create(), stream, buffer, totalBytesRead => var hash = await GetHashAsync(SHA256.Create(), stream, buffer, totalBytesRead =>
{ {
progress?.Report(new ProgressReport(totalBytesRead, totalBytes)); progress?.Report(new ProgressReport(totalBytesRead, totalBytes, type: ProgressType.Hashing));
}); }).ConfigureAwait(false);
return hash; return hash;
} }
finally finally

44
StabilityMatrix.Core/Models/CivitPostDownloadContextAction.cs

@ -0,0 +1,44 @@
using System.Diagnostics;
using System.Text.Json;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Core.Models;
public class CivitPostDownloadContextAction : IContextAction
{
/// <inheritdoc />
public object? Context { get; set; }
public static CivitPostDownloadContextAction FromCivitFile(CivitFile file)
{
return new CivitPostDownloadContextAction
{
Context = file.Hashes.BLAKE3
};
}
public void Invoke(ISettingsManager settingsManager)
{
var result = Context as string;
if (Context is JsonElement jsonElement)
{
result = jsonElement.GetString();
}
if (result is null)
{
Debug.WriteLine($"Context {Context} is not a string.");
return;
}
Debug.WriteLine($"Adding {result} to installed models.");
settingsManager.Transaction(
s =>
{
s.InstalledModelHashes ??= new HashSet<string>();
s.InstalledModelHashes.Add(result);
});
}
}

8
StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs

@ -1,14 +1,19 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using StabilityMatrix.Core.Converters.Json;
namespace StabilityMatrix.Core.Models.FileInterfaces; namespace StabilityMatrix.Core.Models.FileInterfaces;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[JsonConverter(typeof(StringJsonConverter<DirectoryPath>))]
public class DirectoryPath : FileSystemPath, IPathObject public class DirectoryPath : FileSystemPath, IPathObject
{ {
private DirectoryInfo? info; private DirectoryInfo? info;
// ReSharper disable once MemberCanBePrivate.Global // ReSharper disable once MemberCanBePrivate.Global
[JsonIgnore]
public DirectoryInfo Info => info ??= new DirectoryInfo(FullPath); public DirectoryInfo Info => info ??= new DirectoryInfo(FullPath);
[JsonIgnore]
public bool IsSymbolicLink public bool IsSymbolicLink
{ {
get get
@ -21,14 +26,17 @@ public class DirectoryPath : FileSystemPath, IPathObject
/// <summary> /// <summary>
/// Gets a value indicating whether the directory exists. /// Gets a value indicating whether the directory exists.
/// </summary> /// </summary>
[JsonIgnore]
public bool Exists => Info.Exists; public bool Exists => Info.Exists;
/// <inheritdoc/> /// <inheritdoc/>
[JsonIgnore]
public string Name => Info.Name; public string Name => Info.Name;
/// <summary> /// <summary>
/// Get the parent directory. /// Get the parent directory.
/// </summary> /// </summary>
[JsonIgnore]
public DirectoryPath? Parent => Info.Parent == null public DirectoryPath? Parent => Info.Parent == null
? null : new DirectoryPath(Info.Parent); ? null : new DirectoryPath(Info.Parent);

22
StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs

@ -1,15 +1,20 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Text; using System.Text;
using System.Text.Json.Serialization;
using StabilityMatrix.Core.Converters.Json;
namespace StabilityMatrix.Core.Models.FileInterfaces; namespace StabilityMatrix.Core.Models.FileInterfaces;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[JsonConverter(typeof(StringJsonConverter<FilePath>))]
public class FilePath : FileSystemPath, IPathObject public class FilePath : FileSystemPath, IPathObject
{ {
private FileInfo? _info; private FileInfo? _info;
// ReSharper disable once MemberCanBePrivate.Global // ReSharper disable once MemberCanBePrivate.Global
[JsonIgnore]
public FileInfo Info => _info ??= new FileInfo(FullPath); public FileInfo Info => _info ??= new FileInfo(FullPath);
[JsonIgnore]
public bool IsSymbolicLink public bool IsSymbolicLink
{ {
get get
@ -19,13 +24,16 @@ public class FilePath : FileSystemPath, IPathObject
} }
} }
[JsonIgnore]
public bool Exists => Info.Exists; public bool Exists => Info.Exists;
[JsonIgnore]
public string Name => Info.Name; public string Name => Info.Name;
/// <summary> /// <summary>
/// Get the directory of the file. /// Get the directory of the file.
/// </summary> /// </summary>
[JsonIgnore]
public DirectoryPath? Directory public DirectoryPath? Directory
{ {
get get
@ -115,12 +123,22 @@ public class FilePath : FileSystemPath, IPathObject
return File.WriteAllBytesAsync(FullPath, bytes, ct); return File.WriteAllBytesAsync(FullPath, bytes, ct);
} }
/// <summary>
/// Move the file to a directory.
/// </summary>
public FilePath MoveTo(FilePath destinationFile)
{
Info.MoveTo(destinationFile.FullPath, true);
// Return the new path
return destinationFile;
}
/// <summary> /// <summary>
/// Move the file to a directory. /// Move the file to a directory.
/// </summary> /// </summary>
public async Task<FilePath> MoveToAsync(DirectoryPath directory) public async Task<FilePath> MoveToAsync(DirectoryPath directory)
{ {
await Task.Run(() => Info.MoveTo(directory.FullPath)); await Task.Run(() => Info.MoveTo(directory.FullPath)).ConfigureAwait(false);
// Return the new path // Return the new path
return directory.JoinFile(this); return directory.JoinFile(this);
} }
@ -130,7 +148,7 @@ public class FilePath : FileSystemPath, IPathObject
/// </summary> /// </summary>
public async Task<FilePath> MoveToAsync(FilePath destinationFile) public async Task<FilePath> MoveToAsync(FilePath destinationFile)
{ {
await Task.Run(() => Info.MoveTo(destinationFile.FullPath)); await Task.Run(() => Info.MoveTo(destinationFile.FullPath)).ConfigureAwait(false);
// Return the new path // Return the new path
return destinationFile; return destinationFile;
} }

9
StabilityMatrix.Core/Models/IContextAction.cs

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace StabilityMatrix.Core.Models;
[JsonDerivedType(typeof(CivitPostDownloadContextAction), "CivitPostDownload")]
public interface IContextAction
{
object? Context { get; set; }
}

3
StabilityMatrix.Core/Models/Progress/ProgressState.cs

@ -5,5 +5,6 @@ public enum ProgressState
Inactive, Inactive,
Working, Working,
Success, Success,
Failed Failed,
Cancelled
} }

1
StabilityMatrix.Core/Models/Progress/ProgressType.cs

@ -6,4 +6,5 @@ public enum ProgressType
Download, Download,
Extract, Extract,
Update, Update,
Hashing,
} }

317
StabilityMatrix.Core/Models/TrackedDownload.cs

@ -0,0 +1,317 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using AsyncAwaitBestPractices;
using NLog;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Core.Models;
[JsonSerializable(typeof(TrackedDownload))]
public class TrackedDownload
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
[JsonIgnore]
private IDownloadService? downloadService;
[JsonIgnore]
private Task? downloadTask;
[JsonIgnore]
private CancellationTokenSource? downloadCancellationTokenSource;
[JsonIgnore]
private CancellationTokenSource? downloadPauseTokenSource;
[JsonIgnore]
private CancellationTokenSource AggregateCancellationTokenSource =>
CancellationTokenSource.CreateLinkedTokenSource(
downloadCancellationTokenSource?.Token ?? CancellationToken.None,
downloadPauseTokenSource?.Token ?? CancellationToken.None);
public required Guid Id { get; init; }
public required Uri SourceUrl { get; init; }
public Uri? RedirectedUrl { get; init; }
public required DirectoryPath DownloadDirectory { get; init; }
public required string FileName { get; init; }
public required string TempFileName { get; init; }
public string? ExpectedHashSha256 { get; set; }
[JsonIgnore]
[MemberNotNullWhen(true, nameof(ExpectedHashSha256))]
public bool ValidateHash => ExpectedHashSha256 is not null;
[JsonConverter(typeof(JsonStringEnumConverter))]
public ProgressState ProgressState { get; set; } = ProgressState.Inactive;
public List<string> ExtraCleanupFileNames { get; init; } = new();
// Used for restoring progress on load
public long DownloadedBytes { get; set; }
public long TotalBytes { get; set; }
/// <summary>
/// Optional context action to be invoked on completion
/// </summary>
public IContextAction? ContextAction { get; set; }
[JsonIgnore]
public Exception? Exception { get; private set; }
#region Events
private WeakEventManager<ProgressReport>? progressUpdateEventManager;
public event EventHandler<ProgressReport> ProgressUpdate
{
add
{
progressUpdateEventManager ??= new WeakEventManager<ProgressReport>();
progressUpdateEventManager.AddEventHandler(value);
}
remove => progressUpdateEventManager?.RemoveEventHandler(value);
}
protected void OnProgressUpdate(ProgressReport e)
{
// Update downloaded and total bytes
DownloadedBytes = Convert.ToInt64(e.Current);
TotalBytes = Convert.ToInt64(e.Total);
progressUpdateEventManager?.RaiseEvent(this, e, nameof(ProgressUpdate));
}
private WeakEventManager<ProgressState>? progressStateChangedEventManager;
public event EventHandler<ProgressState> ProgressStateChanged
{
add
{
progressStateChangedEventManager ??= new WeakEventManager<ProgressState>();
progressStateChangedEventManager.AddEventHandler(value);
}
remove => progressStateChangedEventManager?.RemoveEventHandler(value);
}
protected void OnProgressStateChanged(ProgressState e)
{
progressStateChangedEventManager?.RaiseEvent(this, e, nameof(ProgressStateChanged));
}
#endregion
[MemberNotNull(nameof(downloadService))]
private void EnsureDownloadService()
{
if (downloadService == null)
{
throw new InvalidOperationException("Download service is not set.");
}
}
private async Task StartDownloadTask(long resumeFromByte, CancellationToken cancellationToken)
{
var progress = new Progress<ProgressReport>(OnProgressUpdate);
await downloadService!.ResumeDownloadToFileAsync(
SourceUrl.ToString(),
DownloadDirectory.JoinFile(TempFileName),
resumeFromByte,
progress,
cancellationToken: cancellationToken).ConfigureAwait(false);
// If hash validation is enabled, validate the hash
if (ValidateHash)
{
OnProgressUpdate(new ProgressReport(0, isIndeterminate: true, type: ProgressType.Hashing));
var hash = await FileHash.GetSha256Async(DownloadDirectory.JoinFile(TempFileName), progress).ConfigureAwait(false);
if (hash != ExpectedHashSha256?.ToLowerInvariant())
{
throw new Exception($"Hash validation for {FileName} failed, expected {ExpectedHashSha256} but got {hash}");
}
}
}
public void Start()
{
if (ProgressState != ProgressState.Inactive)
{
throw new InvalidOperationException($"Download state must be inactive to start, not {ProgressState}");
}
Logger.Debug("Starting download {Download}", FileName);
EnsureDownloadService();
downloadCancellationTokenSource = new CancellationTokenSource();
downloadPauseTokenSource = new CancellationTokenSource();
downloadTask = StartDownloadTask(0, AggregateCancellationTokenSource.Token)
.ContinueWith(OnDownloadTaskCompleted);
ProgressState = ProgressState.Working;
OnProgressStateChanged(ProgressState);
}
public void Resume()
{
if (ProgressState != ProgressState.Inactive)
{
Logger.Warn("Attempted to resume download {Download} but it is not paused ({State})", FileName, ProgressState);
}
Logger.Debug("Resuming download {Download}", FileName);
// Read the temp file to get the current size
var tempSize = 0L;
var tempFile = DownloadDirectory.JoinFile(TempFileName);
if (tempFile.Exists)
{
tempSize = tempFile.Info.Length;
}
EnsureDownloadService();
downloadCancellationTokenSource = new CancellationTokenSource();
downloadPauseTokenSource = new CancellationTokenSource();
downloadTask = StartDownloadTask(tempSize, AggregateCancellationTokenSource.Token)
.ContinueWith(OnDownloadTaskCompleted);
ProgressState = ProgressState.Working;
OnProgressStateChanged(ProgressState);
}
public void Pause()
{
if (ProgressState != ProgressState.Working)
{
Logger.Warn("Attempted to pause download {Download} but it is not in progress ({State})", FileName, ProgressState);
return;
}
Logger.Debug("Pausing download {Download}", FileName);
downloadPauseTokenSource?.Cancel();
}
public void Cancel()
{
if (ProgressState is not (ProgressState.Working or ProgressState.Inactive))
{
Logger.Warn("Attempted to cancel download {Download} but it is not in progress ({State})", FileName, ProgressState);
return;
}
Logger.Debug("Cancelling download {Download}", FileName);
// Cancel token if it exists
if (downloadCancellationTokenSource is { } token)
{
token.Cancel();
}
// Otherwise handle it manually
else
{
DoCleanup();
ProgressState = ProgressState.Cancelled;
OnProgressStateChanged(ProgressState);
}
}
/// <summary>
/// Deletes the temp file and any extra cleanup files
/// </summary>
private void DoCleanup()
{
try
{
DownloadDirectory.JoinFile(TempFileName).Delete();
}
catch (IOException)
{
Logger.Warn("Failed to delete temp file {TempFile}", TempFileName);
}
foreach (var extraFile in ExtraCleanupFileNames)
{
try
{
DownloadDirectory.JoinFile(extraFile).Delete();
}
catch (IOException)
{
Logger.Warn("Failed to delete extra cleanup file {ExtraFile}", extraFile);
}
}
}
/// <summary>
/// Invoked by the task's completion callback
/// </summary>
private void OnDownloadTaskCompleted(Task task)
{
// For cancelled, check if it was actually cancelled or paused
if (task.IsCanceled)
{
// If the task was cancelled, set the state to cancelled
if (downloadCancellationTokenSource?.IsCancellationRequested == true)
{
ProgressState = ProgressState.Cancelled;
}
// If the task was not cancelled, set the state to paused
else if (downloadPauseTokenSource?.IsCancellationRequested == true)
{
ProgressState = ProgressState.Inactive;
}
else
{
throw new InvalidOperationException("Download task was cancelled but neither cancellation token was cancelled.");
}
}
// For faulted
else if (task.IsFaulted)
{
// Set the exception
Exception = task.Exception;
ProgressState = ProgressState.Failed;
}
// Otherwise success
else
{
ProgressState = ProgressState.Success;
}
// For failed or cancelled, delete the temp files
if (ProgressState is ProgressState.Failed or ProgressState.Cancelled)
{
DoCleanup();
}
else if (ProgressState == ProgressState.Success)
{
// Move the temp file to the final file
DownloadDirectory.JoinFile(TempFileName).MoveTo(DownloadDirectory.JoinFile(FileName));
}
// For pause, just do nothing
OnProgressStateChanged(ProgressState);
// Dispose of the task and cancellation token
downloadTask = null;
downloadCancellationTokenSource = null;
downloadPauseTokenSource = null;
}
public void SetDownloadService(IDownloadService service)
{
downloadService = service;
}
}

183
StabilityMatrix.Core/Services/DownloadService.cs

@ -17,43 +17,66 @@ public class DownloadService : IDownloadService
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
} }
public async Task DownloadToFileAsync(string downloadUrl, string downloadPath, public async Task DownloadToFileAsync(
IProgress<ProgressReport>? progress = null, string? httpClientName = null) string downloadUrl,
string downloadPath,
IProgress<ProgressReport>? progress = null,
string? httpClientName = null,
CancellationToken cancellationToken = default
)
{ {
using var client = string.IsNullOrWhiteSpace(httpClientName) using var client = string.IsNullOrWhiteSpace(httpClientName)
? httpClientFactory.CreateClient() ? httpClientFactory.CreateClient()
: httpClientFactory.CreateClient(httpClientName); : httpClientFactory.CreateClient(httpClientName);
client.Timeout = TimeSpan.FromMinutes(10); client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "2.0")); client.DefaultRequestHeaders.UserAgent.Add(
await using var file = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None); new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
await using var file = new FileStream(
downloadPath,
FileMode.Create,
FileAccess.Write,
FileShare.None
);
long contentLength = 0; long contentLength = 0;
var response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); var response = await client
.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
contentLength = response.Content.Headers.ContentLength ?? 0; contentLength = response.Content.Headers.ContentLength ?? 0;
var delays = Backoff.DecorrelatedJitterBackoffV2( var delays = Backoff.DecorrelatedJitterBackoffV2(
TimeSpan.FromMilliseconds(50), retryCount: 3); TimeSpan.FromMilliseconds(50),
retryCount: 3
);
foreach (var delay in delays) foreach (var delay in delays)
{ {
if (contentLength > 0) break; if (contentLength > 0)
break;
logger.LogDebug("Retrying get-headers for content-length"); logger.LogDebug("Retrying get-headers for content-length");
await Task.Delay(delay); await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); response = await client
.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
contentLength = response.Content.Headers.ContentLength ?? 0; contentLength = response.Content.Headers.ContentLength ?? 0;
} }
var isIndeterminate = contentLength == 0; var isIndeterminate = contentLength == 0;
await using var stream = await response.Content.ReadAsStreamAsync(); await using var stream = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
var totalBytesRead = 0L; var totalBytesRead = 0L;
var buffer = new byte[BufferSize]; var buffer = new byte[BufferSize];
while (true) while (true)
{ {
var bytesRead = await stream.ReadAsync(buffer); var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (bytesRead == 0) break; if (bytesRead == 0)
await file.WriteAsync(buffer.AsMemory(0, bytesRead)); break;
await file.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken)
.ConfigureAwait(false);
totalBytesRead += bytesRead; totalBytesRead += bytesRead;
@ -63,13 +86,125 @@ public class DownloadService : IDownloadService
} }
else else
{ {
progress?.Report(new ProgressReport(current: Convert.ToUInt64(totalBytesRead), progress?.Report(
total: Convert.ToUInt64(contentLength), message: "Downloading...")); new ProgressReport(
current: Convert.ToUInt64(totalBytesRead),
total: Convert.ToUInt64(contentLength),
message: "Downloading..."
)
);
} }
} }
await file.FlushAsync(); await file.FlushAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(new ProgressReport(1f, message: "Download complete!"));
}
/// <inheritdoc />
public async Task ResumeDownloadToFileAsync(
string downloadUrl,
string downloadPath,
long existingFileSize,
IProgress<ProgressReport>? progress = null,
string? httpClientName = null,
CancellationToken cancellationToken = default)
{
using var client = string.IsNullOrWhiteSpace(httpClientName)
? httpClientFactory.CreateClient()
: httpClientFactory.CreateClient(httpClientName);
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
// Create file if it doesn't exist
if (!File.Exists(downloadPath))
{
logger.LogInformation("Resume file doesn't exist, creating file {DownloadPath}", downloadPath);
File.Create(downloadPath).Close();
}
await using var file = new FileStream(
downloadPath,
FileMode.Append,
FileAccess.Write,
FileShare.None
);
// Remaining content length
long remainingContentLength = 0;
// Total of the original content
long originalContentLength = 0;
using var request = new HttpRequestMessage();
request.Method = HttpMethod.Get;
request.RequestUri = new Uri(downloadUrl);
request.Headers.Range = new RangeHeaderValue(existingFileSize, null);
HttpResponseMessage? response = null;
foreach (var delay in Backoff.DecorrelatedJitterBackoffV2(
TimeSpan.FromMilliseconds(50),
retryCount: 4
))
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
remainingContentLength = response.Content.Headers.ContentLength ?? 0;
originalContentLength = response.Content.Headers.ContentRange?.Length.GetValueOrDefault() ?? 0;
if (remainingContentLength > 0)
break;
logger.LogDebug("Retrying get-headers for content-length");
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
if (response == null)
{
throw new ApplicationException("Response is null");
}
var isIndeterminate = remainingContentLength == 0;
await using var stream = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
var totalBytesRead = 0L;
var buffer = new byte[BufferSize];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (bytesRead == 0)
break;
await file.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken)
.ConfigureAwait(false);
totalBytesRead += bytesRead;
if (isIndeterminate)
{
progress?.Report(new ProgressReport(-1, isIndeterminate: true));
}
else
{
progress?.Report(
new ProgressReport(
// Report the current as session current + original start size
current: Convert.ToUInt64(totalBytesRead + existingFileSize),
// Total as the original total
total: Convert.ToUInt64(originalContentLength),
message: "Downloading..."
)
);
}
}
await file.FlushAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(new ProgressReport(1f, message: "Download complete!")); progress?.Report(new ProgressReport(1f, message: "Download complete!"));
} }
@ -77,11 +212,13 @@ public class DownloadService : IDownloadService
{ {
using var client = httpClientFactory.CreateClient(); using var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(10); client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "2.0")); client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
try try
{ {
var response = await client.GetAsync(url); var response = await client.GetAsync(url).ConfigureAwait(false);
return await response.Content.ReadAsStreamAsync(); return await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
} }
catch (Exception e) catch (Exception e)
{ {

18
StabilityMatrix.Core/Services/IDownloadService.cs

@ -4,8 +4,22 @@ namespace StabilityMatrix.Core.Services;
public interface IDownloadService public interface IDownloadService
{ {
Task DownloadToFileAsync(string downloadUrl, string downloadPath, Task DownloadToFileAsync(
IProgress<ProgressReport>? progress = null, string? httpClientName = null); string downloadUrl,
string downloadPath,
IProgress<ProgressReport>? progress = null,
string? httpClientName = null,
CancellationToken cancellationToken = default
);
Task ResumeDownloadToFileAsync(
string downloadUrl,
string downloadPath,
long existingFileSize,
IProgress<ProgressReport>? progress = null,
string? httpClientName = null,
CancellationToken cancellationToken = default
);
Task<Stream> GetImageStreamFromUrl(string url); Task<Stream> GetImageStreamFromUrl(string url);
} }

8
StabilityMatrix.Core/Services/ISettingsManager.cs

@ -1,6 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using System.Linq.Expressions; using System.Linq.Expressions;
using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Models.Settings;
namespace StabilityMatrix.Core.Services; namespace StabilityMatrix.Core.Services;
@ -12,10 +13,17 @@ public interface ISettingsManager
bool IsLibraryDirSet { get; } bool IsLibraryDirSet { get; }
string DatabasePath { get; } string DatabasePath { get; }
string ModelsDirectory { get; } string ModelsDirectory { get; }
string DownloadsDirectory { get; }
Settings Settings { get; } Settings Settings { get; }
event EventHandler<string>? LibraryDirChanged; event EventHandler<string>? LibraryDirChanged;
event EventHandler<PropertyChangedEventArgs>? SettingsPropertyChanged; event EventHandler<PropertyChangedEventArgs>? SettingsPropertyChanged;
/// <summary>
/// Register a handler that fires once when LibraryDir is first set.
/// Will fire instantly if it is already set.
/// </summary>
void RegisterOnLibraryDirSet(Action<string> handler);
/// <inheritdoc /> /// <inheritdoc />
SettingsTransaction BeginTransaction(); SettingsTransaction BeginTransaction();

15
StabilityMatrix.Core/Services/ITrackedDownloadService.cs

@ -0,0 +1,15 @@
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
namespace StabilityMatrix.Core.Services;
public interface ITrackedDownloadService
{
IEnumerable<TrackedDownload> Downloads { get; }
event EventHandler<TrackedDownload>? DownloadAdded;
TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath);
TrackedDownload NewDownload(string downloadUrl, FilePath downloadPath) => NewDownload(new Uri(downloadUrl), downloadPath);
}

23
StabilityMatrix.Core/Services/SettingsManager.cs

@ -49,12 +49,33 @@ public class SettingsManager : ISettingsManager
public string DatabasePath => Path.Combine(LibraryDir, "StabilityMatrix.db"); public string DatabasePath => Path.Combine(LibraryDir, "StabilityMatrix.db");
private string SettingsPath => Path.Combine(LibraryDir, "settings.json"); private string SettingsPath => Path.Combine(LibraryDir, "settings.json");
public string ModelsDirectory => Path.Combine(LibraryDir, "Models"); public string ModelsDirectory => Path.Combine(LibraryDir, "Models");
public string DownloadsDirectory => Path.Combine(LibraryDir, ".downloads");
public Settings Settings { get; private set; } = new(); public Settings Settings { get; private set; } = new();
public event EventHandler<string>? LibraryDirChanged; public event EventHandler<string>? LibraryDirChanged;
public event EventHandler<PropertyChangedEventArgs>? SettingsPropertyChanged; public event EventHandler<PropertyChangedEventArgs>? SettingsPropertyChanged;
/// <inheritdoc />
public void RegisterOnLibraryDirSet(Action<string> handler)
{
if (IsLibraryDirSet)
{
handler(LibraryDir);
return;
}
LibraryDirChanged += Handler;
return;
void Handler(object? sender, string dir)
{
LibraryDirChanged -= Handler;
handler(dir);
}
}
/// <inheritdoc /> /// <inheritdoc />
public SettingsTransaction BeginTransaction() public SettingsTransaction BeginTransaction()
{ {

247
StabilityMatrix.Core/Services/TrackedDownloadService.cs

@ -0,0 +1,247 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Core.Services;
public class TrackedDownloadService : ITrackedDownloadService, IDisposable
{
private readonly ILogger<TrackedDownloadService> logger;
private readonly IDownloadService downloadService;
private readonly ISettingsManager settingsManager;
private readonly ConcurrentDictionary<Guid, (TrackedDownload, FileStream)> downloads = new();
public IEnumerable<TrackedDownload> Downloads => downloads.Values.Select(x => x.Item1);
/// <inheritdoc />
public event EventHandler<TrackedDownload>? DownloadAdded;
public TrackedDownloadService(
ILogger<TrackedDownloadService> logger,
IDownloadService downloadService,
ISettingsManager settingsManager)
{
this.logger = logger;
this.downloadService = downloadService;
this.settingsManager = settingsManager;
// Index for in-progress downloads when library dir loaded
settingsManager.RegisterOnLibraryDirSet(path =>
{
var downloadsDir = new DirectoryPath(settingsManager.DownloadsDirectory);
// Ignore if not exist
if (!downloadsDir.Exists) return;
LoadInProgressDownloads(downloadsDir);
});
}
private void OnDownloadAdded(TrackedDownload download)
{
DownloadAdded?.Invoke(this, download);
}
/// <summary>
/// Creates a new tracked download with backed json file and adds it to the dictionary.
/// </summary>
/// <param name="download"></param>
private void AddDownload(TrackedDownload download)
{
// Set download service
download.SetDownloadService(downloadService);
// Create json file
var downloadsDir = new DirectoryPath(settingsManager.DownloadsDirectory);
downloadsDir.Create();
var jsonFile = downloadsDir.JoinFile($"{download.Id}.json");
var jsonFileStream = jsonFile.Info.Open(FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read);
// Serialize to json
var json = JsonSerializer.Serialize(download);
jsonFileStream.Write(Encoding.UTF8.GetBytes(json));
jsonFileStream.Flush();
// Add to dictionary
downloads.TryAdd(download.Id, (download, jsonFileStream));
// Connect to state changed event to update json file
AttachHandlers(download);
logger.LogDebug("Added download {Download}", download.FileName);
OnDownloadAdded(download);
}
/// <summary>
/// Update the json file for the download.
/// </summary>
private void UpdateJsonForDownload(TrackedDownload download)
{
// Serialize to json
var json = JsonSerializer.Serialize(download);
var jsonBytes = Encoding.UTF8.GetBytes(json);
// Write to file
var (_, fs) = downloads[download.Id];
fs.Seek(0, SeekOrigin.Begin);
fs.Write(jsonBytes);
fs.Flush();
}
private void AttachHandlers(TrackedDownload download)
{
download.ProgressStateChanged += TrackedDownload_OnProgressStateChanged;
}
/// <summary>
/// Handler when the download's state changes
/// </summary>
private void TrackedDownload_OnProgressStateChanged(object? sender, ProgressState e)
{
if (sender is not TrackedDownload download)
{
return;
}
// Update json file
UpdateJsonForDownload(download);
// If the download is completed, remove it from the dictionary and delete the json file
if (e is ProgressState.Success or ProgressState.Failed or ProgressState.Cancelled)
{
if (downloads.TryRemove(download.Id, out var downloadInfo))
{
downloadInfo.Item2.Dispose();
// Delete json file
new DirectoryPath(settingsManager.DownloadsDirectory).JoinFile($"{download.Id}.json").Delete();
logger.LogDebug("Removed download {Download}", download.FileName);
}
}
// On successes, run the continuation action
if (e == ProgressState.Success)
{
if (download.ContextAction is CivitPostDownloadContextAction action)
{
logger.LogDebug("Running context action for {Download}", download.FileName);
action.Invoke(settingsManager);
}
}
}
private void LoadInProgressDownloads(DirectoryPath downloadsDir)
{
logger.LogDebug("Indexing in-progress downloads at {DownloadsDir}...", downloadsDir);
var jsonFiles = downloadsDir.Info.EnumerateFiles("*.json", SearchOption.TopDirectoryOnly);
// Add to dictionary, the file name is the guid
foreach (var file in jsonFiles)
{
// Try to get a shared write handle
try
{
var fileStream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.Read);
// Deserialize json and add to dictionary
var download = JsonSerializer.Deserialize<TrackedDownload>(fileStream)!;
// If the download is marked as working, pause it
if (download.ProgressState == ProgressState.Working)
{
download.ProgressState = ProgressState.Inactive;
}
else if (download.ProgressState != ProgressState.Inactive)
{
// If the download is not inactive, skip it
logger.LogWarning("Skipping download {Download} with state {State}", download.FileName, download.ProgressState);
fileStream.Dispose();
// Delete json file
logger.LogDebug("Deleting json file for {Download} with unsupported state", download.FileName);
file.Delete();
continue;
}
download.SetDownloadService(downloadService);
downloads.TryAdd(download.Id, (download, fileStream));
AttachHandlers(download);
OnDownloadAdded(download);
logger.LogDebug("Loaded in-progress download {Download}", download.FileName);
}
catch (Exception e)
{
logger.LogInformation(e, "Could not open file {File} for reading", file.Name);
}
}
}
public TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath)
{
var download = new TrackedDownload
{
Id = Guid.NewGuid(),
SourceUrl = downloadUrl,
DownloadDirectory = downloadPath.Directory!,
FileName = downloadPath.Name,
TempFileName = NewTempFileName(downloadPath.Directory!),
};
AddDownload(download);
return download;
}
/// <summary>
/// Generate a new temp file name that is unique in the given directory.
/// In format of "Unconfirmed {id}.smdownload"
/// </summary>
/// <param name="parentDir"></param>
/// <returns></returns>
private static string NewTempFileName(DirectoryPath parentDir)
{
FilePath? tempFile = null;
for (var i = 0; i < 10; i++)
{
if (tempFile is {Exists: false})
{
return tempFile.Name;
}
var id = Random.Shared.Next(1000000, 9999999);
tempFile = parentDir.JoinFile($"Unconfirmed {id}.smdownload");
}
throw new Exception("Failed to generate a unique temp file name.");
}
/// <inheritdoc />
public void Dispose()
{
foreach (var (download, fs) in downloads.Values)
{
if (download.ProgressState == ProgressState.Working)
{
try
{
download.Pause();
}
catch (Exception e)
{
logger.LogWarning(e, "Failed to pause download {Download}", download.FileName);
}
}
}
GC.SuppressFinalize(this);
}
}
Loading…
Cancel
Save