1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2024-11-25 14:42:44 +01:00

Compare commits

...

2 Commits

Author SHA1 Message Date
031d521402
Convert attachment download events to reactive 2023-12-28 17:33:30 +01:00
0131f8cb50
Refactor integrated server management 2023-12-28 17:33:30 +01:00
18 changed files with 271 additions and 328 deletions

View File

@ -21,6 +21,7 @@
<PackageReference Include="Avalonia.Desktop" 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.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" /> <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.Themes.Fluent" Version="11.0.6" />
</ItemGroup> </ItemGroup>

View File

@ -1,14 +1,19 @@
using System; using System;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Server; using DHT.Server;
using DHT.Server.Service; using DHT.Server.Service;
using DHT.Utils.Logging;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable { sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
private string inputPort; private string inputPort;
public string InputPort { public string InputPort {
@ -29,7 +34,7 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
} }
} }
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken; public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
private bool isToggleServerButtonEnabled = true; private bool isToggleServerButtonEnabled = true;
@ -38,59 +43,69 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
set => Change(ref isToggleServerButtonEnabled, value); set => Change(ref isToggleServerButtonEnabled, value);
} }
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server"; public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
private readonly Window window; private readonly Window window;
private readonly ServerManager serverManager; private readonly ServerManager server;
[Obsolete("Designer")] [Obsolete("Designer")]
public ServerConfigurationPanelModel() : this(null!, new ServerManager(State.Dummy)) {} public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) { public ServerConfigurationPanelModel(Window window, State state) {
this.window = window; this.window = window;
this.serverManager = serverManager; this.server = state.Server;
this.inputPort = ServerManager.Port.ToString(); this.inputPort = ServerConfiguration.Port.ToString();
this.inputToken = ServerManager.Token; this.inputToken = ServerConfiguration.Token;
}
public void Initialize() { server.StatusChanged += OnServerStatusChanged;
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
} }
public void Dispose() { public void Dispose() {
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged; server.StatusChanged -= OnServerStatusChanged;
} }
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) { private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped); Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
}
private void UpdateServerStatus() {
OnPropertyChanged(nameof(ToggleServerButtonText)); OnPropertyChanged(nameof(ToggleServerButtonText));
}
private async Task StartServer() {
IsToggleServerButtonEnabled = false;
try {
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
}
UpdateServerStatus();
IsToggleServerButtonEnabled = true; IsToggleServerButtonEnabled = true;
} }
private void BeforeServerStart() { private async Task StopServer() {
IsToggleServerButtonEnabled = false; IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
try {
await server.Stop();
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
}
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
} }
private void StartServer() { public async Task OnClickToggleServerButton() {
BeforeServerStart(); if (server.IsRunning) {
serverManager.Launch(); await StopServer();
}
private void StopServer() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
serverManager.Stop();
}
public void OnClickToggleServerButton() {
if (serverManager.IsRunning) {
StopServer();
} }
else { else {
StartServer(); await StartServer();
} }
} }
@ -98,19 +113,21 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
InputToken = ServerUtils.GenerateRandomToken(20); InputToken = ServerUtils.GenerateRandomToken(20);
} }
public async void OnClickApplyChanges() { public async Task OnClickApplyChanges() {
if (!ushort.TryParse(InputPort, out ushort port)) { if (!ushort.TryParse(InputPort, out ushort port)) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535."); await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return; return;
} }
BeforeServerStart(); ServerConfiguration.Port = port;
serverManager.Relaunch(port, InputToken); ServerConfiguration.Token = inputToken;
await StartServer();
OnPropertyChanged(nameof(HasMadeChanges)); OnPropertyChanged(nameof(HasMadeChanges));
} }
public void OnClickCancelChanges() { public void OnClickCancelChanges() {
InputPort = ServerManager.Port.ToString(); InputPort = ServerConfiguration.Port.ToString();
InputToken = ServerManager.Token; InputToken = ServerConfiguration.Token;
} }
} }

View File

@ -40,7 +40,7 @@
<StackPanel Orientation="Horizontal" Margin="6 3"> <StackPanel Orientation="Horizontal" Margin="6 3">
<StackPanel Orientation="Vertical" Width="65"> <StackPanel Orientation="Vertical" Width="65">
<TextBlock Classes="label">Status</TextBlock> <TextBlock Classes="label">Status</TextBlock>
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" /> <TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding ServerStatusText}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">

View File

@ -1,45 +1,46 @@
using System; using System;
using Avalonia.Threading;
using DHT.Server;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls; namespace DHT.Desktop.Main.Controls;
sealed class StatusBarModel : BaseModel { sealed class StatusBarModel : BaseModel, IDisposable {
public DatabaseStatistics DatabaseStatistics { get; } public DatabaseStatistics DatabaseStatistics { get; }
private Status status = Status.Stopped; private ServerManager.Status serverStatus;
public Status CurrentStatus { public string ServerStatusText => serverStatus switch {
get => status; ServerManager.Status.Starting => "STARTING",
set { ServerManager.Status.Started => "READY",
status = value; ServerManager.Status.Stopping => "STOPPING",
OnPropertyChanged(nameof(StatusText)); ServerManager.Status.Stopped => "STOPPED",
} _ => ""
} };
public string StatusText { private readonly State state;
get {
return CurrentStatus switch {
Status.Starting => "STARTING",
Status.Ready => "READY",
Status.Stopping => "STOPPING",
Status.Stopped => "STOPPED",
_ => ""
};
}
}
[Obsolete("Designer")] [Obsolete("Designer")]
public StatusBarModel() : this(new DatabaseStatistics()) {} public StatusBarModel() : this(State.Dummy) {}
public StatusBarModel(DatabaseStatistics databaseStatistics) { public StatusBarModel(State state) {
this.DatabaseStatistics = databaseStatistics; this.state = state;
this.DatabaseStatistics = state.Db.Statistics;
state.Server.StatusChanged += OnServerStatusChanged;
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
} }
public enum Status { public void Dispose() {
Starting, state.Server.StatusChanged += OnServerStatusChanged;
Ready, }
Stopping,
Stopped private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(() => {
serverStatus = e;
OnPropertyChanged(nameof(ServerStatusText));
});
} }
} }

View File

@ -8,6 +8,7 @@ using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Screens; using DHT.Desktop.Main.Screens;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Server; using DHT.Server;
using DHT.Utils.Logging;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main; namespace DHT.Desktop.Main;
@ -15,6 +16,8 @@ namespace DHT.Desktop.Main;
sealed class MainWindowModel : BaseModel, IAsyncDisposable { sealed class MainWindowModel : BaseModel, IAsyncDisposable {
private const string DefaultTitle = "Discord History Tracker"; private const string DefaultTitle = "Discord History Tracker";
private static readonly Log Log = Log.ForType<MainWindowModel>();
public string Title { get; private set; } = DefaultTitle; public string Title { get; private set; } = DefaultTitle;
public UserControl CurrentScreen { get; private set; } public UserControl CurrentScreen { get; private set; }
@ -63,11 +66,11 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
} }
if (args.ServerPort != null) { if (args.ServerPort != null) {
ServerManager.Port = args.ServerPort.Value; ServerConfiguration.Port = args.ServerPort.Value;
} }
if (args.ServerToken != null) { if (args.ServerToken != null) {
ServerManager.Token = args.ServerToken; ServerConfiguration.Token = args.ServerToken;
} }
} }
@ -82,15 +85,23 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
await state.DisposeAsync(); await state.DisposeAsync();
} }
state = welcomeScreenModel.Db == null ? null : new State(welcomeScreenModel.Db); if (welcomeScreenModel.Db == null) {
state = null;
if (state == null) {
Title = DefaultTitle; Title = DefaultTitle;
mainContentScreenModel = null; mainContentScreenModel = null;
mainContentScreen = null; mainContentScreen = null;
CurrentScreen = welcomeScreen; CurrentScreen = welcomeScreen;
} }
else { else {
state = new State(welcomeScreenModel.Db);
try {
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
}
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle; Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
mainContentScreenModel = new MainContentScreenModel(window, state); mainContentScreenModel = new MainContentScreenModel(window, state);
await mainContentScreenModel.Initialize(); await mainContentScreenModel.Initialize();

View File

@ -2,7 +2,6 @@ using System;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server;
using DHT.Server; using DHT.Server;
using DHT.Utils.Models; using DHT.Utils.Models;
@ -15,17 +14,13 @@ sealed class AdvancedPageModel : BaseModel, IDisposable {
private readonly State state; private readonly State state;
[Obsolete("Designer")] [Obsolete("Designer")]
public AdvancedPageModel() : this(null!, State.Dummy, new ServerManager(State.Dummy)) {} public AdvancedPageModel() : this(null!, State.Dummy) {}
public AdvancedPageModel(Window window, State state, ServerManager serverManager) { public AdvancedPageModel(Window window, State state) {
this.window = window; this.window = window;
this.state = state; this.state = state;
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager); ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
}
public void Initialize() {
ServerConfigurationModel.Initialize();
} }
public void Dispose() { public void Dispose() {

View File

@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Threading; using System.Reactive.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading; using Avalonia.ReactiveUI;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Server; using DHT.Server;
@ -11,7 +11,6 @@ using DHT.Server.Data;
using DHT.Server.Data.Aggregations; using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Download;
using DHT.Utils.Models; using DHT.Utils.Models;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
@ -61,6 +60,7 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
private readonly State state; private readonly State state;
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer; private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
private IDisposable? finishedItemsSubscription;
private int doneItemsCount; private int doneItemsCount;
private int initialFinishedCount; private int initialFinishedCount;
private int? totalItemsToDownloadCount; private int? totalItemsToDownloadCount;
@ -76,12 +76,11 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
downloadStatisticsComputer.Recompute(); downloadStatisticsComputer.Recompute();
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged; state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
state.Downloader.OnItemFinished += DownloaderOnOnItemFinished;
} }
public void Dispose() { public void Dispose() {
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged; state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
state.Downloader.OnItemFinished -= DownloaderOnOnItemFinished; finishedItemsSubscription?.Dispose();
FilterModel.Dispose(); FilterModel.Dispose();
} }
@ -139,19 +138,22 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
OnPropertyChanged(nameof(DownloadProgress)); OnPropertyChanged(nameof(DownloadProgress));
} }
private void DownloaderOnOnItemFinished(object? sender, DownloadItem e) { private void OnItemsFinished(int finishedItemCount) {
Interlocked.Increment(ref doneItemsCount); doneItemsCount += finishedItemCount;
UpdateDownloadMessage();
Dispatcher.UIThread.Invoke(UpdateDownloadMessage);
downloadStatisticsComputer.Recompute(); downloadStatisticsComputer.Recompute();
} }
public async Task OnClickToggleDownload() { public async Task OnClickToggleDownload() {
IsToggleDownloadButtonEnabled = false;
if (IsDownloading) { if (IsDownloading) {
IsToggleDownloadButtonEnabled = false;
await state.Downloader.Stop(); await state.Downloader.Stop();
finishedItemsSubscription?.Dispose();
finishedItemsSubscription = null;
downloadStatisticsComputer.Recompute(); downloadStatisticsComputer.Recompute();
IsToggleDownloadButtonEnabled = true;
state.Db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching); state.Db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
@ -161,13 +163,22 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
UpdateDownloadMessage(); UpdateDownloadMessage();
} }
else { else {
var finishedItems = await state.Downloader.Start();
initialFinishedCount = statisticsDownloaded.Items + statisticsFailed.Items; initialFinishedCount = statisticsDownloaded.Items + statisticsFailed.Items;
await state.Downloader.Start(); finishedItemsSubscription = finishedItems.Select(static _ => true)
.Buffer(TimeSpan.FromMilliseconds(100))
.Select(static items => items.Count)
.Where(static items => items > 0)
.ObserveOn(AvaloniaScheduler.Instance)
.Subscribe(OnItemsFinished);
EnqueueDownloadItems(); EnqueueDownloadItems();
} }
OnPropertyChanged(nameof(ToggleDownloadButtonText)); OnPropertyChanged(nameof(ToggleDownloadButtonText));
OnPropertyChanged(nameof(IsDownloading)); OnPropertyChanged(nameof(IsDownloading));
IsToggleDownloadButtonEnabled = true;
} }
public void OnClickRetryFailedDownloads() { public void OnClickRetryFailedDownloads() {

View File

@ -54,7 +54,7 @@ sealed class TrackingPageModel : BaseModel {
} }
public async Task<bool> OnClickCopyTrackingScript() { public async Task<bool> OnClickCopyTrackingScript() {
string url = $"http://127.0.0.1:{ServerManager.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerManager.Token)}"; string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url); string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
var clipboard = window.Clipboard; var clipboard = window.Clipboard;

View File

@ -110,7 +110,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
TemporaryFiles.Add(fullPath); TemporaryFiles.Add(fullPath);
Directory.CreateDirectory(rootPath); Directory.CreateDirectory(rootPath);
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token)); await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerConfiguration.Port, ServerConfiguration.Token));
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true }); Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
} }

View File

@ -1,12 +1,9 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Desktop.Main.Pages; using DHT.Desktop.Main.Pages;
using DHT.Desktop.Server;
using DHT.Server; using DHT.Server;
using DHT.Server.Service;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Screens; namespace DHT.Desktop.Main.Screens;
@ -49,18 +46,10 @@ sealed class MainContentScreenModel : IDisposable {
} }
} }
private readonly Window window;
private readonly ServerManager serverManager;
[Obsolete("Designer")] [Obsolete("Designer")]
public MainContentScreenModel() : this(null!, State.Dummy) {} public MainContentScreenModel() : this(null!, State.Dummy) {}
public MainContentScreenModel(Window window, State state) { public MainContentScreenModel(Window window, State state) {
this.window = window;
this.serverManager = new ServerManager(state);
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
DatabasePageModel = new DatabasePageModel(window, state); DatabasePageModel = new DatabasePageModel(window, state);
DatabasePage = new DatabasePage { DataContext = DatabasePageModel }; DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
@ -73,7 +62,7 @@ sealed class MainContentScreenModel : IDisposable {
ViewerPageModel = new ViewerPageModel(window, state); ViewerPageModel = new ViewerPageModel(window, state);
ViewerPage = new ViewerPage { DataContext = ViewerPageModel }; ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
AdvancedPageModel = new AdvancedPageModel(window, state, serverManager); AdvancedPageModel = new AdvancedPageModel(window, state);
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel }; AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
#if DEBUG #if DEBUG
@ -83,37 +72,17 @@ sealed class MainContentScreenModel : IDisposable {
DebugPage = null; DebugPage = null;
#endif #endif
StatusBarModel = new StatusBarModel(state.Db.Statistics); StatusBarModel = new StatusBarModel(state);
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
DatabaseClosed += OnDatabaseClosed;
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
} }
public async Task Initialize() { public async Task Initialize() {
await TrackingPageModel.Initialize(); await TrackingPageModel.Initialize();
AdvancedPageModel.Initialize();
serverManager.Launch();
} }
public void Dispose() { public void Dispose() {
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
AttachmentsPageModel.Dispose(); AttachmentsPageModel.Dispose();
ViewerPageModel.Dispose(); ViewerPageModel.Dispose();
serverManager.Dispose(); AdvancedPageModel.Dispose();
} StatusBarModel.Dispose();
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
StatusBarModel.CurrentStatus = e;
}
private void OnDatabaseClosed(object? sender, EventArgs e) {
serverManager.Stop();
}
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
} }
} }

View File

@ -0,0 +1,8 @@
using DHT.Server.Service;
namespace DHT.Desktop.Server;
static class ServerConfiguration {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
}

View File

@ -1,50 +0,0 @@
using System;
using DHT.Server;
using DHT.Server.Service;
namespace DHT.Desktop.Server;
sealed class ServerManager : IDisposable {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
private static ServerManager? instance;
public bool IsRunning => ServerLauncher.IsRunning;
private readonly State state;
public ServerManager(State state) {
if (state != State.Dummy) {
if (instance != null) {
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
}
instance = this;
}
this.state = state;
}
public void Launch() {
ServerLauncher.Relaunch(Port, Token, state.Db);
}
public void Relaunch(ushort port, string token) {
Port = port;
Token = token;
Launch();
}
public void Stop() {
ServerLauncher.Stop();
}
public void Dispose() {
Stop();
if (instance == this) {
instance = null;
}
}
}

View File

@ -9,8 +9,6 @@ public sealed class Downloader {
private DownloaderTask? current; private DownloaderTask? current;
public bool IsDownloading => current != null; public bool IsDownloading => current != null;
public event EventHandler<DownloadItem>? OnItemFinished;
private readonly IDatabaseFile db; private readonly IDatabaseFile db;
private readonly SemaphoreSlim semaphore = new (1, 1); private readonly SemaphoreSlim semaphore = new (1, 1);
@ -18,13 +16,11 @@ public sealed class Downloader {
this.db = db; this.db = db;
} }
public async Task Start() { public async Task<IObservable<DownloadItem>> Start() {
await semaphore.WaitAsync(); await semaphore.WaitAsync();
try { try {
if (current == null) { current ??= new DownloaderTask(db);
current = new DownloaderTask(db); return current.FinishedItems;
current.OnItemFinished += DelegateOnItemFinished;
}
} finally { } finally {
semaphore.Release(); semaphore.Release();
} }
@ -34,16 +30,11 @@ public sealed class Downloader {
await semaphore.WaitAsync(); await semaphore.WaitAsync();
try { try {
if (current != null) { if (current != null) {
await current.Stop(); await current.DisposeAsync();
current.OnItemFinished -= DelegateOnItemFinished;
current = null; current = null;
} }
} finally { } finally {
semaphore.Release(); semaphore.Release();
} }
} }
private void DelegateOnItemFinished(object? sender, DownloadItem e) {
OnItemFinished?.Invoke(this, e);
}
} }

View File

@ -1,25 +1,23 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Reactive.Subjects;
using System.Threading; using System.Threading;
using System.Threading.Channels; using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Models;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
namespace DHT.Server.Download; namespace DHT.Server.Download;
sealed class DownloaderTask : BaseModel { sealed class DownloaderTask : IAsyncDisposable {
private static readonly Log Log = Log.ForType<DownloaderTask>(); private static readonly Log Log = Log.ForType<DownloaderTask>();
private const int DownloadTasks = 4; private const int DownloadTasks = 4;
private const int QueueSize = 25; private const int QueueSize = 25;
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
internal event EventHandler<DownloadItem>? OnItemFinished;
private readonly Channel<DownloadItem> downloadQueue = Channel.CreateBounded<DownloadItem>(new BoundedChannelOptions(QueueSize) { private readonly Channel<DownloadItem> downloadQueue = Channel.CreateBounded<DownloadItem>(new BoundedChannelOptions(QueueSize) {
SingleReader = false, SingleReader = false,
SingleWriter = true, SingleWriter = true,
@ -31,12 +29,16 @@ sealed class DownloaderTask : BaseModel {
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly IDatabaseFile db; private readonly IDatabaseFile db;
private readonly Subject<DownloadItem> finishedItemPublisher = new ();
private readonly Task queueWriterTask; private readonly Task queueWriterTask;
private readonly Task[] downloadTasks; private readonly Task[] downloadTasks;
public IObservable<DownloadItem> FinishedItems => finishedItemPublisher;
internal DownloaderTask(IDatabaseFile db) { internal DownloaderTask(IDatabaseFile db) {
this.cancellationToken = cancellationTokenSource.Token;
this.db = db; this.db = db;
this.cancellationToken = cancellationTokenSource.Token;
this.queueWriterTask = Task.Run(RunQueueWriterTask); this.queueWriterTask = Task.Run(RunQueueWriterTask);
this.downloadTasks = Enumerable.Range(1, DownloadTasks).Select(taskIndex => Task.Run(() => RunDownloadTask(taskIndex))).ToArray(); this.downloadTasks = Enumerable.Range(1, DownloadTasks).Select(taskIndex => Task.Run(() => RunDownloadTask(taskIndex))).ToArray();
} }
@ -79,7 +81,7 @@ sealed class DownloaderTask : BaseModel {
log.Error(e); log.Error(e);
} finally { } finally {
try { try {
OnItemFinished?.Invoke(this, item); finishedItemPublisher.OnNext(item);
} catch (Exception e) { } catch (Exception e) {
log.Error("Caught exception in event handler: " + e); log.Error("Caught exception in event handler: " + e);
} }
@ -87,7 +89,7 @@ sealed class DownloaderTask : BaseModel {
} }
} }
internal async Task Stop() { public async ValueTask DisposeAsync() {
try { try {
await cancellationTokenSource.CancelAsync(); await cancellationTokenSource.CancelAsync();
} catch (Exception) { } catch (Exception) {
@ -102,6 +104,7 @@ sealed class DownloaderTask : BaseModel {
await Task.WhenAll(downloadTasks).WaitIgnoringCancellation(); await Task.WhenAll(downloadTasks).WaitIgnoringCancellation();
} finally { } finally {
cancellationTokenSource.Dispose(); cancellationTokenSource.Dispose();
finishedItemPublisher.OnCompleted();
} }
} }
} }

View File

@ -12,6 +12,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,126 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using DHT.Server.Database;
using DHT.Utils.Logging;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
namespace DHT.Server.Service;
public static class ServerLauncher {
private static readonly Log Log = Log.ForType(typeof(ServerLauncher));
private static IWebHost? Server { get; set; } = null;
public static bool IsRunning { get; private set; }
public static event EventHandler? ServerStatusChanged;
public static event EventHandler<Exception>? ServerManagementExceptionCaught;
private static Thread? ManagementThread { get; set; } = null;
private static readonly Mutex ManagementThreadLock = new();
private static readonly BlockingCollection<IMessage> Messages = new(new ConcurrentQueue<IMessage>());
private static void EnqueueMessage(IMessage message) {
ManagementThreadLock.WaitOne();
try {
if (ManagementThread == null) {
ManagementThread = new Thread(RunManagementThread) {
Name = "DHT server management thread",
IsBackground = true
};
ManagementThread.Start();
}
Messages.Add(message);
} finally {
ManagementThreadLock.ReleaseMutex();
}
}
[SuppressMessage("ReSharper", "FunctionNeverReturns")]
private static void RunManagementThread() {
foreach (IMessage message in Messages.GetConsumingEnumerable()) {
try {
switch (message) {
case IMessage.StartServer start:
StopServerFromManagementThread();
StartServerFromManagementThread(start.Port, start.Token, start.Db);
break;
case IMessage.StopServer:
StopServerFromManagementThread();
break;
}
} catch (Exception e) {
ServerManagementExceptionCaught?.Invoke(null, e);
}
}
}
private static void StartServerFromManagementThread(ushort port, string token, IDatabaseFile db) {
Log.Info("Starting server on port " + port + "...");
void AddServices(IServiceCollection services) {
services.AddSingleton(typeof(IDatabaseFile), db);
services.AddSingleton(typeof(ServerParameters), new ServerParameters(port, token));
}
void SetKestrelOptions(KestrelServerOptions options) {
options.Limits.MaxRequestBodySize = null;
options.Limits.MinResponseDataRate = null;
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
}
Server = new WebHostBuilder()
.ConfigureServices(AddServices)
.UseKestrel(SetKestrelOptions)
.UseStartup<Startup>()
.Build();
Server.Start();
Log.Info("Server started");
IsRunning = true;
ServerStatusChanged?.Invoke(null, EventArgs.Empty);
}
private static void StopServerFromManagementThread() {
if (Server != null) {
Log.Info("Stopping server...");
Server.StopAsync().Wait();
Server.Dispose();
Server = null;
Log.Info("Server stopped");
IsRunning = false;
ServerStatusChanged?.Invoke(null, EventArgs.Empty);
}
}
public static void Relaunch(ushort port, string token, IDatabaseFile db) {
EnqueueMessage(new IMessage.StartServer(port, token, db));
}
public static void Stop() {
EnqueueMessage(new IMessage.StopServer());
}
private interface IMessage {
public sealed class StartServer : IMessage {
public ushort Port { get; }
public string Token { get; }
public IDatabaseFile Db { get; }
public StartServer(ushort port, string token, IDatabaseFile db) {
this.Port = port;
this.Token = token;
this.Db = db;
}
}
public sealed class StopServer : IMessage {}
}
}

View File

@ -0,0 +1,107 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using DHT.Server.Database;
using DHT.Utils.Logging;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
namespace DHT.Server.Service;
public sealed class ServerManager {
private static readonly Log Log = Log.ForType(typeof(ServerManager));
private IWebHost? server;
public bool IsRunning => server != null;
public event EventHandler<Status>? StatusChanged;
public enum Status {
Starting,
Started,
Stopping,
Stopped
}
private readonly IDatabaseFile db;
private readonly SemaphoreSlim semaphore = new (1, 1);
internal ServerManager(IDatabaseFile db) {
this.db = db;
}
public async Task Start(ushort port, string token) {
await semaphore.WaitAsync();
try {
await StartInternal(port, token);
} finally {
semaphore.Release();
}
}
public async Task Stop() {
await semaphore.WaitAsync();
try {
await StopInternal();
} finally {
semaphore.Release();
}
}
private async Task StartInternal(ushort port, string token) {
await StopInternal();
StatusChanged?.Invoke(this, Status.Starting);
void AddServices(IServiceCollection services) {
services.AddSingleton(typeof(IDatabaseFile), db);
services.AddSingleton(typeof(ServerParameters), new ServerParameters(port, token));
}
void SetKestrelOptions(KestrelServerOptions options) {
options.Limits.MaxRequestBodySize = null;
options.Limits.MinResponseDataRate = null;
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
}
var newServer = new WebHostBuilder()
.ConfigureServices(AddServices)
.UseKestrel(SetKestrelOptions)
.UseStartup<Startup>()
.Build();
Log.Info("Starting server on port " + port + "...");
try {
await newServer.StartAsync();
} catch (Exception) {
Log.Error("Server could not start");
StatusChanged?.Invoke(this, Status.Stopped);
throw;
}
Log.Info("Server started");
server = newServer;
StatusChanged?.Invoke(this, Status.Started);
}
private async Task StopInternal() {
if (server == null) {
return;
}
StatusChanged?.Invoke(this, Status.Stopping);
Log.Info("Stopping server...");
await server.StopAsync();
Log.Info("Server stopped");
server.Dispose();
server = null;
StatusChanged?.Invoke(this, Status.Stopped);
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Download; using DHT.Server.Download;
using DHT.Server.Service;
namespace DHT.Server; namespace DHT.Server;
@ -10,14 +11,17 @@ public sealed class State : IAsyncDisposable {
public IDatabaseFile Db { get; } public IDatabaseFile Db { get; }
public Downloader Downloader { get; } public Downloader Downloader { get; }
public ServerManager Server { get; }
public State(IDatabaseFile db) { public State(IDatabaseFile db) {
Db = db; Db = db;
Downloader = new Downloader(db); Downloader = new Downloader(db);
Server = new ServerManager(db);
} }
public async ValueTask DisposeAsync() { public async ValueTask DisposeAsync() {
await Downloader.Stop(); await Downloader.Stop();
await Server.Stop();
Db.Dispose(); Db.Dispose();
} }
} }