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}" />
-
+