The tour mentions marketplaces at the end rather than at the accounts step: that
step runs before any login, so the button is necessarily disabled while the user
is looking at it.
The FAQ entry is filed under the symptom - books missing from the scan - because
that is what someone in this position knows. Nothing tells them a marketplace
they have never heard of is holding titles.
The accounts screenshots in getting-started now predate the Marketplaces column
and need retaking on Windows.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The accounts grid gains a Marketplaces button per row, enabled once the account
has credentials to check with, and a dialog that asks each marketplace what it
holds and lets the user tick the ones to scan. The scan picker now lists every
marketplace an account reads, since one checkbox there can scan several.
A marketplace that could not be reached is reported as unchecked rather than
empty. Calling it empty would recreate the exact silence this feature exists to
break: titles present, and nothing anywhere in the app to suggest it.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Parsing HTML belongs before the data reaches the data layer, not in it. Move the
flattening to AudibleUtilities, where the rest of the Item translation lives, so
Book.Description holds plain text and DataLayer no longer references
HtmlAgilityPack.
Import now updates a description as well as creating one, which it never did
before: books imported while descriptions were stored as raw HTML are still
holding markup, and a re-scan is what clears it. A scan that reports no summary
leaves the stored one alone.
This also cleans up the CSV/Excel export, which was emitting raw HTML too.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Book.Description is publisher_summary verbatim, which is HTML. Audible's .aaxc
files embed an already-flattened copy, so the fallback only fired on Widevine
downloads - whose DASH files carry no description tags at all - and wrote raw
<p> markup into the file's comment and long-description atoms.
Move the grid's existing HtmlAgilityPack flattening into a shared helper that
also de-entitizes, and use it for the tag fallback. Extract the tag-filling body
of the RetrievedMetadata handler so it can be exercised directly by a test.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
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>
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>
'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>
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>
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>
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.
"(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.
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.
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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
Only a newly imported book ever recorded a supplement, so a title that gained a
PDF after its first import never got one, and a title that lost its PDF went on
claiming one - which in issue #1973 is why three titles Audible has no PDF for
were still being asked for.
Sync from updateBook as well, and give Book set-semantics for the one supplement
Audible reports per title. The duplicate guard compared the incoming url to
itself, so it happened to mean 'this book already has a supplement' and a url
that had changed was silently ignored.
A supplement is dropped only when Audible says outright that no supplement url is
available. A missing url says nothing by itself: episodes come from the catalog,
which is never asked for pdf_url, so there it means 'not asked'. A PDF already
downloaded is left alone either way, since the file is on disk and the library
should go on saying so.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
A multi-title download left a waited-on title out only when it needed its
audiobook, on the grounds that the audiobook download was what Audible had
refused. A PDF is fetched through the same license request, so a title needing
nothing but its PDF was requested again on every run of the very thing the wait
exists to stop.
The skip reason's wording now comes from the shared message, so the app and the
CLI say the same thing about the same skip.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Reported in issue #1973: a scheduled liberate run re-requested a content license
for the same 59 titles every 15 minutes, 1397 refused requests in six hours,
because nothing about a failed PDF was remembered and the PDF step asked Audible
afresh every time.
The two download paths have been converging for a while - a PDF is now named and
placed by the audiobook path's own logic, and verified like one - and every part
of this bug lives where that stopped short.
Both steps ask Audible for the same license. The request asks for pdf_url
alongside the content reference, and LicenseInfo dropped it, so DownloadPdf
turned round and requested an identical license to read the field the first
response had already returned. Carry PdfUrl on LicenseInfo and give both steps
one ILicensedDownload contract: a license may be supplied to a step, and the one
a step ended up using is published for the next step for the same title. The CLI
and the GUI queue hand it on, so a title costs one license request per run
however many steps want something from it. A carried license is retried once
with a fresh one if it does not work, since Audible's links are signed and a long
decrypt can run between the two steps.
Where the audiobook step obtained no license there is nothing to hand on and the
supplement step does not run, which deletes a bug rather than guarding it:
Completed fires from a finally, so a refused audio download was followed at once
by a PDF request that reproduced the refusal.
Error now means the same for a PDF as for a book. The audiobook step has always
skipped LiberatedStatus.Error through AudioExists, and NeedsPdfDownload agrees,
but DownloadPdf selected on PdfExists and so retried an errored PDF forever. A
license that is granted and carries no pdf_url - the 'No PDF URL available' in
the report - is Audible saying the title has no PDF, and is written off that same
way instead of failing identically on every run. It stays resettable by
everything that resets a book: --force, a named title, Set PDF Not Downloaded.
Refusals now reach ProcessSingleAsync, which has always recorded them for
whichever step throws one; DownloadPdf swallowed everything and recorded nothing.
It keeps swallowing what the classifier does not recognise, which is what stopped
a missing PDF from taking the app down with it.
A bulk CLI run leaves alone the titles the last scan did not find, by the same
Downloadable rule every multi-title path in the app already uses, and the PDF
back-fill pass waits on a refused title just as the first pass does. --force and
a named title still attempt everything.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Two states were wrong, both when the live library is empty but the trash is not.
GetLibrary() filters IsDeleted, so an all-trashed library reads as empty and the
getting-started panel took over.
Without a filter it said 'Libation is empty' while 29 books sat in the trash -
the books are not gone, they are one click away. The panel now carries a trash
line when there is anything in there.
With a filter it was worse: searching for a book that IS in the trash showed
'Libation is empty. Add your Audible account' and suppressed the trash hint
entirely, which is the exact failure #1925 was about, reintroduced in the one
case where the library is empty.
The rule is now that someone searching is not someone getting started, so a
filter always answers for itself. That leaves one merely-suboptimal case, an
empty library filtered to something in neither place, which says 'no books
match' - true, and better than claiming the library is empty when it is being
filtered.
Also fixes what made this invisible while testing: GettingStartedVisible depends
on the filter but nothing raised it when the filter changed, so the panel never
re-evaluated.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The blank grid said nothing, and the only pointer anywhere was the status bar's
'No books. Begin by importing your library' - which is the wrong advice for
someone who has not added an account yet, since Import has nothing to scan.
An empty library has two causes with different next steps, and AccountsCount
already knows which one applies, so say the one that fits and offer the action
that goes with it:
no accounts Libation is empty.
Add your Audible account, then scan your library ...
[Add Account] [Take a Guided Tour]
no books No books in your library yet.
Scan your Audible account to bring your books in.
[Scan Library] [Take a Guided Tour]
The buttons run the same commands the menu items do, so the two cannot drift.
The tour is there because it is offered once on first launch and never again
once declined; Settings > Take a Guided Tour is not somewhere a new user looks.
Held back until LibraryStats has been counted at least once, which is what keeps
'Add your Audible account' off the screen in the moment between the window
appearing and a full library loading, and held back mid-scan. An empty library
takes precedence over an empty filter result, since 'no books match' is true but
useless when there are no books at all.
Both grid empty states now share one set of strings in GridEmptyStateUi rather
than the no-matches pair living in TrashBinUi, which was already the wrong home
for them and would only get worse with a third.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Two follow-ups to making the trash visible.
Log every change to it. Startup already reported LibraryBooksInTrash as part of
'Initial database statistics'; now moving books to the trash, restoring them and
permanently deleting them each log the action, how many books, and the resulting
trash total. Reading the count must never fail the change that succeeded, so it
degrades to a warning.
Explain an empty filter result. A trashed book is filtered out of both the
library and the search index, so searching for one returns nothing and looks
exactly like a book that was never imported - which is how issue #1925 went a
week without an answer. When a filter matches nothing, the grid now says so, and
when the same filter matches something in the trash it says that too and offers
a button to open it.
The trash is indexed on demand rather than kept in step. Filtering runs on Enter
or the Filter button, never per keystroke, so the work happens at human speed
and only after the library has already come up empty. TrashBinSearch reuses the
library's query syntax, so a fielded query means the same thing in both places,
and returns nothing rather than throwing since it only powers a hint.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Removal is a soft delete and nothing on screen said so. GetLibrary() filters
!IsDeleted, so a trashed book leaves the grid, the search index and every status
count at once, with no indication it still exists. In #1925 a purchased book sat
in the trash for a week while the reporter and I looked everywhere else for it.
Add a status bar segment reading '29 in trash' that opens the trash bin when
clicked, and put the same count on the Settings > Trash Bin menu item. Both hide
themselves when the trash is empty, which is the normal case and says nothing
worth screen space.
The count is a separate query rather than part of LibraryStats: GetCounts also
runs against the visible subset on every filter change, where a database round
trip would be wasted work. GetLibraryBookCountsByTrashFlag already existed and
counts rows without loading entities. Refreshed on LibrarySizeChanged, which
both removal and restore already fire, and after the trash bin dialog closes.
Wording lives in LibationUiBase.TrashBinUi so Avalonia and WinForms agree.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Classic's upgrade dialog has a single line above the release notes box, so the
full explanation would have rendered on top of them. Carry both a one-line
summary and the full text, and let each UI take the one it has room for.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
An in-app upgrade overlays files Windows has never seen. Smart App Control
blocks unsigned files it does not recognise, so upgrading in place under
enforcement is precisely how a working install becomes one that cannot start,
which is what #1873, #1876 and #1967 all describe.
Read VerifiedAndReputablePolicyState under HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy
to find out. The read needs no elevation and cannot raise a UAC prompt: UAC
prompts only on an explicit elevation request, and HKLM is readable by standard
users. Only the value 1 counts as enforcing. A missing key, a missing value, or
anything unrecognised counts as not enforcing, because the cost of guessing
wrong is telling someone to disable Smart App Control, which cannot be undone,
on a PC that was never blocking anything.
When enforcing, the upgrade notification becomes a notice with the download
link instead of an update prompt, and the flow stops before downloading even if
a UI ignores that. Classic honoured no such flag at all, so its dialog now takes
one; its two prompt labels had to be promoted from designer locals to fields to
carry the explanation.
The blocked-file dialog now states the setting it found rather than asking the
user to go and look, and startup logs the state, the install folder, and any
cloud sync root containing it, so a report answers these without a round trip.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Visible Books > Set PDF 'Downloaded' status manually is PDF-only and does not touch
BookStatus, so the gap the row items fill is a per-title one, not the absence of any
way at all.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
This is the reported leak. AuthenticationRequiredException held the live
Account, and Serilog.Exceptions writes every public property of a logged
exception into the log file - following nested objects as it goes - so
pausing auto-scan wrote the reporter's real address into a file we ask
people to attach to public issues. Their DecryptKey happened to be empty;
with activation bytes set it would have published those too.
The exception now carries an AccountSummary: masked entry and a
credentials flag, both safe to log, plus the owner-facing label behind a
method rather than a property, because reflection reads properties and
never calls methods. The constructor still takes an Account, so callers
and tests are unchanged.
The thrown message named the account too, and it reaches the log twice -
once as {Exception}, once as ExceptionDetail.Message - so it is masked
now. The GUI dialog still shows the full name and address, since that is
the owner's own screen. For the CLI, stderr is not teed into Serilog, so
that is where a headless user is told which account in full.
Two tests, one for the bug and one for the class of bug: the first logs a
real exception through the same WithExceptionDetails enricher Libation
configures and asserts no address, activation bytes, tokens, or cookies
come out. The second walks the public property graph of every exception
type in these assemblies and fails if one can reach an Account or an
Identity. Restoring the old property makes all of it fail, naming
"jade@example.com" and the path AuthenticationRequiredException.Account.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
'Set Download status to Not Downloaded' moves both statuses together and was the only
way to reset a PDF, so a user who wanted a PDF re-fetched also queued the audiobook
for a fresh download - which then rewrote that title's other files. Reported in
issue #1947.
Add a PDF-only pair beside it, shown only for a selection that has a PDF, since for
anything else the existing pair is already audio-only.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Bounding the persisted setting by processor count was only half of it.
Both spinners take their maximum from the same machine-derived number
and are two-way, so a NumericUpDown handed a value above its maximum
coerces the display down and writes the coerced value straight back.
A user who chose 8 on their desktop and opened the queue panel on a
two-core laptop still ended up with 8 replaced by 2 - the same defect,
one layer up, and not fixed by the configuration change alone.
The bound is now the machine's capability or the stored value,
whichever is higher, so the spinner can never coerce what is already
there. Lowering it remains the user's to do, and what actually runs is
still held down to what the machine can manage at the point of use.
Same unlocked-enumeration problem as the others: the speed limit is
changed from the UI thread while book tasks are starting and finishing,
and Active is the live list.
Faulted book tasks were dropped unobserved. The reaping pass removes any
completed task from the active set before the closing WhenAll can
rethrow, so an exception out of ProcessOneAsync - which can happen via
GetFailureActionAsync in its finally - went nowhere. In the sequential
loop it reached the outer catch and was logged. It is logged again.
The bad-book dialog did not survive concurrency. The license and
Widevine messages are guarded to appear once per run, but the "skip this
book?" dialog is per book and its "apply to all remaining books" answer
lands in a shared BadBookSessionContext. Three books failing together
put three modals on screen racing to set the same override. The dialog
is now serialised on the session, and each book re-checks the override
after its turn comes: if the book ahead answered "apply to all", the
question is not asked again.
Machine capability no longer overwrites the stored concurrency setting.
MaxAllowedConcurrentDownloads clamped both the getter and the setter, so
a user who chose 8 and then opened the same config on a two-core machine
read back 2 - and had 2 written over their 8. The stored value is now
bounded only by the hard limit, which is machine-independent; processor
count bounds the spinner and is applied at the point of use, where the
loop decides how many books to run.
The opt-in daily download limit landed after this branch was written and
lives inside the sequential while (Queue.MoveNext()) loop this change
replaces. It is sequential by construction, so rebasing alone leaves it
subtly wrong rather than merely conflicted.
The gate now runs in the dispatch loop, between taking a book off the
queue and starting its task. That keeps the existing semantics - checked
as a book is about to start so the queue keeps its contents and the
limit can be changed mid-run - while books already in flight carry on.
Putting it inside the book task instead would have every blocked book
polling the history at once.
Three concrete defects that fell out of the collision:
RequeueLast deferred a book with Queue.ClearCurrent(), which drops
Active[0]. With one book at a time that is the book being deferred; with
three in flight it is somebody else's download, so deferring the second
active book silently evicted the first. It now removes the book it was
given.
A book cancelled at the gate was left on the active list. The sequential
loop retired it on the next MoveNext(); there is no next MoveNext(), so
it is marked completed explicitly.
CancelAllAsync existed twice after the rebase - the sequential version
that sets cancelAllRequested and cancels Queue.Current, and this
branch's version that cancels every active book. Unified into one that
does both. The flag matters: a queue paused on the limit is sitting in
WaitForDailyLimitAsync and that flag is how it learns to stop.
AnyOtherQueuedBookAllowed enumerates the queue with Queue.Any(...) while
book tasks mutate it; that is safe now that GetAllItems snapshots under
the lock, and it also now takes a copy of the active list to cancel.
Both of these are reachable only once more than one book is active at a
time, and both were found by compiling TrackedQueue<T> on its own.
MarkCompleted moved a book from Active to Completed and raised only
CompletedCountChanged. The display index of an active book is derived
from Completed.Count, so when the second of two active books finishes
first the two swap places - with no CollectionChanged, a bound list
keeps painting the old order and rows show the wrong book's progress.
Out-of-order completion is the normal case with parallel downloads. Now
raises a Move for the book whose position actually changed; with one
book at a time the indices are equal and nothing is raised, so the
sequential path behaves exactly as before.
GetAllItems built a lazy Concat inside the lock and returned it, so the
enumeration ran unlocked. Any foreach or LINQ over the queue while a
book task mutated it threw InvalidOperationException. It now returns a
snapshot taken while the lock is held. This is the source of the crash
that setLiberatedVisibleMenuItem was patched for at the symptom.
RemoveActive and ClearCurrent also read _completed.Count outside the
lock when computing the index to report; both now capture it inside.
The queue loop only woke when a book finished, so books queued a moment
after it started sat idle until an in-flight download happened to
complete. Observed as one book downloading alone for 15 seconds with a
limit of 5, then four starting within 37ms of the first finishing.
The loop now waits on whichever comes first: an active task completing,
or a new book being queued. The enqueue signal is captured before the
queue is inspected, so a book queued between TryDequeueNext and the wait
completes the token already held rather than a fresh one - otherwise the
wakeup is lost and the loop sleeps until something else finishes. The
'nothing left, exit' path re-checks the same signal for the same reason.
Chardonnay had parallel downloads with no way to configure them, since
the queue logic lives in shared UI code but each UI supplies its own
controls. Adds the Auto-scroll toggle and the 'At once' spinner to
Chardonnay's queue panel, bound to the same view model properties the
WinForms panel uses.
Also uses Environment.ProcessorCount as the spinner's ceiling rather
than its default: min(ProcessorCount, 10). Downloading is bound by
Audible's license throttling rather than local CPU, so core count says
nothing about how many concurrent downloads will succeed - it only
bounds how many decrypts can usefully run at once. The default stays 3.
Spinner bounds are bound rather than hardcoded, so the two UIs cannot
drift apart.
Replaces the MultiThreadEnabled checkbox and the ProcessorCount default
with a single MaxConcurrentDownloads value where 1 means serial - the
behaviour Libation had before parallel downloads existed.
The old pairing could not express 'off' at all: the bool was hardcoded
true in the view model constructor and the int setter clamped to a
minimum of 2, so there was no way back to one-at-a-time downloads.
Collapsing both into one value makes that state unreachable rather than
merely fixed, and removes the risk of the two settings disagreeing.
Bounds live in Configuration as named constants: minimum 1, default 3,
maximum 10. Audible throttles license requests, so the default is
deliberately conservative and the cap keeps users from choosing a number
that produces license denials instead of speed.
WinForms swaps the 'Parallel downloads' checkbox for an 'At once:'
spinner that reads its bounds from those constants.
ClearQueue() only stops new work from starting. With parallel downloads,
books already running kept going after an abort or a disk-full result,
so the queue reported itself stopped while downloads continued.
Adds ProcessQueueViewModel.CancelAllAsync(), which clears the queue and
cancels every active book, and uses it for both the abort and disk-full
paths. Both Cancel All buttons now route through it as well - the Avalonia
one previously cancelled only Queue.Current, so it missed every book but
the first.