diff --git a/OF DL.Core/Services/ApiService.cs b/OF DL.Core/Services/ApiService.cs index c6126bd..e231e59 100644 --- a/OF DL.Core/Services/ApiService.cs +++ b/OF DL.Core/Services/ApiService.cs @@ -159,7 +159,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// /// The user endpoint. /// The user entity when available. - public async Task GetUserInfo(string endpoint) + public async Task GetUserInfo(string endpoint, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetUserInfo: {endpoint}"); @@ -170,6 +170,7 @@ public class ApiService(IAuthService authService, IConfigService configService, try { + cancellationToken.ThrowIfCancellationRequested(); UserEntities.User user = new(); Dictionary getParams = new() { @@ -179,7 +180,7 @@ public class ApiService(IAuthService authService, IConfigService configService, HttpClient client = new(); HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint); - using HttpResponseMessage response = await client.SendAsync(request); + using HttpResponseMessage response = await client.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -415,7 +416,8 @@ public class ApiService(IAuthService authService, IConfigService configService, public async Task?> GetMedia(MediaType mediatype, string endpoint, string? username, - string folder) + string folder, + CancellationToken cancellationToken = default) { Log.Debug($"Calling GetMedia - {username}"); @@ -426,6 +428,7 @@ public class ApiService(IAuthService authService, IConfigService configService, try { + cancellationToken.ThrowIfCancellationRequested(); Dictionary returnUrls = new(); const int limit = 5; int offset = 0; @@ -649,7 +652,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// A paid post collection. public async Task GetPaidPosts(string endpoint, string folder, string username, - List paidPostIds, IStatusReporter statusReporter) + List paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetPaidPosts - {username}"); @@ -830,7 +833,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// Status reporter. /// A post collection. public async Task GetPosts(string endpoint, string folder, List paidPostIds, - IStatusReporter statusReporter) + IStatusReporter statusReporter, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetPosts - {endpoint}"); @@ -1018,7 +1021,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// The post endpoint. /// The creator folder path. /// A single post collection. - public async Task GetPost(string endpoint, string folder) + public async Task GetPost(string endpoint, string folder, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetPost - {endpoint}"); @@ -1168,7 +1171,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// A streams collection. public async Task GetStreams(string endpoint, string folder, List paidPostIds, - IStatusReporter statusReporter) + IStatusReporter statusReporter, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetStreams - {endpoint}"); @@ -1319,7 +1322,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// Status reporter. /// An archived collection. public async Task GetArchived(string endpoint, string folder, - IStatusReporter statusReporter) + IStatusReporter statusReporter, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetArchived - {endpoint}"); @@ -1473,7 +1476,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// Status reporter. /// A message collection. public async Task GetMessages(string endpoint, string folder, - IStatusReporter statusReporter) + IStatusReporter statusReporter, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetMessages - {endpoint}"); @@ -1661,7 +1664,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// The paid message endpoint. /// The creator folder path. /// A single paid message collection. - public async Task GetPaidMessage(string endpoint, string folder) + public async Task GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetPaidMessage - {endpoint}"); @@ -1809,7 +1812,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// A paid message collection. public async Task GetPaidMessages(string endpoint, string folder, string username, - IStatusReporter statusReporter) + IStatusReporter statusReporter, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetPaidMessages - {username}"); @@ -2031,7 +2034,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// The purchased tab endpoint. /// Known users map. /// A username-to-userId map. - public async Task> GetPurchasedTabUsers(string endpoint, Dictionary users) + public async Task> GetPurchasedTabUsers(string endpoint, Dictionary users, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetPurchasedTabUsers - {endpoint}"); @@ -2046,6 +2049,7 @@ public class ApiService(IAuthService authService, IConfigService configService, { "skip_users", "all" } }; + cancellationToken.ThrowIfCancellationRequested(); string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); if (body == null) { @@ -2060,6 +2064,7 @@ public class ApiService(IAuthService authService, IConfigService configService, getParams["offset"] = purchased.List.Count.ToString(); while (true) { + cancellationToken.ThrowIfCancellationRequested(); string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); PurchasedEntities.Purchased newPurchased; Dictionary loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); @@ -2073,7 +2078,7 @@ public class ApiService(IAuthService authService, IConfigService configService, looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); } - using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest)) + using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest, cancellationToken)) { loopresponse.EnsureSuccessStatusCode(); string loopbody = await loopresponse.Content.ReadAsStringAsync(); @@ -2098,6 +2103,7 @@ public class ApiService(IAuthService authService, IConfigService configService, foreach (PurchasedEntities.ListItem purchase in purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt)) { + cancellationToken.ThrowIfCancellationRequested(); long fromUserId = purchase.FromUser?.Id ?? 0; long authorId = purchase.Author?.Id ?? 0; @@ -2197,7 +2203,7 @@ public class ApiService(IAuthService authService, IConfigService configService, /// Known users map. /// A list of purchased tab collections. public async Task> GetPurchasedTab(string endpoint, string folder, - Dictionary users) + Dictionary users, CancellationToken cancellationToken = default) { Log.Debug($"Calling GetPurchasedTab - {endpoint}"); @@ -2213,6 +2219,7 @@ public class ApiService(IAuthService authService, IConfigService configService, { "skip_users", "all" } }; + cancellationToken.ThrowIfCancellationRequested(); string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); PurchasedDtos.PurchasedDto? purchasedDto = DeserializeJson(body, s_mJsonSerializerSettings); @@ -2222,6 +2229,7 @@ public class ApiService(IAuthService authService, IConfigService configService, getParams["offset"] = purchased.List.Count.ToString(); while (true) { + cancellationToken.ThrowIfCancellationRequested(); string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); PurchasedEntities.Purchased newPurchased; Dictionary loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); @@ -2235,7 +2243,7 @@ public class ApiService(IAuthService authService, IConfigService configService, looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); } - using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest)) + using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest, cancellationToken)) { loopresponse.EnsureSuccessStatusCode(); string loopbody = await loopresponse.Content.ReadAsStringAsync(); @@ -2260,6 +2268,7 @@ public class ApiService(IAuthService authService, IConfigService configService, foreach (PurchasedEntities.ListItem purchase in purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt)) { + cancellationToken.ThrowIfCancellationRequested(); if (purchase.FromUser != null) { if (!userPurchases.ContainsKey(purchase.FromUser.Id)) @@ -2283,6 +2292,7 @@ public class ApiService(IAuthService authService, IConfigService configService, foreach (KeyValuePair> user in userPurchases) { + cancellationToken.ThrowIfCancellationRequested(); PurchasedEntities.PurchasedTabCollection purchasedTabCollection = new(); JObject? userObject = await GetUserInfoById($"/users/list?x[]={user.Key}"); purchasedTabCollection.UserId = user.Key; diff --git a/OF DL.Core/Services/DownloadOrchestrationService.cs b/OF DL.Core/Services/DownloadOrchestrationService.cs index b708f2e..e6da7d6 100644 --- a/OF DL.Core/Services/DownloadOrchestrationService.cs +++ b/OF DL.Core/Services/DownloadOrchestrationService.cs @@ -171,11 +171,13 @@ public class DownloadOrchestrationService( eventHandler.OnUserStarting(username); Log.Debug($"Scraping Data for {username}"); + eventHandler.CancellationToken.ThrowIfCancellationRequested(); await PrepareUserFolderAsync(username, userId, path); if (config.DownloadAvatarHeaderPhoto) { - UserEntities.User? userInfo = await apiService.GetUserInfo($"/users/{username}"); + eventHandler.CancellationToken.ThrowIfCancellationRequested(); + UserEntities.User? userInfo = await apiService.GetUserInfo($"/users/{username}", eventHandler.CancellationToken); if (userInfo != null) { await downloadService.DownloadAvatarHeader(userInfo.Avatar, userInfo.Header, path, username); @@ -240,9 +242,10 @@ public class DownloadOrchestrationService( if (config.DownloadStories) { + eventHandler.CancellationToken.ThrowIfCancellationRequested(); eventHandler.OnMessage("Getting Stories"); Dictionary? tempStories = await apiService.GetMedia(MediaType.Stories, - $"/users/{userId}/stories", null, path); + $"/users/{userId}/stories", null, path, eventHandler.CancellationToken); if (tempStories is { Count: > 0 }) { @@ -268,9 +271,10 @@ public class DownloadOrchestrationService( if (config.DownloadHighlights) { + eventHandler.CancellationToken.ThrowIfCancellationRequested(); eventHandler.OnMessage("Getting Highlights"); Dictionary? tempHighlights = await apiService.GetMedia(MediaType.Highlights, - $"/users/{userId}/stories/highlights", null, path); + $"/users/{userId}/stories/highlights", null, path, eventHandler.CancellationToken); if (tempHighlights is { Count: > 0 }) { @@ -387,13 +391,19 @@ public class DownloadOrchestrationService( { Config config = configService.CurrentConfig; + eventHandler.OnMessage("Fetching purchased tab users..."); + eventHandler.CancellationToken.ThrowIfCancellationRequested(); + Dictionary purchasedTabUsers = - await apiService.GetPurchasedTabUsers("/posts/paid/all", users); + await apiService.GetPurchasedTabUsers("/posts/paid/all", users, eventHandler.CancellationToken); eventHandler.OnMessage("Checking folders for users in Purchased Tab"); + eventHandler.CancellationToken.ThrowIfCancellationRequested(); foreach (KeyValuePair user in purchasedTabUsers) { + eventHandler.CancellationToken.ThrowIfCancellationRequested(); + string path = ResolveDownloadPath(user.Key); Log.Debug($"Download path: {path}"); @@ -405,7 +415,7 @@ public class DownloadOrchestrationService( Log.Debug($"Created folder for {user.Key}"); } - await apiService.GetUserInfo($"/users/{user.Key}"); + await apiService.GetUserInfo($"/users/{user.Key}", eventHandler.CancellationToken); await dbService.CreateDb(path); } @@ -415,11 +425,16 @@ public class DownloadOrchestrationService( Log.Debug($"Download path: {basePath}"); + eventHandler.OnMessage("Fetching purchased tab content..."); + eventHandler.CancellationToken.ThrowIfCancellationRequested(); + List purchasedTabCollections = - await apiService.GetPurchasedTab("/posts/paid/all", basePath, users); + await apiService.GetPurchasedTab("/posts/paid/all", basePath, users, eventHandler.CancellationToken); foreach (PurchasedEntities.PurchasedTabCollection purchasedTabCollection in purchasedTabCollections) { + eventHandler.CancellationToken.ThrowIfCancellationRequested(); + eventHandler.OnUserStarting(purchasedTabCollection.Username); string path = ResolveDownloadPath(purchasedTabCollection.Username); Log.Debug($"Download path: {path}"); diff --git a/OF DL.Core/Services/DownloadService.cs b/OF DL.Core/Services/DownloadService.cs index 5fedd5a..0afd23d 100644 --- a/OF DL.Core/Services/DownloadService.cs +++ b/OF DL.Core/Services/DownloadService.cs @@ -191,7 +191,7 @@ public class DownloadService( await OnFFMPEGDownloadComplete(tempFilename, lastModified, folder, path, customFileName, filename, mediaId, apiType, progressReporter); }; - await ffmpeg.ExecuteAsync(parameters, CancellationToken.None); + await ffmpeg.ExecuteAsync(parameters, progressReporter.CancellationToken); return await _completionSource.Task; } @@ -747,9 +747,9 @@ public class DownloadService( using HttpClient client = new(); HttpRequestMessage request = new() { Method = HttpMethod.Get, RequestUri = new Uri(url) }; - using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, progressReporter.CancellationToken); response.EnsureSuccessStatusCode(); - Stream body = await response.Content.ReadAsStreamAsync(); + Stream body = await response.Content.ReadAsStreamAsync(progressReporter.CancellationToken); // Wrap the body stream with the ThrottledStream to limit read rate. await using (ThrottledStream throttledStream = new(body, @@ -761,14 +761,14 @@ public class DownloadService( true); byte[] buffer = new byte[16384]; int read; - while ((read = await throttledStream.ReadAsync(buffer, CancellationToken.None)) > 0) + while ((read = await throttledStream.ReadAsync(buffer, progressReporter.CancellationToken)) > 0) { if (configService.CurrentConfig.ShowScrapeSize) { progressReporter.ReportProgress(read); } - await fileStream.WriteAsync(buffer.AsMemory(0, read), CancellationToken.None); + await fileStream.WriteAsync(buffer.AsMemory(0, read), progressReporter.CancellationToken); } } diff --git a/OF DL.Core/Services/IApiService.cs b/OF DL.Core/Services/IApiService.cs index 1a293bc..21d1a89 100644 --- a/OF DL.Core/Services/IApiService.cs +++ b/OF DL.Core/Services/IApiService.cs @@ -39,69 +39,69 @@ public interface IApiService /// /// Retrieves media URLs for stories or highlights. /// - Task?> GetMedia(MediaType mediaType, string endpoint, string? username, string folder); + Task?> GetMedia(MediaType mediaType, string endpoint, string? username, string folder, CancellationToken cancellationToken = default); /// /// Retrieves paid posts and their media. /// Task GetPaidPosts(string endpoint, string folder, string username, List paidPostIds, - IStatusReporter statusReporter); + IStatusReporter statusReporter, CancellationToken cancellationToken = default); /// /// Retrieves posts and their media. /// Task GetPosts(string endpoint, string folder, List paidPostIds, - IStatusReporter statusReporter); + IStatusReporter statusReporter, CancellationToken cancellationToken = default); /// /// Retrieves a single post and its media. /// - Task GetPost(string endpoint, string folder); + Task GetPost(string endpoint, string folder, CancellationToken cancellationToken = default); /// /// Retrieves streams and their media. /// Task GetStreams(string endpoint, string folder, List paidPostIds, - IStatusReporter statusReporter); + IStatusReporter statusReporter, CancellationToken cancellationToken = default); /// /// Retrieves archived posts and their media. /// Task GetArchived(string endpoint, string folder, - IStatusReporter statusReporter); + IStatusReporter statusReporter, CancellationToken cancellationToken = default); /// /// Retrieves messages and their media. /// - Task GetMessages(string endpoint, string folder, IStatusReporter statusReporter); + Task GetMessages(string endpoint, string folder, IStatusReporter statusReporter, CancellationToken cancellationToken = default); /// /// Retrieves paid messages and their media. /// Task GetPaidMessages(string endpoint, string folder, string username, - IStatusReporter statusReporter); + IStatusReporter statusReporter, CancellationToken cancellationToken = default); /// /// Retrieves a single paid message and its media. /// - Task GetPaidMessage(string endpoint, string folder); + Task GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default); /// /// Retrieves users that appear in the Purchased tab. /// - Task> GetPurchasedTabUsers(string endpoint, Dictionary users); + Task> GetPurchasedTabUsers(string endpoint, Dictionary users, CancellationToken cancellationToken = default); /// /// Retrieves Purchased tab content grouped by user. /// Task> GetPurchasedTab(string endpoint, string folder, - Dictionary users); + Dictionary users, CancellationToken cancellationToken = default); /// /// Retrieves user information. /// - Task GetUserInfo(string endpoint); + Task GetUserInfo(string endpoint, CancellationToken cancellationToken = default); /// /// Retrieves user information by ID. diff --git a/OF DL.Core/Services/IDownloadEventHandler.cs b/OF DL.Core/Services/IDownloadEventHandler.cs index 602f6c2..6df1e37 100644 --- a/OF DL.Core/Services/IDownloadEventHandler.cs +++ b/OF DL.Core/Services/IDownloadEventHandler.cs @@ -8,6 +8,11 @@ namespace OF_DL.Services; /// public interface IDownloadEventHandler { + /// + /// Gets the cancellation token for the operation. + /// + CancellationToken CancellationToken { get; } + /// /// Wraps work in a status indicator (spinner) during API fetching. /// The implementation controls how the status is displayed. diff --git a/OF DL.Core/Services/IProgressReporter.cs b/OF DL.Core/Services/IProgressReporter.cs index dc59aca..f6ceb9b 100644 --- a/OF DL.Core/Services/IProgressReporter.cs +++ b/OF DL.Core/Services/IProgressReporter.cs @@ -11,4 +11,9 @@ public interface IProgressReporter /// /// The amount to increment progress by void ReportProgress(long increment); + + /// + /// Gets the cancellation token for canceling the operation. + /// + CancellationToken CancellationToken { get; } } diff --git a/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs b/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs index cb42640..d0df032 100644 --- a/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs +++ b/OF DL.Gui/Services/AvaloniaDownloadEventHandler.cs @@ -9,8 +9,11 @@ internal sealed class AvaloniaDownloadEventHandler( Action progressStart, Action progressIncrement, Action progressStop, - Func isCancellationRequested) : IDownloadEventHandler + Func isCancellationRequested, + CancellationToken cancellationToken) : IDownloadEventHandler { + public CancellationToken CancellationToken { get; } = cancellationToken; + public async Task WithStatusAsync(string statusMessage, Func> work) { ThrowIfCancellationRequested(); @@ -33,7 +36,7 @@ internal sealed class AvaloniaDownloadEventHandler( progressStart(description, maxValue, showSize); try { - AvaloniaProgressReporter reporter = new(progressIncrement, isCancellationRequested); + AvaloniaProgressReporter reporter = new(progressIncrement, isCancellationRequested, cancellationToken); return await work(reporter); } finally diff --git a/OF DL.Gui/Services/AvaloniaProgressReporter.cs b/OF DL.Gui/Services/AvaloniaProgressReporter.cs index 59205be..47bf8bc 100644 --- a/OF DL.Gui/Services/AvaloniaProgressReporter.cs +++ b/OF DL.Gui/Services/AvaloniaProgressReporter.cs @@ -4,8 +4,11 @@ namespace OF_DL.Gui.Services; internal sealed class AvaloniaProgressReporter( Action reportAction, - Func isCancellationRequested) : IProgressReporter + Func isCancellationRequested, + CancellationToken cancellationToken) : IProgressReporter { + public CancellationToken CancellationToken { get; } = cancellationToken; + public void ReportProgress(long increment) { if (isCancellationRequested()) diff --git a/OF DL.Gui/ViewModels/MainWindowViewModel.cs b/OF DL.Gui/ViewModels/MainWindowViewModel.cs index f173dff..9f9dad0 100644 --- a/OF DL.Gui/ViewModels/MainWindowViewModel.cs +++ b/OF DL.Gui/ViewModels/MainWindowViewModel.cs @@ -598,6 +598,17 @@ public partial class MainWindowViewModel( AppendLog(downloadPurchasedTabOnly ? "Starting Purchased Tab download." : $"Starting download for {selectedUsers.Count} users."); + StatusMessage = downloadPurchasedTabOnly + ? "Starting Purchased Tab download..." + : $"Starting download for {selectedUsers.Count} users..."; + + // Show progress bar immediately with indeterminate state + StartDownloadProgress( + downloadPurchasedTabOnly + ? "Initializing Purchased Tab download..." + : $"Initializing download for {selectedUsers.Count} users...", + 0, + false); AvaloniaDownloadEventHandler eventHandler = new( AppendLog, @@ -605,7 +616,8 @@ public partial class MainWindowViewModel( StartDownloadProgress, IncrementDownloadProgress, StopDownloadProgress, - () => _workCancellationSource?.IsCancellationRequested == true); + () => _workCancellationSource?.IsCancellationRequested == true, + _workCancellationSource.Token); try { diff --git a/OF DL.Gui/Views/MainWindow.axaml b/OF DL.Gui/Views/MainWindow.axaml index 0fbd1c4..3c0e7fa 100644 --- a/OF DL.Gui/Views/MainWindow.axaml +++ b/OF DL.Gui/Views/MainWindow.axaml @@ -773,16 +773,18 @@ + Content="{Binding SelectedUsersSummary}" + IsEnabled="{Binding !IsDownloading}" /> + SelectedItem="{Binding SelectedListName}" + IsEnabled="{Binding !IsDownloading}" /> - +