mirror of
				https://github.com/chylex/Discord-History-Tracker.git
				synced 2025-10-31 11:17:15 +01:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			1700f99bf7
			...
			e1eae393c3
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e1eae393c3 | |||
| fbed74529d | |||
| 8924aa7c06 | |||
| 9738880ba7 | 
| @@ -15,14 +15,14 @@ | ||||
|   </PropertyGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Avalonia" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Desktop" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " /> | ||||
|     <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" /> | ||||
|     <PackageReference Include="Avalonia" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Desktop" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Diagnostics" Version="11.0.11" Condition=" '$(Configuration)' == 'Debug' " /> | ||||
|     <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.11" /> | ||||
|     <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.11" /> | ||||
|     <PackageReference Include="CommunityToolkit.Mvvm" Version="999.0.0-build.0.g0d941a6a62" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.ComponentModel; | ||||
| using System.Linq; | ||||
| using System.Reactive.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.ReactiveUI; | ||||
| @@ -8,13 +9,17 @@ using CommunityToolkit.Mvvm.ComponentModel; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Tasks; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Controls; | ||||
|  | ||||
| sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposable { | ||||
| sealed partial class DownloadItemFilterPanelModel : ObservableObject, IAsyncDisposable { | ||||
| 	private static readonly Log Log = Log.ForType<DownloadItemFilterPanelModel>(); | ||||
| 	 | ||||
| 	public sealed record Unit(string Name, uint Scale); | ||||
|  | ||||
| 	 | ||||
| 	private static readonly Unit[] AllUnits = [ | ||||
| 		new Unit("B", 1), | ||||
| 		new Unit("kB", 1024), | ||||
| @@ -33,7 +38,7 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl | ||||
| 	private bool limitSize = false; | ||||
| 	 | ||||
| 	[ObservableProperty] | ||||
| 	private ulong maximumSize = 0L; | ||||
| 	private ulong maximumSize = 0UL; | ||||
| 	 | ||||
| 	[ObservableProperty] | ||||
| 	private Unit maximumSizeUnit = AllUnits[0]; | ||||
| @@ -43,6 +48,9 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl | ||||
| 	private readonly State state; | ||||
| 	private readonly string verb; | ||||
|  | ||||
| 	private readonly DelayedThrottledTask<FilterSettings> saveFilterSettingsTask; | ||||
| 	private bool isLoadingFilterSettings; | ||||
|  | ||||
| 	private readonly RestartableTask<long> downloadItemCountTask; | ||||
| 	private long? matchingItemCount; | ||||
| 	 | ||||
| @@ -56,6 +64,8 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl | ||||
| 		this.state = state; | ||||
| 		this.verb = verb; | ||||
|  | ||||
| 		this.saveFilterSettingsTask = new DelayedThrottledTask<FilterSettings>(Log, TimeSpan.FromSeconds(5), SaveFilterSettings); | ||||
|  | ||||
| 		this.downloadItemCountTask = new RestartableTask<long>(SetMatchingCount, TaskScheduler.FromCurrentSynchronizationContext()); | ||||
| 		this.downloadItemCountSubscription = state.Db.Downloads.TotalCount.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnDownloadItemCountChanged); | ||||
|  | ||||
| @@ -64,13 +74,51 @@ sealed partial class DownloadItemFilterPanelModel : ObservableObject, IDisposabl | ||||
| 		PropertyChanged += OnPropertyChanged; | ||||
| 	} | ||||
|  | ||||
| 	public void Dispose() { | ||||
| 	public async Task Initialize() { | ||||
| 		isLoadingFilterSettings = true; | ||||
| 		 | ||||
| 		LimitSize = await state.Db.Settings.Get(SettingsKey.DownloadsLimitSize, LimitSize); | ||||
| 		MaximumSize = await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSize, MaximumSize); | ||||
|  | ||||
| 		if (await state.Db.Settings.Get(SettingsKey.DownloadsMaximumSizeUnit, null) is {} unitName && AllUnits.FirstOrDefault(unit => unit.Name == unitName) is {} unitValue) { | ||||
| 			MaximumSizeUnit = unitValue; | ||||
| 		} | ||||
| 		 | ||||
| 		isLoadingFilterSettings = false; | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		saveFilterSettingsTask.Dispose(); | ||||
| 		 | ||||
| 		downloadItemCountTask.Cancel(); | ||||
| 		downloadItemCountSubscription.Dispose(); | ||||
| 		 | ||||
| 		await SaveFilterSettings(new FilterSettings(this)); | ||||
| 	} | ||||
| 	 | ||||
| 	private sealed record FilterSettings(bool LimitSize, ulong MaximumSize, Unit MaximumSizeUnit) { | ||||
| 		public FilterSettings(DownloadItemFilterPanelModel model) : this(model.LimitSize, model.MaximumSize, model.MaximumSizeUnit) {} | ||||
| 	} | ||||
| 	 | ||||
| 	private async Task SaveFilterSettings(FilterSettings settings) { | ||||
| 		try { | ||||
| 			await state.Db.Settings.Set(async setter => { | ||||
| 				await setter.Set(SettingsKey.DownloadsLimitSize, settings.LimitSize); | ||||
| 				await setter.Set(SettingsKey.DownloadsMaximumSize, settings.MaximumSize); | ||||
| 				await setter.Set(SettingsKey.DownloadsMaximumSizeUnit, settings.MaximumSizeUnit.Name); | ||||
| 			}); | ||||
| 		} catch (Exception e) { | ||||
| 			Log.Error("Could not save download filter settings"); | ||||
| 			Log.Error(e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { | ||||
| 		if (e.PropertyName != null && FilterProperties.Contains(e.PropertyName)) { | ||||
| 			if (!isLoadingFilterSettings) { | ||||
| 				saveFilterSettingsTask.Post(new FilterSettings(this)); | ||||
| 			} | ||||
| 			 | ||||
| 			UpdateFilterStatistics(); | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -95,6 +95,8 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable { | ||||
| 		mainContentScreenModel = new MainContentScreenModel(window, state); | ||||
| 		mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed; | ||||
| 		 | ||||
| 		await mainContentScreenModel.Initialize(); | ||||
| 		 | ||||
| 		Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle; | ||||
| 		CurrentScreen = new MainContentScreen { DataContext = mainContentScreenModel }; | ||||
|  | ||||
| @@ -104,7 +106,7 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable { | ||||
| 	private async void MainContentScreenModelOnDatabaseClosed(object? sender, EventArgs e) { | ||||
| 		if (mainContentScreenModel != null) { | ||||
| 			mainContentScreenModel.DatabaseClosed -= MainContentScreenModelOnDatabaseClosed; | ||||
| 			mainContentScreenModel.Dispose(); | ||||
| 			await mainContentScreenModel.DisposeAsync(); | ||||
| 			mainContentScreenModel = null; | ||||
| 		} | ||||
|  | ||||
| @@ -124,7 +126,10 @@ sealed partial class MainWindowModel : ObservableObject, IAsyncDisposable { | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		mainContentScreenModel?.Dispose(); | ||||
| 		if (mainContentScreenModel != null) { | ||||
| 			await mainContentScreenModel.DisposeAsync(); | ||||
| 		} | ||||
| 		 | ||||
| 		await DisposeState(); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -31,8 +31,14 @@ | ||||
|     </UserControl.Styles> | ||||
|  | ||||
|     <StackPanel Orientation="Vertical" Spacing="20"> | ||||
|         <Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" /> | ||||
|         <StackPanel Orientation="Horizontal" Spacing="10"> | ||||
|             <Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" /> | ||||
|             <Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button> | ||||
|         </StackPanel> | ||||
|         <controls:DownloadItemFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !$parent[UserControl].((pages:DownloadsPageModel)DataContext).IsDownloading}" /> | ||||
|         <TextBlock TextWrapping="Wrap"> | ||||
|             Downloading state and filter settings are remembered per-database. | ||||
|         </TextBlock> | ||||
|         <StackPanel Orientation="Vertical" Spacing="12"> | ||||
|             <Expander Header="Download Status" IsExpanded="True"> | ||||
|                 <DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True"> | ||||
| @@ -43,9 +49,6 @@ | ||||
|                     </DataGrid.Columns> | ||||
|                 </DataGrid> | ||||
|             </Expander> | ||||
|             <StackPanel Orientation="Horizontal" Spacing="10"> | ||||
|                 <Button Command="{Binding OnClickRetryFailedDownloads}" IsEnabled="{Binding IsRetryFailedOnDownloadsButtonEnabled}">Retry Failed Downloads</Button> | ||||
|             </StackPanel> | ||||
|         </StackPanel> | ||||
|     </StackPanel> | ||||
| </UserControl> | ||||
|   | ||||
| @@ -9,13 +9,14 @@ using DHT.Desktop.Main.Controls; | ||||
| using DHT.Server; | ||||
| using DHT.Server.Data.Aggregations; | ||||
| using DHT.Server.Data.Filters; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Server.Download; | ||||
| using DHT.Utils.Logging; | ||||
| using DHT.Utils.Tasks; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Pages; | ||||
|  | ||||
| sealed partial class DownloadsPageModel : ObservableObject, IDisposable { | ||||
| sealed partial class DownloadsPageModel : ObservableObject, IAsyncDisposable { | ||||
| 	private static readonly Log Log = Log.ForType<DownloadsPageModel>(); | ||||
|  | ||||
| 	[ObservableProperty(Setter = Access.Private)] | ||||
| @@ -73,14 +74,22 @@ sealed partial class DownloadsPageModel : ObservableObject, IDisposable { | ||||
|  | ||||
| 		RecomputeDownloadStatistics(); | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task Initialize() { | ||||
| 		await FilterModel.Initialize(); | ||||
| 		 | ||||
| 		if (await state.Db.Settings.Get(SettingsKey.DownloadsAutoStart, false)) { | ||||
| 			await StartDownload(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public void Dispose() { | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		finishedItemsSubscription?.Dispose(); | ||||
| 		 | ||||
| 		downloadItemCountSubscription.Dispose(); | ||||
| 		downloadStatisticsTask.Dispose(); | ||||
|  | ||||
| 		FilterModel.Dispose(); | ||||
| 		await FilterModel.DisposeAsync(); | ||||
| 	} | ||||
|  | ||||
| 	private void OnDownloadCountChanged(long newDownloadCount) { | ||||
| @@ -91,26 +100,41 @@ sealed partial class DownloadsPageModel : ObservableObject, IDisposable { | ||||
| 		IsToggleDownloadButtonEnabled = false; | ||||
|  | ||||
| 		if (IsDownloading) { | ||||
| 			await state.Downloader.Stop(); | ||||
| 			await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
|  | ||||
| 			finishedItemsSubscription?.Dispose(); | ||||
| 			finishedItemsSubscription = null; | ||||
| 			 | ||||
| 			currentDownloadFilter = null; | ||||
| 			await StopDownload(); | ||||
| 		} | ||||
| 		else { | ||||
| 			await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
| 			 | ||||
| 			var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter()); | ||||
| 			finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||
| 			await StartDownload(); | ||||
| 		} | ||||
|  | ||||
| 		await state.Db.Settings.Set(SettingsKey.DownloadsAutoStart, IsDownloading); | ||||
| 		IsToggleDownloadButtonEnabled = true; | ||||
| 	} | ||||
|  | ||||
| 	private async Task StartDownload() { | ||||
| 		await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
| 		 | ||||
| 		var finishedItems = await state.Downloader.Start(currentDownloadFilter = FilterModel.CreateFilter()); | ||||
| 		finishedItemsSubscription = finishedItems.ObserveOn(AvaloniaScheduler.Instance).Subscribe(OnItemFinished); | ||||
| 		 | ||||
| 		OnDownloadStateChanged(); | ||||
| 	} | ||||
|  | ||||
| 	private async Task StopDownload() { | ||||
| 		await state.Downloader.Stop(); | ||||
| 		await state.Db.Downloads.MoveDownloadingItemsBackToQueue(); | ||||
|  | ||||
| 		finishedItemsSubscription?.Dispose(); | ||||
| 		finishedItemsSubscription = null; | ||||
| 			 | ||||
| 		currentDownloadFilter = null; | ||||
| 		OnDownloadStateChanged(); | ||||
| 	} | ||||
|  | ||||
| 	private void OnDownloadStateChanged() { | ||||
| 		RecomputeDownloadStatistics(); | ||||
|  | ||||
| 		OnPropertyChanged(nameof(ToggleDownloadButtonText)); | ||||
| 		OnPropertyChanged(nameof(IsDownloading)); | ||||
| 		IsToggleDownloadButtonEnabled = true; | ||||
| 	} | ||||
|  | ||||
| 	private void OnItemFinished(DownloadItem item) { | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System; | ||||
| using System.Threading.Tasks; | ||||
| using Avalonia.Controls; | ||||
| using DHT.Desktop.Main.Controls; | ||||
| using DHT.Desktop.Main.Pages; | ||||
| @@ -6,7 +7,7 @@ using DHT.Server; | ||||
|  | ||||
| namespace DHT.Desktop.Main.Screens; | ||||
|  | ||||
| sealed class MainContentScreenModel : IDisposable { | ||||
| sealed class MainContentScreenModel : IAsyncDisposable { | ||||
| 	public DatabasePage DatabasePage { get; } | ||||
| 	private DatabasePageModel DatabasePageModel { get; } | ||||
|  | ||||
| @@ -70,9 +71,13 @@ sealed class MainContentScreenModel : IDisposable { | ||||
|  | ||||
| 		StatusBarModel = new StatusBarModel(state); | ||||
| 	} | ||||
| 	 | ||||
| 	public async Task Initialize() { | ||||
| 		await DownloadsPageModel.Initialize(); | ||||
| 	} | ||||
|  | ||||
| 	public void Dispose() { | ||||
| 		DownloadsPageModel.Dispose(); | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| 		await DownloadsPageModel.DisposeAsync(); | ||||
| 		ViewerPageModel.Dispose(); | ||||
| 		AdvancedPageModel.Dispose(); | ||||
| 		StatusBarModel.Dispose(); | ||||
|   | ||||
| @@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.ComponentModel; | ||||
| using DHT.Desktop.Common; | ||||
| using DHT.Desktop.Dialogs.Message; | ||||
| using DHT.Desktop.Dialogs.Progress; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Server.Database; | ||||
| using DHT.Server.Database.Sqlite.Schema; | ||||
|  | ||||
| @@ -46,10 +47,18 @@ sealed partial class WelcomeScreenModel : ObservableObject { | ||||
| 	public async Task OpenOrCreateDatabaseFromPath(string path) { | ||||
| 		dbFilePath = path; | ||||
| 		 | ||||
| 		bool isNew = !File.Exists(path); | ||||
| 		 | ||||
| 		var db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window)); | ||||
| 		if (db != null) { | ||||
| 			DatabaseSelected?.Invoke(this, db); | ||||
| 		if (db == null) { | ||||
| 			return; | ||||
| 		} | ||||
| 		 | ||||
| 		if (isNew && await Dialog.ShowYesNo(window, "Automatic Downloads", "Do you want to automatically download files hosted on Discord? You can change this later in the Downloads tab.") == DialogResult.YesNo.Yes) { | ||||
| 			await db.Settings.Set(SettingsKey.DownloadsAutoStart, true); | ||||
| 		} | ||||
| 		 | ||||
| 		DatabaseSelected?.Invoke(this, db); | ||||
| 	} | ||||
|  | ||||
| 	private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks { | ||||
|   | ||||
							
								
								
									
										60
									
								
								app/Server/Data/Settings/SettingsKey.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								app/Server/Data/Settings/SettingsKey.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
|  | ||||
| namespace DHT.Server.Data.Settings; | ||||
|  | ||||
| public static class SettingsKey { | ||||
| 	public static Bool DownloadsAutoStart { get; } = new ("downloads_auto_start"); | ||||
| 	public static Bool DownloadsLimitSize { get; } = new ("downloads_limit_size"); | ||||
| 	public static UnsignedLong DownloadsMaximumSize { get; } = new ("downloads_maximum_size"); | ||||
| 	public static String DownloadsMaximumSizeUnit { get; } = new ("downloads_maximum_size_unit"); | ||||
|  | ||||
| 	public sealed class String(string key) : SettingsKey<string>(key) { | ||||
| 		internal override bool FromString(string value, out string result) { | ||||
| 			result = value; | ||||
| 			return true; | ||||
| 		} | ||||
|  | ||||
| 		internal override string ToString(string value) { | ||||
| 			return value; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed class Bool(string key) : SettingsKey<bool>(key) { | ||||
| 		internal override bool FromString(string value, out bool result) { | ||||
| 			switch (value) { | ||||
| 				case "1": | ||||
| 					result = true; | ||||
| 					return true; | ||||
|  | ||||
| 				case "0": | ||||
| 					result = false; | ||||
| 					return true; | ||||
|  | ||||
| 				default: | ||||
| 					result = false; | ||||
| 					return false; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		internal override string ToString(bool value) { | ||||
| 			return value ? "1" : "0"; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed class UnsignedLong(string key) : SettingsKey<ulong>(key) { | ||||
| 		internal override bool FromString(string value, out ulong result) { | ||||
| 			return ulong.TryParse(value, out result); | ||||
| 		} | ||||
|  | ||||
| 		internal override string ToString(ulong value) { | ||||
| 			return value.ToString(); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| public abstract class SettingsKey<T>(string key) { | ||||
| 	internal string Key => key; | ||||
|  | ||||
| 	internal abstract bool FromString(string value, [NotNullWhen(true)] out T result); | ||||
| 	internal abstract string ToString(T value); | ||||
| } | ||||
| @@ -10,6 +10,7 @@ sealed class DummyDatabaseFile : IDatabaseFile { | ||||
|  | ||||
| 	public string Path => ""; | ||||
| 	 | ||||
| 	public ISettingsRepository Settings { get; } = new ISettingsRepository.Dummy(); | ||||
| 	public IUserRepository Users { get; } = new IUserRepository.Dummy(); | ||||
| 	public IServerRepository Servers { get; } = new IServerRepository.Dummy(); | ||||
| 	public IChannelRepository Channels { get; } = new IChannelRepository.Dummy(); | ||||
|   | ||||
| @@ -7,6 +7,7 @@ namespace DHT.Server.Database; | ||||
| public interface IDatabaseFile : IAsyncDisposable { | ||||
| 	string Path { get; } | ||||
|  | ||||
| 	ISettingsRepository Settings { get; } | ||||
| 	IUserRepository Users { get; } | ||||
| 	IServerRepository Servers { get; } | ||||
| 	IChannelRepository Channels { get; } | ||||
|   | ||||
							
								
								
									
										31
									
								
								app/Server/Database/Repositories/ISettingsRepository.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/Server/Database/Repositories/ISettingsRepository.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| using System; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data.Settings; | ||||
|  | ||||
| namespace DHT.Server.Database.Repositories; | ||||
|  | ||||
| public interface ISettingsRepository { | ||||
| 	Task Set<T>(SettingsKey<T> key, T value); | ||||
| 	 | ||||
| 	Task Set(Func<ISetter, Task> setter); | ||||
| 	 | ||||
| 	Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue); | ||||
| 	 | ||||
| 	interface ISetter { | ||||
| 		Task Set<T>(SettingsKey<T> key, T value); | ||||
| 	} | ||||
| 	 | ||||
| 	internal sealed class Dummy : ISettingsRepository { | ||||
| 		public Task Set<T>(SettingsKey<T> key, T value) { | ||||
| 			return Task.CompletedTask; | ||||
| 		} | ||||
|  | ||||
| 		public Task Set(Func<ISetter, Task> setter) { | ||||
| 			return Task.CompletedTask; | ||||
| 		} | ||||
|  | ||||
| 		public Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) { | ||||
| 			return Task.FromResult(defaultValue); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,58 @@ | ||||
| using System; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Server.Data.Settings; | ||||
| using DHT.Server.Database.Repositories; | ||||
| using DHT.Server.Database.Sqlite.Utils; | ||||
| using Microsoft.Data.Sqlite; | ||||
|  | ||||
| namespace DHT.Server.Database.Sqlite.Repositories; | ||||
|  | ||||
| sealed class SqliteSettingsRepository(SqliteConnectionPool pool) : ISettingsRepository { | ||||
| 	public Task Set<T>(SettingsKey<T> key, T value) { | ||||
| 		return Set(setter => setter.Set(key, value)); | ||||
| 	} | ||||
|  | ||||
| 	public async Task Set(Func<ISettingsRepository.ISetter, Task> setter) { | ||||
| 		await using var conn = await pool.Take(); | ||||
| 		await conn.BeginTransactionAsync(); | ||||
| 		 | ||||
| 		await using var cmd = conn.Command( | ||||
| 			""" | ||||
| 			INSERT INTO metadata (key, value) | ||||
| 			VALUES (:key, :value) | ||||
| 			ON CONFLICT (key) | ||||
| 			DO UPDATE SET value = excluded.value | ||||
| 			""" | ||||
| 		); | ||||
|  | ||||
| 		cmd.Add(":key", SqliteType.Text); | ||||
| 		cmd.Add(":value", SqliteType.Text); | ||||
| 		 | ||||
| 		await setter(new Setter(cmd)); | ||||
|  | ||||
| 		await cmd.ExecuteNonQueryAsync(); | ||||
| 		await conn.CommitTransactionAsync(); | ||||
| 	} | ||||
| 	 | ||||
| 	private sealed class Setter(SqliteCommand cmd) : ISettingsRepository.ISetter { | ||||
| 		public async Task Set<T>(SettingsKey<T> key, T value) { | ||||
| 			cmd.Set(":key", key.Key); | ||||
| 			cmd.Set(":value", key.ToString(value)); | ||||
| 			await cmd.ExecuteNonQueryAsync(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async Task<T?> Get<T>(SettingsKey<T> key, T? defaultValue) { | ||||
| 		string? value; | ||||
| 		 | ||||
| 		await using (var conn = await pool.Take()) { | ||||
| 			await using var cmd = conn.Command("SELECT value FROM metadata WHERE key = :key"); | ||||
| 			cmd.AddAndSet(":key", SqliteType.Text, key.Key); | ||||
|  | ||||
| 			await using var reader = await cmd.ExecuteReaderAsync(); | ||||
| 			value = await reader.ReadAsync() ? reader.GetString(0) : null; | ||||
| 		} | ||||
|  | ||||
| 		return value != null && key.FromString(value, out var convertedValue) ? convertedValue : defaultValue; | ||||
| 	} | ||||
| } | ||||
| @@ -39,6 +39,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile { | ||||
|  | ||||
| 	public string Path { get; } | ||||
| 	 | ||||
| 	public ISettingsRepository Settings => settings; | ||||
| 	public IUserRepository Users => users; | ||||
| 	public IServerRepository Servers => servers; | ||||
| 	public IChannelRepository Channels => channels; | ||||
| @@ -47,6 +48,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile { | ||||
| 	 | ||||
| 	private readonly SqliteConnectionPool pool; | ||||
| 	 | ||||
| 	private readonly SqliteSettingsRepository settings; | ||||
| 	private readonly SqliteUserRepository users; | ||||
| 	private readonly SqliteServerRepository servers; | ||||
| 	private readonly SqliteChannelRepository channels; | ||||
| @@ -58,6 +60,7 @@ public sealed class SqliteDatabaseFile : IDatabaseFile { | ||||
| 		this.pool = pool; | ||||
|  | ||||
| 		downloads = new SqliteDownloadRepository(pool); | ||||
| 		settings = new SqliteSettingsRepository(pool); | ||||
| 		users = new SqliteUserRepository(pool, downloads); | ||||
| 		servers = new SqliteServerRepository(pool); | ||||
| 		channels = new SqliteChannelRepository(pool); | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
|   </ItemGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.7" /> | ||||
|     <PackageReference Include="System.Linq.Async" Version="6.0.1" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
|   | ||||
							
								
								
									
										61
									
								
								app/Utils/Tasks/DelayedThrottledTask.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/Utils/Tasks/DelayedThrottledTask.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Channels; | ||||
| using System.Threading.Tasks; | ||||
| using DHT.Utils.Logging; | ||||
|  | ||||
| namespace DHT.Utils.Tasks; | ||||
|  | ||||
| public sealed class DelayedThrottledTask<T> : IDisposable { | ||||
| 	private readonly Channel<T> taskChannel = Channel.CreateBounded<T>(new BoundedChannelOptions(capacity: 1) { | ||||
| 		SingleReader = true, | ||||
| 		SingleWriter = false, | ||||
| 		AllowSynchronousContinuations = false, | ||||
| 		FullMode = BoundedChannelFullMode.DropOldest | ||||
| 	}); | ||||
|  | ||||
| 	private readonly CancellationTokenSource cancellationTokenSource = new (); | ||||
| 	private readonly Log log; | ||||
| 	private readonly TimeSpan delay; | ||||
| 	private readonly Func<T, Task> inputProcessor; | ||||
|  | ||||
| 	public DelayedThrottledTask(Log log, TimeSpan delay, Func<T, Task> inputProcessor) { | ||||
| 		this.log = log; | ||||
| 		this.delay = delay; | ||||
| 		this.inputProcessor = inputProcessor; | ||||
|  | ||||
| 		Task.Run(ReaderTask); | ||||
| 	} | ||||
|  | ||||
| 	private async Task ReaderTask() { | ||||
| 		var cancellationToken = cancellationTokenSource.Token; | ||||
|  | ||||
| 		try { | ||||
| 			while (await taskChannel.Reader.WaitToReadAsync(cancellationToken)) { | ||||
| 				await Task.Delay(delay, cancellationToken); | ||||
|  | ||||
| 				T input = await taskChannel.Reader.ReadAsync(cancellationToken); | ||||
| 				try { | ||||
| 					await inputProcessor(input); | ||||
| 				} catch (OperationCanceledException) { | ||||
| 					throw; | ||||
| 				} catch (Exception e) { | ||||
| 					log.Error("Caught exception in task: " + e); | ||||
| 				} | ||||
| 			} | ||||
| 		} catch (OperationCanceledException) { | ||||
| 			// Ignore. | ||||
| 		} finally { | ||||
| 			cancellationTokenSource.Dispose(); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public void Post(T input) { | ||||
| 		taskChannel.Writer.TryWrite(input); | ||||
| 	} | ||||
|  | ||||
| 	public void Dispose() { | ||||
| 		taskChannel.Writer.Complete(); | ||||
| 		cancellationTokenSource.Cancel(); | ||||
| 	} | ||||
| } | ||||
		Reference in New Issue
	
	Block a user