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--;