Update GUI so that the stop button works quicker and more reliably

This commit is contained in:
whimsical-c4lic0 2026-02-14 12:30:31 -06:00
parent 35f7d98112
commit 85e299db41
13 changed files with 133 additions and 70 deletions

View File

@ -159,7 +159,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// </summary> /// </summary>
/// <param name="endpoint">The user endpoint.</param> /// <param name="endpoint">The user endpoint.</param>
/// <returns>The user entity when available.</returns> /// <returns>The user entity when available.</returns>
public async Task<UserEntities.User?> GetUserInfo(string endpoint) public async Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetUserInfo: {endpoint}"); Log.Debug($"Calling GetUserInfo: {endpoint}");
@ -170,6 +170,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
try try
{ {
cancellationToken.ThrowIfCancellationRequested();
UserEntities.User user = new(); UserEntities.User user = new();
Dictionary<string, string> getParams = new() Dictionary<string, string> getParams = new()
{ {
@ -179,7 +180,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
HttpClient client = new(); HttpClient client = new();
HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint); HttpRequestMessage request = await BuildHttpRequestMessage(getParams, endpoint);
using HttpResponseMessage response = await client.SendAsync(request); using HttpResponseMessage response = await client.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@ -415,7 +416,8 @@ public class ApiService(IAuthService authService, IConfigService configService,
public async Task<Dictionary<long, string>?> GetMedia(MediaType mediatype, public async Task<Dictionary<long, string>?> GetMedia(MediaType mediatype,
string endpoint, string endpoint,
string? username, string? username,
string folder) string folder,
CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetMedia - {username}"); Log.Debug($"Calling GetMedia - {username}");
@ -426,6 +428,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
try try
{ {
cancellationToken.ThrowIfCancellationRequested();
Dictionary<long, string> returnUrls = new(); Dictionary<long, string> returnUrls = new();
const int limit = 5; const int limit = 5;
int offset = 0; int offset = 0;
@ -649,7 +652,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <returns>A paid post collection.</returns> /// <returns>A paid post collection.</returns>
public async Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, public async Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
string username, string username,
List<long> paidPostIds, IStatusReporter statusReporter) List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetPaidPosts - {username}"); Log.Debug($"Calling GetPaidPosts - {username}");
@ -830,7 +833,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="statusReporter">Status reporter.</param> /// <param name="statusReporter">Status reporter.</param>
/// <returns>A post collection.</returns> /// <returns>A post collection.</returns>
public async Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds, public async Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter) IStatusReporter statusReporter, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetPosts - {endpoint}"); Log.Debug($"Calling GetPosts - {endpoint}");
@ -1018,7 +1021,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="endpoint">The post endpoint.</param> /// <param name="endpoint">The post endpoint.</param>
/// <param name="folder">The creator folder path.</param> /// <param name="folder">The creator folder path.</param>
/// <returns>A single post collection.</returns> /// <returns>A single post collection.</returns>
public async Task<SinglePostCollection> GetPost(string endpoint, string folder) public async Task<SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetPost - {endpoint}"); Log.Debug($"Calling GetPost - {endpoint}");
@ -1168,7 +1171,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <returns>A streams collection.</returns> /// <returns>A streams collection.</returns>
public async Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, public async Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder,
List<long> paidPostIds, List<long> paidPostIds,
IStatusReporter statusReporter) IStatusReporter statusReporter, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetStreams - {endpoint}"); Log.Debug($"Calling GetStreams - {endpoint}");
@ -1319,7 +1322,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="statusReporter">Status reporter.</param> /// <param name="statusReporter">Status reporter.</param>
/// <returns>An archived collection.</returns> /// <returns>An archived collection.</returns>
public async Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder, public async Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter) IStatusReporter statusReporter, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetArchived - {endpoint}"); Log.Debug($"Calling GetArchived - {endpoint}");
@ -1473,7 +1476,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="statusReporter">Status reporter.</param> /// <param name="statusReporter">Status reporter.</param>
/// <returns>A message collection.</returns> /// <returns>A message collection.</returns>
public async Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder, public async Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter) IStatusReporter statusReporter, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetMessages - {endpoint}"); Log.Debug($"Calling GetMessages - {endpoint}");
@ -1661,7 +1664,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="endpoint">The paid message endpoint.</param> /// <param name="endpoint">The paid message endpoint.</param>
/// <param name="folder">The creator folder path.</param> /// <param name="folder">The creator folder path.</param>
/// <returns>A single paid message collection.</returns> /// <returns>A single paid message collection.</returns>
public async Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder) public async Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetPaidMessage - {endpoint}"); Log.Debug($"Calling GetPaidMessage - {endpoint}");
@ -1809,7 +1812,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <returns>A paid message collection.</returns> /// <returns>A paid message collection.</returns>
public async Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder, public async Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder,
string username, string username,
IStatusReporter statusReporter) IStatusReporter statusReporter, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetPaidMessages - {username}"); Log.Debug($"Calling GetPaidMessages - {username}");
@ -2031,7 +2034,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="endpoint">The purchased tab endpoint.</param> /// <param name="endpoint">The purchased tab endpoint.</param>
/// <param name="users">Known users map.</param> /// <param name="users">Known users map.</param>
/// <returns>A username-to-userId map.</returns> /// <returns>A username-to-userId map.</returns>
public async Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users) public async Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetPurchasedTabUsers - {endpoint}"); Log.Debug($"Calling GetPurchasedTabUsers - {endpoint}");
@ -2046,6 +2049,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
{ "skip_users", "all" } { "skip_users", "all" }
}; };
cancellationToken.ThrowIfCancellationRequested();
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
if (body == null) if (body == null)
{ {
@ -2060,6 +2064,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
getParams["offset"] = purchased.List.Count.ToString(); getParams["offset"] = purchased.List.Count.ToString();
while (true) while (true)
{ {
cancellationToken.ThrowIfCancellationRequested();
string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}"));
PurchasedEntities.Purchased newPurchased; PurchasedEntities.Purchased newPurchased;
Dictionary<string, string> loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); Dictionary<string, string> loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams);
@ -2073,7 +2078,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value);
} }
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest)) using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest, cancellationToken))
{ {
loopresponse.EnsureSuccessStatusCode(); loopresponse.EnsureSuccessStatusCode();
string loopbody = await loopresponse.Content.ReadAsStringAsync(); string loopbody = await loopresponse.Content.ReadAsStringAsync();
@ -2098,6 +2103,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
foreach (PurchasedEntities.ListItem purchase in foreach (PurchasedEntities.ListItem purchase in
purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt)) purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt))
{ {
cancellationToken.ThrowIfCancellationRequested();
long fromUserId = purchase.FromUser?.Id ?? 0; long fromUserId = purchase.FromUser?.Id ?? 0;
long authorId = purchase.Author?.Id ?? 0; long authorId = purchase.Author?.Id ?? 0;
@ -2197,7 +2203,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
/// <param name="users">Known users map.</param> /// <param name="users">Known users map.</param>
/// <returns>A list of purchased tab collections.</returns> /// <returns>A list of purchased tab collections.</returns>
public async Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder, public async Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
Dictionary<string, long> users) Dictionary<string, long> users, CancellationToken cancellationToken = default)
{ {
Log.Debug($"Calling GetPurchasedTab - {endpoint}"); Log.Debug($"Calling GetPurchasedTab - {endpoint}");
@ -2213,6 +2219,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
{ "skip_users", "all" } { "skip_users", "all" }
}; };
cancellationToken.ThrowIfCancellationRequested();
string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient()); string? body = await BuildHeaderAndExecuteRequests(getParams, endpoint, GetHttpClient());
PurchasedDtos.PurchasedDto? purchasedDto = PurchasedDtos.PurchasedDto? purchasedDto =
DeserializeJson<PurchasedDtos.PurchasedDto>(body, s_mJsonSerializerSettings); DeserializeJson<PurchasedDtos.PurchasedDto>(body, s_mJsonSerializerSettings);
@ -2222,6 +2229,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
getParams["offset"] = purchased.List.Count.ToString(); getParams["offset"] = purchased.List.Count.ToString();
while (true) while (true)
{ {
cancellationToken.ThrowIfCancellationRequested();
string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}")); string loopqueryParams = "?" + string.Join("&", getParams.Select(kvp => $"{kvp.Key}={kvp.Value}"));
PurchasedEntities.Purchased newPurchased; PurchasedEntities.Purchased newPurchased;
Dictionary<string, string> loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams); Dictionary<string, string> loopheaders = GetDynamicHeaders("/api2/v2" + endpoint, loopqueryParams);
@ -2235,7 +2243,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value); looprequest.Headers.Add(keyValuePair.Key, keyValuePair.Value);
} }
using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest)) using (HttpResponseMessage loopresponse = await loopclient.SendAsync(looprequest, cancellationToken))
{ {
loopresponse.EnsureSuccessStatusCode(); loopresponse.EnsureSuccessStatusCode();
string loopbody = await loopresponse.Content.ReadAsStringAsync(); string loopbody = await loopresponse.Content.ReadAsStringAsync();
@ -2260,6 +2268,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
foreach (PurchasedEntities.ListItem purchase in foreach (PurchasedEntities.ListItem purchase in
purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt)) purchased.List.OrderByDescending(p => p.PostedAt ?? p.CreatedAt))
{ {
cancellationToken.ThrowIfCancellationRequested();
if (purchase.FromUser != null) if (purchase.FromUser != null)
{ {
if (!userPurchases.ContainsKey(purchase.FromUser.Id)) if (!userPurchases.ContainsKey(purchase.FromUser.Id))
@ -2283,6 +2292,7 @@ public class ApiService(IAuthService authService, IConfigService configService,
foreach (KeyValuePair<long, List<PurchasedEntities.ListItem>> user in userPurchases) foreach (KeyValuePair<long, List<PurchasedEntities.ListItem>> user in userPurchases)
{ {
cancellationToken.ThrowIfCancellationRequested();
PurchasedEntities.PurchasedTabCollection purchasedTabCollection = new(); PurchasedEntities.PurchasedTabCollection purchasedTabCollection = new();
JObject? userObject = await GetUserInfoById($"/users/list?x[]={user.Key}"); JObject? userObject = await GetUserInfoById($"/users/list?x[]={user.Key}");
purchasedTabCollection.UserId = user.Key; purchasedTabCollection.UserId = user.Key;

View File

@ -171,11 +171,13 @@ public class DownloadOrchestrationService(
eventHandler.OnUserStarting(username); eventHandler.OnUserStarting(username);
Log.Debug($"Scraping Data for {username}"); Log.Debug($"Scraping Data for {username}");
eventHandler.CancellationToken.ThrowIfCancellationRequested();
await PrepareUserFolderAsync(username, userId, path); await PrepareUserFolderAsync(username, userId, path);
if (config.DownloadAvatarHeaderPhoto) 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) if (userInfo != null)
{ {
await downloadService.DownloadAvatarHeader(userInfo.Avatar, userInfo.Header, path, username); await downloadService.DownloadAvatarHeader(userInfo.Avatar, userInfo.Header, path, username);
@ -240,9 +242,10 @@ public class DownloadOrchestrationService(
if (config.DownloadStories) if (config.DownloadStories)
{ {
eventHandler.CancellationToken.ThrowIfCancellationRequested();
eventHandler.OnMessage("Getting Stories"); eventHandler.OnMessage("Getting Stories");
Dictionary<long, string>? tempStories = await apiService.GetMedia(MediaType.Stories, Dictionary<long, string>? tempStories = await apiService.GetMedia(MediaType.Stories,
$"/users/{userId}/stories", null, path); $"/users/{userId}/stories", null, path, eventHandler.CancellationToken);
if (tempStories is { Count: > 0 }) if (tempStories is { Count: > 0 })
{ {
@ -268,9 +271,10 @@ public class DownloadOrchestrationService(
if (config.DownloadHighlights) if (config.DownloadHighlights)
{ {
eventHandler.CancellationToken.ThrowIfCancellationRequested();
eventHandler.OnMessage("Getting Highlights"); eventHandler.OnMessage("Getting Highlights");
Dictionary<long, string>? tempHighlights = await apiService.GetMedia(MediaType.Highlights, Dictionary<long, string>? 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 }) if (tempHighlights is { Count: > 0 })
{ {
@ -387,13 +391,19 @@ public class DownloadOrchestrationService(
{ {
Config config = configService.CurrentConfig; Config config = configService.CurrentConfig;
eventHandler.OnMessage("Fetching purchased tab users...");
eventHandler.CancellationToken.ThrowIfCancellationRequested();
Dictionary<string, long> purchasedTabUsers = Dictionary<string, long> 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.OnMessage("Checking folders for users in Purchased Tab");
eventHandler.CancellationToken.ThrowIfCancellationRequested();
foreach (KeyValuePair<string, long> user in purchasedTabUsers) foreach (KeyValuePair<string, long> user in purchasedTabUsers)
{ {
eventHandler.CancellationToken.ThrowIfCancellationRequested();
string path = ResolveDownloadPath(user.Key); string path = ResolveDownloadPath(user.Key);
Log.Debug($"Download path: {path}"); Log.Debug($"Download path: {path}");
@ -405,7 +415,7 @@ public class DownloadOrchestrationService(
Log.Debug($"Created folder for {user.Key}"); 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); await dbService.CreateDb(path);
} }
@ -415,11 +425,16 @@ public class DownloadOrchestrationService(
Log.Debug($"Download path: {basePath}"); Log.Debug($"Download path: {basePath}");
eventHandler.OnMessage("Fetching purchased tab content...");
eventHandler.CancellationToken.ThrowIfCancellationRequested();
List<PurchasedEntities.PurchasedTabCollection> purchasedTabCollections = List<PurchasedEntities.PurchasedTabCollection> 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) foreach (PurchasedEntities.PurchasedTabCollection purchasedTabCollection in purchasedTabCollections)
{ {
eventHandler.CancellationToken.ThrowIfCancellationRequested();
eventHandler.OnUserStarting(purchasedTabCollection.Username); eventHandler.OnUserStarting(purchasedTabCollection.Username);
string path = ResolveDownloadPath(purchasedTabCollection.Username); string path = ResolveDownloadPath(purchasedTabCollection.Username);
Log.Debug($"Download path: {path}"); Log.Debug($"Download path: {path}");

View File

@ -191,7 +191,7 @@ public class DownloadService(
await OnFFMPEGDownloadComplete(tempFilename, lastModified, folder, path, customFileName, filename, await OnFFMPEGDownloadComplete(tempFilename, lastModified, folder, path, customFileName, filename,
mediaId, apiType, progressReporter); mediaId, apiType, progressReporter);
}; };
await ffmpeg.ExecuteAsync(parameters, CancellationToken.None); await ffmpeg.ExecuteAsync(parameters, progressReporter.CancellationToken);
return await _completionSource.Task; return await _completionSource.Task;
} }
@ -747,9 +747,9 @@ public class DownloadService(
using HttpClient client = new(); using HttpClient client = new();
HttpRequestMessage request = new() { Method = HttpMethod.Get, RequestUri = new Uri(url) }; 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(); 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. // Wrap the body stream with the ThrottledStream to limit read rate.
await using (ThrottledStream throttledStream = new(body, await using (ThrottledStream throttledStream = new(body,
@ -761,14 +761,14 @@ public class DownloadService(
true); true);
byte[] buffer = new byte[16384]; byte[] buffer = new byte[16384];
int read; int read;
while ((read = await throttledStream.ReadAsync(buffer, CancellationToken.None)) > 0) while ((read = await throttledStream.ReadAsync(buffer, progressReporter.CancellationToken)) > 0)
{ {
if (configService.CurrentConfig.ShowScrapeSize) if (configService.CurrentConfig.ShowScrapeSize)
{ {
progressReporter.ReportProgress(read); progressReporter.ReportProgress(read);
} }
await fileStream.WriteAsync(buffer.AsMemory(0, read), CancellationToken.None); await fileStream.WriteAsync(buffer.AsMemory(0, read), progressReporter.CancellationToken);
} }
} }

View File

@ -39,69 +39,69 @@ public interface IApiService
/// <summary> /// <summary>
/// Retrieves media URLs for stories or highlights. /// Retrieves media URLs for stories or highlights.
/// </summary> /// </summary>
Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, string folder); Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, string folder, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves paid posts and their media. /// Retrieves paid posts and their media.
/// </summary> /// </summary>
Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username, Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username,
List<long> paidPostIds, List<long> paidPostIds,
IStatusReporter statusReporter); IStatusReporter statusReporter, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves posts and their media. /// Retrieves posts and their media.
/// </summary> /// </summary>
Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds, Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter); IStatusReporter statusReporter, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves a single post and its media. /// Retrieves a single post and its media.
/// </summary> /// </summary>
Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder); Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves streams and their media. /// Retrieves streams and their media.
/// </summary> /// </summary>
Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds, Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter); IStatusReporter statusReporter, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves archived posts and their media. /// Retrieves archived posts and their media.
/// </summary> /// </summary>
Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder, Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter); IStatusReporter statusReporter, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves messages and their media. /// Retrieves messages and their media.
/// </summary> /// </summary>
Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder, IStatusReporter statusReporter); Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder, IStatusReporter statusReporter, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves paid messages and their media. /// Retrieves paid messages and their media.
/// </summary> /// </summary>
Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder, string username, Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder, string username,
IStatusReporter statusReporter); IStatusReporter statusReporter, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves a single paid message and its media. /// Retrieves a single paid message and its media.
/// </summary> /// </summary>
Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder); Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves users that appear in the Purchased tab. /// Retrieves users that appear in the Purchased tab.
/// </summary> /// </summary>
Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users); Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves Purchased tab content grouped by user. /// Retrieves Purchased tab content grouped by user.
/// </summary> /// </summary>
Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder, Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
Dictionary<string, long> users); Dictionary<string, long> users, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves user information. /// Retrieves user information.
/// </summary> /// </summary>
Task<UserEntities.User?> GetUserInfo(string endpoint); Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves user information by ID. /// Retrieves user information by ID.

View File

@ -8,6 +8,11 @@ namespace OF_DL.Services;
/// </summary> /// </summary>
public interface IDownloadEventHandler public interface IDownloadEventHandler
{ {
/// <summary>
/// Gets the cancellation token for the operation.
/// </summary>
CancellationToken CancellationToken { get; }
/// <summary> /// <summary>
/// Wraps work in a status indicator (spinner) during API fetching. /// Wraps work in a status indicator (spinner) during API fetching.
/// The implementation controls how the status is displayed. /// The implementation controls how the status is displayed.

View File

@ -11,4 +11,9 @@ public interface IProgressReporter
/// </summary> /// </summary>
/// <param name="increment">The amount to increment progress by</param> /// <param name="increment">The amount to increment progress by</param>
void ReportProgress(long increment); void ReportProgress(long increment);
/// <summary>
/// Gets the cancellation token for canceling the operation.
/// </summary>
CancellationToken CancellationToken { get; }
} }

View File

@ -9,8 +9,11 @@ internal sealed class AvaloniaDownloadEventHandler(
Action<string, long, bool> progressStart, Action<string, long, bool> progressStart,
Action<long> progressIncrement, Action<long> progressIncrement,
Action progressStop, Action progressStop,
Func<bool> isCancellationRequested) : IDownloadEventHandler Func<bool> isCancellationRequested,
CancellationToken cancellationToken) : IDownloadEventHandler
{ {
public CancellationToken CancellationToken { get; } = cancellationToken;
public async Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work) public async Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work)
{ {
ThrowIfCancellationRequested(); ThrowIfCancellationRequested();
@ -33,7 +36,7 @@ internal sealed class AvaloniaDownloadEventHandler(
progressStart(description, maxValue, showSize); progressStart(description, maxValue, showSize);
try try
{ {
AvaloniaProgressReporter reporter = new(progressIncrement, isCancellationRequested); AvaloniaProgressReporter reporter = new(progressIncrement, isCancellationRequested, cancellationToken);
return await work(reporter); return await work(reporter);
} }
finally finally

View File

@ -4,8 +4,11 @@ namespace OF_DL.Gui.Services;
internal sealed class AvaloniaProgressReporter( internal sealed class AvaloniaProgressReporter(
Action<long> reportAction, Action<long> reportAction,
Func<bool> isCancellationRequested) : IProgressReporter Func<bool> isCancellationRequested,
CancellationToken cancellationToken) : IProgressReporter
{ {
public CancellationToken CancellationToken { get; } = cancellationToken;
public void ReportProgress(long increment) public void ReportProgress(long increment)
{ {
if (isCancellationRequested()) if (isCancellationRequested())

View File

@ -598,6 +598,17 @@ public partial class MainWindowViewModel(
AppendLog(downloadPurchasedTabOnly AppendLog(downloadPurchasedTabOnly
? "Starting Purchased Tab download." ? "Starting Purchased Tab download."
: $"Starting download for {selectedUsers.Count} users."); : $"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( AvaloniaDownloadEventHandler eventHandler = new(
AppendLog, AppendLog,
@ -605,7 +616,8 @@ public partial class MainWindowViewModel(
StartDownloadProgress, StartDownloadProgress,
IncrementDownloadProgress, IncrementDownloadProgress,
StopDownloadProgress, StopDownloadProgress,
() => _workCancellationSource?.IsCancellationRequested == true); () => _workCancellationSource?.IsCancellationRequested == true,
_workCancellationSource.Token);
try try
{ {

View File

@ -773,16 +773,18 @@
<TextBlock FontWeight="SemiBold" Foreground="#1F2A44" Text="Users" /> <TextBlock FontWeight="SemiBold" Foreground="#1F2A44" Text="Users" />
<CheckBox IsThreeState="True" <CheckBox IsThreeState="True"
IsChecked="{Binding AllUsersSelected}" IsChecked="{Binding AllUsersSelected}"
Content="{Binding SelectedUsersSummary}" /> Content="{Binding SelectedUsersSummary}"
IsEnabled="{Binding !IsDownloading}" />
</StackPanel> </StackPanel>
<ComboBox Grid.Column="1" <ComboBox Grid.Column="1"
Width="280" Width="280"
VerticalAlignment="Center" VerticalAlignment="Center"
PlaceholderText="Select a list of users" PlaceholderText="Select a list of users"
ItemsSource="{Binding UserLists}" ItemsSource="{Binding UserLists}"
SelectedItem="{Binding SelectedListName}" /> SelectedItem="{Binding SelectedListName}"
IsEnabled="{Binding !IsDownloading}" />
</Grid> </Grid>
<ListBox Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding AvailableUsers}"> <ListBox Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding AvailableUsers}" IsEnabled="{Binding !IsDownloading}">
<ListBox.Styles> <ListBox.Styles>
<Style Selector="ListBoxItem"> <Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" /> <Setter Property="Padding" Value="0" />

View File

@ -42,6 +42,8 @@ internal sealed class ProgressRecorder : IProgressReporter
{ {
public long Total { get; private set; } public long Total { get; private set; }
public CancellationToken CancellationToken { get; } = CancellationToken.None;
public void ReportProgress(long increment) => Total += increment; public void ReportProgress(long increment) => Total += increment;
} }
@ -138,44 +140,44 @@ internal sealed class StaticApiService : IApiService
new() { { "X-Test", "value" } }; new() { { "X-Test", "value" } };
public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username,
string folder) => Task.FromResult(MediaToReturn); string folder, CancellationToken cancellationToken = default) => Task.FromResult(MediaToReturn);
public Task<Dictionary<string, long>?> GetLists(string endpoint) => throw new NotImplementedException(); public Task<Dictionary<string, long>?> GetLists(string endpoint) => throw new NotImplementedException();
public Task<List<string>?> GetListUsers(string endpoint) => throw new NotImplementedException(); public Task<List<string>?> GetListUsers(string endpoint) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Purchased.PaidPostCollection> GetPaidPosts(string endpoint, string folder, public Task<OF_DL.Models.Entities.Purchased.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
string username, List<long> paidPostIds, IStatusReporter statusReporter) => string username, List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Posts.PostCollection> GetPosts(string endpoint, string folder, public Task<OF_DL.Models.Entities.Posts.PostCollection> GetPosts(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter) => throw new NotImplementedException(); List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Posts.SinglePostCollection> GetPost(string endpoint, string folder) => public Task<OF_DL.Models.Entities.Posts.SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Streams.StreamsCollection> GetStreams(string endpoint, string folder, public Task<OF_DL.Models.Entities.Streams.StreamsCollection> GetStreams(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter) => throw new NotImplementedException(); List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Archived.ArchivedCollection> GetArchived(string endpoint, string folder, public Task<OF_DL.Models.Entities.Archived.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter) => throw new NotImplementedException(); IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Messages.MessageCollection> GetMessages(string endpoint, string folder, public Task<OF_DL.Models.Entities.Messages.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter) => throw new NotImplementedException(); IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Purchased.PaidMessageCollection> GetPaidMessages(string endpoint, public Task<OF_DL.Models.Entities.Purchased.PaidMessageCollection> GetPaidMessages(string endpoint,
string folder, string username, IStatusReporter statusReporter) => throw new NotImplementedException(); string folder, string username, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Purchased.SinglePaidMessageCollection> GetPaidMessage(string endpoint, public Task<OF_DL.Models.Entities.Purchased.SinglePaidMessageCollection> GetPaidMessage(string endpoint,
string folder) => throw new NotImplementedException(); string folder, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users) => public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<List<OF_DL.Models.Entities.Purchased.PurchasedTabCollection>> GetPurchasedTab(string endpoint, public Task<List<OF_DL.Models.Entities.Purchased.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
string folder, Dictionary<string, long> users) => throw new NotImplementedException(); string folder, Dictionary<string, long> users, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<UserEntities.User?> GetUserInfo(string endpoint) => public Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<JObject?> GetUserInfoById(string endpoint) => public Task<JObject?> GetUserInfoById(string endpoint) =>
@ -217,52 +219,52 @@ internal sealed class ConfigurableApiService : IApiService
ListUsersHandler?.Invoke(endpoint) ?? Task.FromResult<List<string>?>(null); ListUsersHandler?.Invoke(endpoint) ?? Task.FromResult<List<string>?>(null);
public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username, public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username,
string folder) => string folder, CancellationToken cancellationToken = default) =>
MediaHandler?.Invoke(mediaType, endpoint, username, folder) ?? MediaHandler?.Invoke(mediaType, endpoint, username, folder) ??
Task.FromResult<Dictionary<long, string>?>(null); Task.FromResult<Dictionary<long, string>?>(null);
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder) => public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default) =>
PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection()); PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection());
public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder) => public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default) =>
PaidMessageHandler?.Invoke(endpoint, folder) ?? PaidMessageHandler?.Invoke(endpoint, folder) ??
Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection()); Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection());
public Task<UserEntities.User?> GetUserInfo(string endpoint) => public Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default) =>
UserInfoHandler?.Invoke(endpoint) ?? Task.FromResult<UserEntities.User?>(null); UserInfoHandler?.Invoke(endpoint) ?? Task.FromResult<UserEntities.User?>(null);
public Task<JObject?> GetUserInfoById(string endpoint) => public Task<JObject?> GetUserInfoById(string endpoint) =>
UserInfoByIdHandler?.Invoke(endpoint) ?? Task.FromResult<JObject?>(null); UserInfoByIdHandler?.Invoke(endpoint) ?? Task.FromResult<JObject?>(null);
public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username, public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username,
List<long> paidPostIds, IStatusReporter statusReporter) => List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds, public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter) => IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds, public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter) => IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder, public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter) => IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder, public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter) => IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder, public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder,
string username, IStatusReporter statusReporter) => string username, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users) => public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder, public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
Dictionary<string, long> users) => Dictionary<string, long> users, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Dictionary<string, string> GetDynamicHeaders(string path, string queryParam) => public Dictionary<string, string> GetDynamicHeaders(string path, string queryParam) =>
@ -427,6 +429,8 @@ internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
public List<(string contentType, DownloadResult result)> DownloadCompletes { get; } = []; public List<(string contentType, DownloadResult result)> DownloadCompletes { get; } = [];
public List<(string description, long maxValue, bool showSize)> ProgressCalls { get; } = []; public List<(string description, long maxValue, bool showSize)> ProgressCalls { get; } = [];
public CancellationToken CancellationToken { get; } = CancellationToken.None;
public Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work) => public Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work) =>
work(new RecordingStatusReporter(statusMessage)); work(new RecordingStatusReporter(statusMessage));

View File

@ -10,6 +10,8 @@ namespace OF_DL.CLI;
/// </summary> /// </summary>
public class SpectreDownloadEventHandler : IDownloadEventHandler public class SpectreDownloadEventHandler : IDownloadEventHandler
{ {
public CancellationToken CancellationToken { get; } = CancellationToken.None;
public async Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work) public async Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work)
{ {
TaskCompletionSource<T> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource<T> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);

View File

@ -6,9 +6,11 @@ namespace OF_DL.CLI;
/// <summary> /// <summary>
/// Implementation of IProgressReporter that uses Spectre.Console's ProgressTask for CLI output. /// Implementation of IProgressReporter that uses Spectre.Console's ProgressTask for CLI output.
/// </summary> /// </summary>
public class SpectreProgressReporter(ProgressTask task) : IProgressReporter public class SpectreProgressReporter(ProgressTask task, CancellationToken cancellationToken = default) : IProgressReporter
{ {
private readonly ProgressTask _task = task ?? throw new ArgumentNullException(nameof(task)); private readonly ProgressTask _task = task ?? throw new ArgumentNullException(nameof(task));
public CancellationToken CancellationToken { get; } = cancellationToken;
public void ReportProgress(long increment) => _task.Increment(increment); public void ReportProgress(long increment) => _task.Increment(increment);
} }