diff --git a/.vitepress/config.js b/.vitepress/config.js
index bbb59d24..2a06e74a 100644
--- a/.vitepress/config.js
+++ b/.vitepress/config.js
@@ -99,6 +99,10 @@ export default defineConfig({
link: "/docs/features/daily-download-limit",
},
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
+ {
+ text: "Parallel Downloads",
+ link: "/docs/features/parallel-downloads",
+ },
{
text: "Retrying Refused Downloads",
link: "/docs/features/retrying-refused-downloads",
diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs
index 7969640d..1cc6752b 100644
--- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs
+++ b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs
@@ -155,8 +155,8 @@ public partial class ProcessQueueControl : UserControl
public async void CancelAllBtn_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
- // Cancels every active book, not just Current - with parallel downloads there
- // can be several running at once.
+ // Cancels every active book, not just the first one - with parallel downloads
+ // there can be several running at once.
if (_viewModel is ProcessQueueViewModel vm)
await vm.CancelAllAsync();
}
diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs
index 462cf3ed..e4f87bac 100644
--- a/Source/LibationFileManager/Configuration.PersistentSettings.cs
+++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs
@@ -431,10 +431,11 @@ public partial class Configuration
public const int DefaultConcurrentDownloads = 3;
///
- /// The highest is worth running on this machine, for use as
- /// the bound on a spinner. Deliberately not applied to the stored value: a setting saved
- /// on an eight-core desktop must survive being opened on a two-core laptop or in a container and
- /// come back intact, rather than being silently rewritten to what that machine could manage.
+ /// The highest is worth running on this machine. Applied only
+ /// where it bites - when the queue decides how many books to start - and deliberately not
+ /// to the stored value or to any spinner's bound: a setting saved on an eight-core desktop must
+ /// survive being opened on a two-core laptop or in a container and come back intact, rather than
+ /// being silently rewritten to what that machine could manage.
///
///
/// Processor count is also not the default. Downloading is bound by Audible's license throttling
diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs
index 7e624253..e6104ab5 100644
--- a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs
+++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs
@@ -152,8 +152,8 @@ public class ProcessQueueViewModel : ReactiveObject
: 0;
config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024);
- // Apply to all currently active books. Over a copy: the speed limit is changed from the UI
- // thread while book tasks start and finish, and Active is the live list.
+ // Apply to all currently active books. Over a copy, because the speed limit is changed from
+ // the UI thread while book tasks start and finish, which mutates the queue's active list.
foreach (var activeBook in Queue.GetActive().OfType())
activeBook.Configuration.DownloadSpeedLimit = config.DownloadSpeedLimit;
@@ -717,10 +717,12 @@ public class ProcessQueueViewModel : ReactiveObject
var result = await ProcessBookHandler(book);
- // Claimed before the queue is touched, and before the logging. Only the book that
- // actually answered Abort tears the queue down: Abort is a session-wide override now, so
- // every book in flight arrives here - directly or by inheriting that answer - and each one
- // re-entering CancelAllAsync would have every book asking every other book to cancel.
+ // Claimed before the queue is touched, and before the logging. Exactly one book tears the
+ // queue down: Abort is a session-wide override now, so every book in flight arrives here -
+ // directly or by inheriting that answer - and each one re-entering CancelAllAsync would
+ // have every book asking every other book to cancel. The winner is whichever book reaches
+ // this line first, not necessarily the one whose dialog was answered; they are
+ // interchangeable here, and racing to identify the answering book would buy nothing.
// Claiming this early also keeps the window small in which the loop can start another
// book, which would then outlive the abort by starting after CancelAllAsync snapshots
// what to cancel.
@@ -744,8 +746,8 @@ public class ProcessQueueViewModel : ReactiveObject
await CancelAllAsync(book);
else
{
- // Inherited the abort rather than answering it. It was cancelled by the book
- // that did, and that is what it should report.
+ // Inherited the abort rather than triggering the teardown. It was cancelled by
+ // the book that did, and that is what it should report.
book.Result = ProcessBookResult.Cancelled;
book.Status = ProcessBookStatus.Cancelled;
}
diff --git a/Source/_Tests/LibationUiBase.Tests/ProcessQueueDispatchTests.cs b/Source/_Tests/LibationUiBase.Tests/ProcessQueueDispatchTests.cs
index 324fa3ee..09a72a0e 100644
--- a/Source/_Tests/LibationUiBase.Tests/ProcessQueueDispatchTests.cs
+++ b/Source/_Tests/LibationUiBase.Tests/ProcessQueueDispatchTests.cs
@@ -70,8 +70,10 @@ public class ProcessQueueDispatchTests
public Task Handle(ProcessBookViewModel book)
{
var asin = book.LibraryBook.Book.AudibleProductId.ToString()!;
- Started.Enqueue(asin);
+ // Count first, then publish. WaitForStarted polls Started, so enqueuing before the
+ // counter moves lets a test wake up in the gap and assert a HighWaterMark that is one
+ // short of the books it just waited for.
lock (countLock)
{
running++;
@@ -79,6 +81,8 @@ public class ProcessQueueDispatchTests
HighWaterMark = running;
}
+ Started.Enqueue(asin);
+
return GateFor(asin).Task.ContinueWith(t =>
{
lock (countLock) running--;