Commit Graph
1961 Commits
Author SHA1 Message Date
Cursor Agentandrmcrackan 1c32149c2b Update Dinah.Core to 10.2.6.1
No code change needed. 10.2.6.1 replaces the Pluralize.NET dependency
with an internal pluralizer inside Dinah.Core; the Pluralize and
PluralizeWithCount extensions this repo uses behave the same, which the
existing tests that assert on their output confirm.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:58:28 +00:00
Cursor Agentandrmcrackan 612ec05b19 Stop a download-only upgrade notice from starting an install
When Libation cannot install an upgrade itself - a portable or AppImage
install, a Linux build with no package-manager symlink, macOS outside
/Applications - the prompt is a notice with a download link, and Chardonnay
relabels its button from "Yes" to "OK" to say so. The button still closed with
DialogResult.OK, which MainWindow and the About window both read as "yes,
install it", so acknowledging the notice downloaded the release and ran the
auto-upgrader anyway. That could only fail, and it failed loudly: an "Upgrade
Failed" error box in answer to dismissing a notice.

The button now closes with Cancel when there was no upgrade on offer, so the
answer matches the question. Classic already did the equivalent, hiding its Yes
button and relabelling No.

UpgraderBase gets the matching guard. It already refused to install under
Windows Application Control on the grounds that a UI ignoring CapUpgrade must
not be able to start an upgrade that leaves Libation unable to start; that
reasoning covers every capped upgrade, not just the Windows one, so the stop is
now keyed on CapUpgrade itself via MayInstallUpgrade.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-25 22:05:59 +00:00
Cursor Agentandrmcrackan e4dde1e6c8 Point the update-check copy at Settings > About, not Help > About
Both Chardonnay and Classic list About... under the Settings menu; neither
has a Help menu.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-25 21:47:31 +00:00
Cursor Agentandrmcrackan c2f9392fa8 Let users turn off the startup update check
Libation asked GitHub for a newer release every time it started, with no way
to stop it. That is noise for anyone whose install is updated by something
else - a package manager, or an AppImage updater - because the prompt it
raises is one they can do nothing useful with.

Add CheckForUpgradesAtStartup, on by default so nothing changes for people
who rely on the prompt. Only the automatic check is optional: the About
window's "Check for Upgrade" button and the CLI's `version --check` ask for a
check outright, so they run either way. That is why the setting is read in a
new CheckForUpgradeAtStartupAsync rather than inside CheckForUpgradeAsync,
which the startup path and the About button share.

The new setting takes the slot of BetaOptIn, which is deleted here. It was
declared, described and logged, but no axaml or designer ever bound it and
nothing read the value: GetLatestRelease only ever asks for the stable
release, so there was no beta channel for it to select. A stale BetaOptIn key
in an existing Settings.json needs no migration, since PersistentDictionary
ignores keys with no matching property.

Closes #1999

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-25 21:09:23 +00:00
rmcrackan e965cc6c59 Merge pull request #1998 from rmcrackan/cursor/rename-not-downloaded-to-download-pending-56c9
Rename the "Not Downloaded" status label to "Download Pending"
2026-08-24 22:53:32 -04:00
Cursor Agentandrmcrackan e0c780c998 Make --download-pending the CLI flag, with --not-downloaded as a legacy alias
The status this flag sets is now called 'Download Pending', so -p /
--download-pending is the name the help offers.

-n / --not-downloaded keeps working. It is what years of scripts, forum
answers and issue comments tell people to run, so breaking it would cost
more than the inconsistency is worth. It is hidden from --help so the new
name is the only one advertised, and both flags feed one SetPending
property that the verb acts on.

All three names stay in the 'Download Status' option group, so 'at least
one status flag is required' still holds. That error message does
enumerate the group, which is the one place the legacy name still
surfaces.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-25 02:42:56 +00:00
Cursor Agentandrmcrackan f870612f92 Rename the 'Not Downloaded' status label to 'Download Pending'
'Not Downloaded' reads as a claim about the file on disk, but the status
it names is really an instruction about the future. Telling someone with
a finished audiobook to set it 'Not Downloaded' asks them to assert
something they know is false, which is why the question keeps coming up.

'Download Pending' says the same thing about intent without saying
anything untrue about the file, and stands alone in a dropdown or a
support reply where bare 'Pending' would not.

The context menu carriers move from "Set Download status to 'X'" to
"Mark as 'X'" so the word does not land twice in one breath, with the
accelerator on P to stay clear of Downloaded's D. The persisted enum,
the --not-downloaded CLI flag and the IsLiberated search tags are
unchanged, so scripts and saved quick filters keep working.

WinForms status combo boxes grow from 121 to 150px and the
better-quality Mark button from 210 to 240px to fit the longer label.

Docs carry 'previously "Not Downloaded"' on first mention, since years
of Reddit and GitHub answers use the old name.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 23:29:28 +00:00
rmcrackan 2497e3a03e Merge pull request #1997 from rmcrackan/cursor/cover-a-date-shaped-title-in-search-011e
test: cover a title made of digits and punctuation
2026-08-24 19:26:05 -04:00
Cursor Agentandrmcrackan 1431fd73a9 test: cover a title made of digits and punctuation
Stephen King's 11/22/63 sits either side of the line the number handling draws.
The analyzer keeps a date like this as one token, so the title is text and must
not be zero-padded, while "title:11*" is a bare number in a text field and must
not be padded either - that one fails against the code from before the numeric
fix, going looking for "title:00000011.00*".

Audible lists it with slashes and a hyphenated spelling turns up elsewhere, so
both are covered, as are books 11, 22 and 63 minutes long so that nothing can
pass by matching a number field instead of the title.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 22:37:43 +00:00
Cursor Agentandrmcrackan 04a7103ae8 Bump version to 13.7.11
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 22:07:52 +00:00
Cursor Agentandrmcrackan bf017ac28c fix: restore the LibationWinForms build
setLiberatedVisibleMenuItem takes the books to count as a parameter, but the
body that reads it was extracted into setLiberatedVisibleMenuItemCore, which
did not, so the extracted method referenced a name that is not in its scope.

Two changes landed on the same method within hours and git merged them with no
textual conflict: 75f1d753 pulled the body out behind a try/catch, and #1958
brought in the 'visible ??=' fallback that the thread-safety fix had added to
the body back in June. Neither is wrong on its own.

Thread the parameter through. The whole solution builds again.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 20:51:09 +00:00
Cursor Agentandrmcrackan a0045156c1 docs: drop Neil Gaiman from the examples
Replaced with Stephen Fry, who the search and naming-template docs already use
as a narrator and who writes and narrates his own books, so the author-narrator
examples still say what they meant to.

The one code mention is a comment describing how Audible builds a contributor
URL. It used his real author page and id; the point being made is that the name
in the path is decorative, so a placeholder id makes it better, not worse.

Two things fell out of editing the narrator format line. Its template printed
'\) ' - a backslash inside quotes that already make the paren literal - so it
produced "Arthur (Conan\) Doyle", not the output printed beside it. And the
line is raw HTML rather than backticks, so markdown ate the backslash before
the opening paren, leaving the website showing a different template from the
markdown. Both spellings produce the documented output; the line now uses the
one that survives rendering, so source and page agree.

The screenshot for the author-narrator search showed a Gaiman query, so it is
replaced by the query inline. The now-unreferenced image is removed with it.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 20:31:29 +00:00
rmcrackan 8402010a2a Merge pull request #1992 from rmcrackan/cursor/parse-a-field-name-used-as-a-value-011e
fix: three search query bugs (field names as values, number padding, grouped negation)
2026-08-24 16:19:36 -04:00
Cursor Agentandrmcrackan 59ab316358 docs: rewrite the Lucene syntax guide around Libation's own fields
It was a generic primer whose examples used title, body and mod_date. Only one
of those exists here, so half the guide could not be typed into the search box.
Every example is now a real field and was run against an index before being
written down, including the exclusive range and the leading-wildcard error the
old text described but never showed.

Covers the two forms that reach the new number handling and had no test: a
wildcard on a numeric title, and a curly-brace range.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 20:14:44 +00:00
rmcrackan b56e55bee2 Merge pull request #1991 from rmcrackan/cursor/repair-a-search-index-missing-books-011e
fix: repair a search index that is missing books (#1989)
2026-08-24 16:01:04 -04:00
Cursor Agentandrmcrackan cad813e28b test: cover a book found through Hours, not just LengthInMinutes
A bare number has to keep every book it already matched and add the one whose
title is that number. The library had a book 14 minutes long but none 14 hours
long, so the Hours field went untested - the same field that made an earlier
draft of these tests pass against the bug by accident.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 19:51:45 +00:00
Cursor Agentandrmcrackan 0e6523e0ae fix: a number is only zero-padded when the field it is compared against is
Lucene 3 has no numeric type, so a number field is indexed zero-padded to make
a range sort: 600 minutes is stored as "00000600.00". Every other field keeps
the number as written, so the novel "1984" is stored as "1984". Padding every
number in a query regardless of the field it was being compared against made a
numeric title unfindable - "title:1984" looked for "00001984.00" among the
titles - and a bare "1984" could only ever mean "some number field is 1984".

A number now takes the spelling its field is indexed in. With no field named,
the default field holds both spellings, so both are searched. A range and a
phrase cannot hold that disjunction: a range stays padded, a phrase does not.

Expanding a bare number to a group exposed the pure-negation workaround, which
judged a query by every clause in its tree, so "-(a OR b)" was read as mixed
and returned nothing. It is now decided per query by that query's own clauses.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 19:26:25 +00:00
Cursor Agentandrmcrackan 0489be0350 fix: searching a field for a word that names another field
Search field names are ordinary words - absent, podcast, plus, series - and
a book's own text is full of them. The sanitizer read any known field name as
a field name wherever it appeared, so a value in that position picked up the
implied ":True" of a bool field: "title:absent" became "title:absent:True",
which Lucene refuses to parse, and "title:\"absent friend\"" quietly matched
nothing. A colon straight after a field name now marks the next term as that
field's value.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 18:41:40 +00:00
Cursor Agentandrmcrackan a0ef5c3d95 fix: repair a search index that is missing books
A book the index does not hold cannot be found by any positive filter, and
every negated filter - which resolves to every document in the index - drops
it from the grid instead. That is why issue #1989 reads as half a working
filter: Absent found nothing while -Absent removed exactly the absent books.

The index is only ever written as a whole, and a rebuild that fails is
deliberately swallowed so a bad index cannot fail a good scan, so a short
index stayed short until something else changed the library. Count the
index against the library once per run and rebuild when it is short.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 18:00:52 +00:00
Cursor Agentandrmcrackan d4248200d1 fix(queue): report the books an enqueue added, and where they start
Both halves of the Add notification were wrong. The index was read after the range
had been added, so it pointed past the end by the size of the batch - queueing two
books into a list of four announced them at index 6. And the parameter is IList<T>,
which does not implement the non-generic IList, so the compiler bound the
changedItem overload and the event named the list object itself as the single item
added rather than the books in it.

Both UIs survived it: WinForms discards the event and re-reads, and Avalonia
evidently falls back to re-reading too. But this class exists to give an index-based
consumer something it can follow, and this was the one notification it could not.

The list is also copied now, so the event does not hand out a reference the caller
can still mutate.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 17:52:44 +00:00
Cursor Agentandrmcrackan aecffc4584 refactor(queue): name the pending helpers, and log a stray completion outside the lock
Pend(int) and PendQueued(int) differed only in which counter they stood for, which
is the kind of pair someone eventually calls the wrong half of. They are now
PendCompletedCount and PendQueuedCount.

MarkCompleted logged its "not active" case while holding lockObject. A Serilog sink
can be slow and the UI thread takes that lock on every read of Count, IndexOf and
the indexer, so the write happens after the lock is released.

No behaviour change.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 17:52:32 +00:00
rmcrackan 819193fe03 Merge pull request #1958 from dmatlock171/parallel-downloads-rebased
Parallel downloads rebased
2026-08-24 13:41:58 -04:00
Allamagoosa 76e036fb42 Report the abort on the book the user answered for
With several books in flight, the book whose dialog was answered showed
"Cancelled" while some unrelated book showed "Error, Abort". Tearing the queue
down is claimed by whichever book finishes first, and the books genuinely are
interchangeable for that. The status left on a row is not: it is read afterwards
by someone who remembers which book they were asked about.

The two jobs are now separate. ShowRetryDialogAsync records the answering book as
BadBookSessionContext.AbortOriginator, written before Override so that a book
reading Override and racing ahead to the queue loop cannot find the originator
still unset and conclude there was none. The loop gives the abort to that book and
Cancelled to every book that inherited the answer. ClaimAbort is unchanged and
still decides the teardown.

With no dialog in play - Bad Book set to Abort in settings - nobody answered
anything, there is no originator, and the book that claimed the teardown keeps the
abort as before.
2026-08-24 10:02:46 -07:00
Allamagoosa 5e1ac3f9cc Keep the concurrency hint inside the width it has
"(4 on this machine)" sits in whatever the two spinners leave of a fixed 400px
pane. That fit on one machine and ellipsed to "(4 on this machi..." on another,
which left the hint existing only in the tooltip on exactly the machines it was
written for.

Now "(4 at a time)": six characters shorter, and short enough to fit that column
with room over rather than by a hair. Why the number is smaller than the setting
is what the tooltip is for; the number itself is what has to be legible. Classic
reads the same string into its tooltip, so both UIs move together.

Layout geometry is untouched on purpose - the spinner widths and the row structure
are the part that was reported as clean.
2026-08-24 10:02:46 -07:00
Allamagoosa 326e48c663 Deliver queue notifications outside the lock a handler can block behind
DispatchPending held dispatchLock across delivery. With NotificationInvoker null,
Deliver runs inline on the mutating thread, so a handler that blocks - WinForms'
RefreshDisplay does - sat inside that lock while the UI thread it was waiting on
blocked trying to enter the same lock to deliver its own mutation. Each waiting on
the other. Unreachable while both UIs set the invoker, but it should not be one
assignment away from reachable.

The claim is now a _draining flag, set under lockObject in the same critical
section that takes the batch. One thread at a time delivers, and a thread that
finds the flag set returns immediately rather than blocking, leaving its
notifications for the draining thread's next pass. Delivery order is unchanged:
the drainer re-checks _pending after clearing the flag, under the same lock that
pending is appended beneath, so a thread that left without delivering cannot lose
what it pended.

A flag is also not reentrant the way a lock is, so a handler that mutates the
queue can no longer deliver its own notification ahead of the rest of the batch it
is standing in.
2026-08-24 10:02:46 -07:00
Allamagoosa c376af9a95 Stop two queue tests racing the notification they assert on
Both asserted queue.QueuedCount, which is the view model's mirror of the queued
count rather than the queue itself. It arrives through the posted notification
path, and TestInitialize installs a bare SynchronizationContext that posts to the
thread pool - so the assert raced delivery and lost most of the time. Five of six
runs red locally; on CI, six of nine build jobs reached the unit test step and all
six failed here.

They now assert queue.Queue.Count, which is read under the queue's own lock and is
already true by the time the loop returns. Polling it with the Patience deadline
would have worked too, but there is nothing left to wait for once the assertion
reads the structure instead of its mirror.

No product code changes. QueuedCount is delivered through the posted path
deliberately - that is what keeps a bound list in mutation order.
2026-08-24 10:02:46 -07:00
rmcrackan c6e5e37f7f Merge pull request #1988 from rmcrackan/cursor/keep-a-failing-books-volume-non-fatal-74f7
Keep a failing Books volume from closing Libation
2026-08-24 11:10:19 -04:00
Cursor Agentandrmcrackan df9702997d test: stop asserting on path spelling, which differs on Windows
LongPath rewrites '/' to '\' on Windows, so comparing against hardcoded unix
paths failed there. The test is about which entries survive a truncated walk
and in what order, not about how they are spelled.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 14:56:50 +00:00
Cursor Agentandrmcrackan 1839eebb20 feat: say so when the Books folder is there but cannot be read
A pulled or failing drive still answers that it is a directory, and a listing of
it comes back empty rather than refusing, so Libation would start a download
against an unreadable folder and report a full library as having nothing
downloaded. Check before queueing and name the drive as the likely cause.

See issue #1984.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 14:22:08 +00:00
Cursor Agentandrmcrackan 75f1d753b1 fix: counting the visible books can no longer close Libation
Both UIs recount from async void event handlers, where a failure is not a
faulted task anyone awaits but an unhandled exception. The count reads the file
system, so a Books folder on a drive that was just unplugged was enough to end
the session.

See issue #1984.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 14:22:08 +00:00
Cursor Agentandrmcrackan cc40d5e494 fix: an unreadable Books folder no longer poisons AudibleFileStorage
Building the Books file cache from a static field initializer meant any failure
became a TypeInitializationException, which the runtime caches for the life of
the process and rethrows at every later reader of the type. A USB drive that
started failing mid-session therefore crashed Libation on every launch after
that, in the startup logging, before the window appeared.

See issue #1984.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 14:22:08 +00:00
Cursor Agentandrmcrackan 5ef3f3e11e fix: a directory that stops being readable no longer throws at whoever lists it
SaferEnumerateFiles returned a lazy sequence, so an I/O error was raised where
the sequence was walked rather than where it was created - past the try/catch
callers had wrapped around it. IgnoreInaccessible did not help either: it only
forgives permissions, not a volume that has stopped answering.

Walk the enumerator defensively instead, keeping what was read and reporting
the reason, and let a caller ask whether a directory can be read at all.

See issue #1984.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 14:21:58 +00:00
Cursor Agentandrmcrackan 03dab18223 test fix: give the anchored evil-regex row an input that cannot beat the timeout
^(a+)+$ against 22 a's is only ~2x the 100ms match timeout, so whether
CatastrophicBacktracking_AnchoredRepeated passes depends on how fast the machine
is: a fast box completes the match, nothing throws, and the row fails its own
Assert.Fail. Every added character doubles the work, so 50 a's puts it in the
same never-completes class as the other evil-regex rows, the same treatment
9ed98cbf already gave (a+a+)+b.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 14:09:30 +00:00
Allamagoosa 492d2e9ae3 Cancel the book, not the queue, at the daily download limit
The gate read a queue-wide cancelAllRequested flag, which then had to be cleared
again - and whatever clears it can un-cancel a book that is still parked. Clearing
it at the end of the cancellation drain was the window you described; scoping it to
the run moved that window into AddToQueue, which cleared it so newly queued books
would not inherit an old cancellation. Same bug, different trigger.

WaitForDailyLimitAsync already waits on behalf of one specific book, so it now asks
whether that book was cancelled. Cancel All reaches a parked book through
ProcessBookViewModel.CancelAsync like any other active book - it is on the active
list, having been dequeued before the gate - so nothing has to be set or cleared at
the queue level and the flag is gone entirely, along with both of its clear sites.
Books queued afterwards are untouched by construction rather than by timing.

CancelAsync records the request before its early return, because a book that has
not started a step is precisely the case that matters. That also makes cancelling
a single parked book work, which it previously did not.
2026-08-21 07:50:14 -07:00
Allamagoosa b6875591f0 Stop the dispatch tests measuring the CI runner instead of the loop
EffectiveConcurrentDownloads clamps the setting by Environment.ProcessorCount, so
a test asking for three books at once starts only two on a small machine, waits
out its ten second patience and fails. Reproduced with DOTNET_PROCESSOR_COUNT=2:
three failures and a thirty-four second run, against five seconds and none on a
sixteen core box. The CI matrix includes runners small enough to hit it.

Machine capability is now overridable, and the dispatch tests pin it to the
concurrency they are asking about. The clamp itself is unchanged in the app.

Two tests for the seam while it is there, since neither the point-of-use clamp
nor the hint had any coverage: that a machine smaller than the setting holds the
loop down without rewriting what the user asked for, and that ConcurrencyHint
says so and falls silent when the machine can keep up.
2026-08-21 07:18:33 -07:00
Allamagoosa 29a9cb3873 Fix the sidebar entry, a test race, and comments left pointing at deleted members
The docs page was linked from docs/index.md but missing from the VitePress sidebar, where every sibling feature page is listed.

In the dispatch test fake, Started.Enqueue ran before the counter incremented, so WaitForStarted could return in the gap and the HighWaterMark assertion could observe one book fewer than had started.

Comments still named TrackedQueue.Current and .Active after both were deleted, the Configuration doc comment still called itself the bound on a spinner when nothing binds it any more, and the ClaimAbort comment said the winner was the book that answered the dialog when it is really whichever book gets there first.
2026-08-21 06:33:31 -07:00
Allamagoosa 8b166312c6 Pin down the dispatch loop with tests
All the risk parallel downloads added lives in the dispatch loop, and none
of it was reachable while the only way to run a book was to download one.
Driven through ProcessBookHandler, every book here finishes when the test
says so and never touches the network, the database or the disk. The loop
is the real one.

Covers the capacity cap, including lowering it mid-run while more books
than the new cap are still in flight; the enqueue signal, both for books
queued into free slots and for one arriving as the loop winds down; the
abort drain, that it clears the queue and the loop still comes back rather
than dying inside it; that only the book which answered Abort claims it
while the rest report Cancelled; that a faulted book task is observed
instead of taking the loop out through its outer catch; and Cancel All.

The abort test runs one book at a time on purpose. With more, a book
finishing in the same instant as the abort lets the loop take one more off
the queue before the queue is cleared - the narrow window noted in the
abort commit - and that is not what this test is about.
2026-08-20 17:13:44 -07:00
Allamagoosa f816ccf3e4 Clear the review nits
Deleted rather than documented: TrackedQueue's Current, Active,
ClearCurrent() and MoveNext(). The first three had no callers at all, and
MoveNext() had exactly one - the Avalonia design-mode preview, which now
builds its sample queue from TryDequeueNext and MarkCompleted, the same
calls the dispatch loop makes. The "legacy / kept for compatibility" labels
pointed at callers that stopped existing when the loop stopped being
sequential.

TrackedQueue.Current's doc comment cited the speed limit display as its
reason to exist, which stopped being true when the speed limit moved to
GetActive(). SpeedLimit itself now records why the sequential loop's
per-book read-back was dropped rather than leaving it unexplained: with
several books running there is no single book to read it back from, and
doing so would have whichever started last overwrite what the user typed.

Restored the UTF-8 BOMs on ProcessQueueViewModel.cs and
BadBookSessionContext.cs, and put back the eight "// " separators in the
WinForms designer that had been rewritten to "//". That file's diff is now
identical with and without whitespace, so everything left in it is a real
change.
2026-08-20 17:13:30 -07:00
Allamagoosa 1eddccbe63 Bound concurrency by the hard limit and say what the machine will do
Taking the alternative offered in review. The spinner's maximum is the flat
hard limit now, the same number in both UIs and on every machine, and
machine capability is applied only at the point of use.

Bounding the control by capability made it lie in two directions at once.
Below the stored value, a two-way spinner coerces its display down to its
maximum and writes that back, so opening the panel on a smaller machine
overwrote an 8 chosen on a larger one. Raising the bound to meet the stored
value fixed that but left a stored 8 displaying 8 on a two-core box that
will only ever run 2. The two UIs had also drifted apart: Avalonia bound
Maximum and ratcheted down, WinForms set it once in the constructor, so
within a session you could lower and re-raise in one and not the other.

What is left is the gap between what the setting says and what runs, and
that is now stated rather than hidden. Chardonnay shows "(2 on this
machine)" beside the spinner, in the spare column the settings row already
had. Classic's settings table is full at three columns with no width to
spare, so it says the same thing in the control's tooltip.
2026-08-20 17:13:02 -07:00
Allamagoosa 84c6f6fed0 Scope cancelAllRequested to the run rather than to the drain
Clearing it when the last cancellation settles reopens a worse window than
it closes. A queue parked in WaitForDailyLimitAsync only re-reads the flag
every poll interval, so pressing Cancel All during a limit pause with
nothing in flight lets the drain finish and clear the flag well before the
gate wakes - it sees false and resumes the book that was just cancelled.

It is cleared in QueueLoop's finally instead, so it lasts exactly as long
as the run it belongs to. Queueing more work still withdraws an earlier
Cancel All, which is what AddToQueue's clear is for.
2026-08-20 17:12:47 -07:00
Allamagoosa d36369b321 Make Abort mean the run, and stop it taking the queue loop with it
Abort is a statement about the run, not about the one book being asked
about, so it now becomes the session answer whether or not "apply to all"
was ticked. Without that, a user who aborts with three books in flight is
asked the same question by each of the others, and the run they just
stopped keeps prompting.

That makes every book in flight arrive at the abort path, which the
previous shape could not survive: each one called CancelAllAsync, so every
book asked every other book to cancel. Only the book that claims the abort
tears the queue down now; the rest were cancelled by it, and report
Cancelled rather than each claiming an abort of its own. The claim is made
as soon as the result is known, before the queue is touched, which also
keeps small the window in which the dispatch loop can start another book -
one starting after CancelAllAsync snapshots the active list would outlive
the abort. The window is not closed, only narrowed; it needs a book to
finish in the same instant as the abort, and the queue is cleared behind it.

abortCts was a CancellationTokenSource whose token was never passed to
anything - only Cancel() and IsCancellationRequested were ever used, which
read as though cancellation reached the book tasks when it does not. It is
a plain flag now, written under the result lock and read by the loop, and
says what it means. _resultLock was a local named like a field.

Also adds the seam the loop is tested through: ProcessBookHandler is the
single call the dispatch loop makes into a book, so a fake book can finish
on command without downloading anything. The loop itself is unchanged.
2026-08-20 17:12:38 -07:00
Allamagoosa 5d0ed3af68 Make auto-scroll mean the same thing in both UIs
WinForms had been changed to pin the first active download to the top on every
start, which interrupts anyone who has scrolled further down the queue - the
behaviour master's comment says it deliberately avoids. Avalonia kept master's
gentler version but never read the setting, so Chardonnay's checkbox did nothing.

Both now run master's logic - scroll the new item into view only when the
previous one is visible - gated by AutoScrollQueue. VirtualFlowControl.ScrollToTop
had no callers left, so it goes.
2026-08-20 13:19:32 -07:00
Allamagoosa 059b817b03 Give Chardonnay's queue spinners a row of their own
Stacking the two spinners inside the middle column only made Clear Finished fit
by collapsing the star column that Cancel All lived in, so the button rendered as
"Ca". The pane is a fixed 400px and four groups do not fit on one line.

The spinners now take a full-width row and the buttons sit under them, both in
Auto columns, so neither can be squeezed by whatever is beside it. Auto-scroll
moves down beside Cancel All, which leaves real slack on both rows rather than
the few pixels the previous version was relying on.

Needs eyes at 400px before this is pushed.
2026-08-20 13:18:21 -07:00
Allamagoosa 0c66a2ede9 Drive TrackedQueue ordering through the posted path in tests
The existing tests all assert on inline delivery, which is the path the app never
takes. Adds a fake ISynchronizeInvoke that only queues, so a test can prove
nothing was delivered inline and then run delivery itself, in order, the way the
UI thread would.

Two tests on top of it: that a mutation made on the calling thread is still
posted rather than run inline, and that four books completing on four real
threads leave a Move-following bound list identical to the queue, over 50 runs.
Plus a test that completing a book which is not active changes nothing.
2026-08-20 13:16:44 -07:00
Allamagoosa b38e298ddc Deliver TrackedQueue notifications in mutation order
Indices were computed under lockObject and the events raised after releasing it.
A second item completing in that gap meant two Move events could be delivered in
an order no index-based consumer can replay, turning a queue whose real order is
[B, C, A] into a bound list holding [B, A, B] - a duplicated row and a lost one.
The gap is not tight either: MarkCompleted raises CompletedCountChanged first and
its handler walks Completed twice before the Move goes out.

Mutators now append their notifications to a pending list while they still hold
lockObject, and delivery happens afterwards under a separate dispatch lock, so
whoever drains first delivers everything in mutation order regardless of which
thread does it. Args are built inside the lock too, which incidentally stops
RemoveQueued, ClearQueue and Enqueue reading QueueStartIndex unsynchronised.

Delivery is marshalled through an injected ISynchronizeInvoke, since TrackedQueue
is not a ReactiveObject and has no invoker of its own. ProcessQueueViewModel
supplies a SynchronizeInvoker constructed with alwaysInvoke: true so BeginInvoke
posts unconditionally - a plain invoker runs inline when already on the UI thread,
which would let a UI-thread mutation deliver ahead of events a book thread posted
earlier. Nothing is ever raised while lockObject is held, and never a blocking
Invoke: the UI thread reads Count, IndexOf and the indexer from inside these
handlers and would deadlock a book thread against itself. With no invoker set,
delivery stays inline on the mutating thread, which is what the tests rely on.

MarkCompleted on an item that is not active now logs and returns instead of
appending it to Completed, where it changed Count with no CollectionChanged at
all and silently desynchronised every bound list.

Restores the UTF-8 BOM this file lost.
2026-08-20 13:14:46 -07:00
Allamagoosa e9e56182b8 Stop abort and disk full from killing the queue loop
CancelAsync read CurrentProcessable, which is lazy - _currentProcessable ??=
Processes.Dequeue().Invoke() - so a book past its last step threw "Queue empty."
on read, and the catch threw a second time interpolating CurrentProcessable.Name.
Not a narrow race: every book waiting in the bad book dialog is in that state,
because ProcessOneAsync reaches GetFailureActionAsync from its finally after the
processable loop has drained. The faulted task then surfaced through the abort
branch's Task.WhenAll, took QueueLoop out through its outer catch, and left the
remaining books running with the progress bar still on screen.

Test the _currentProcessable field instead, keep the property out of the catch
message, and isolate each cancellation in CancelAllAsync so one book failing
cannot abandon the rest of the list. Reading the field also stops the cancelling
thread dequeuing from a non-thread-safe Queue<T> while the book's own loop reads it.

Also drops the doubled <summary>/<param> left on CancelAllAsync by an earlier edit.
2026-08-20 13:09:42 -07:00
rmcrackan 819a0960be Merge pull request #1981 from rmcrackan/cursor/keep-file-cache-alive-past-a-vanished-path-a274
fix: one unreadable path no longer stops the file cache tracking anything
2026-08-19 16:31:17 -04:00
rmcrackan 0b03be5d02 Merge pull request #1980 from rmcrackan/cursor/fix-quickfilters-not-loaded-c279
Fix QuickFilters not loading from disk after restart (#1979)
2026-08-19 16:17:29 -04:00
Cursor Agentandrmcrackan 7cb58c7912 fix: one unreadable path no longer stops the file cache tracking anything
All three Windows CI legs failed on master while the other six passed, and not on
an assertion: every test in FileLiberator.Tests' PDF path suite failed in
TestInitialize with an AggregateException wrapping FileNotFoundException, naming a
path none of those tests had anything to do with.

The watcher had raised Created for a folder an earlier test's cleanup then
deleted. AddPath asked whether the path existed, was told yes, asked what it was,
and got an exception - Exists and GetAttributes can disagree over a long \\?\
path, and the answer to the first can stop being true before the second is asked
anyway. That exception ended the background scanner, so nothing further reached
the cache, and it was stored on the task, so the next Stop() rethrew it as an
AggregateException at whoever had called Refresh(). In the app that caller is the
Books directory refresh after every download.

Three changes, smallest first: the attribute read is guarded and returns 'nothing
to add' where the existence check used to say it, which also removes the race
rather than narrowing it; the scanner survives an event it cannot apply; and
Stop() waits on a scanner that has already failed without handing the failure to
a caller that is about to replace it.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 20:17:23 +00:00
Cursor Agentandrmcrackan 10c1cc2a60 Restore MaxSampleRate clamping lost with migrate_to_v11_6_5
Commit 065118cf also deleted migrate_to_v11_6_5, which clamped
MaxSampleRate into LAME's supported [Hz_8000, Hz_48000] range on every
startup (the fix for #1116). Its replacement, ValidateEnumSettings, only
rejects values that fail to parse - but AAXClean.SampleRate defines
Hz_7350, Hz_64000, Hz_88200 and Hz_96000, so a hand-edited or
pre-v11.6.5 Settings.json could carry a valid-but-unsupported rate
straight into the encoder.

Clamp in the property getter and setter instead of a startup hook,
following the DailyDownloadLimitQuantity pattern.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 20:12:44 +00:00