Newtonsoft applies values in the order the document lists them, so a settings
file naming AdditionalLocaleNames before its IdentityTokens has no registered
marketplace to compare against yet, and the duplicate check on the way in does
nothing. That account would then scan one marketplace twice.
Filtering on read makes the order irrelevant.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
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>
WinForms gets the same per-row button and dialog Chardonnay has, and its scan
picker lists an account's marketplaces for the same reason.
list-accounts grows an 'Also scans' column, because printing one locale for an
account that reads three misreports what a scan will do. It appears only when
some account has more than one marketplace, so the ordinary table is unchanged;
--bare always emits the field, last, so a script reading the first five keeps
working.
Compile-checked only for WinForms: it builds on Linux via EnableWindowsTargeting
but cannot run there, so the new dialog's layout still needs a look 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>
A title bought while an Amazon address was briefly set to another country stays
in that country's library for good, and a scan of the account's own marketplace
never sees it - no error, no warning, the titles are simply absent. The only way
to reach them was to add the same login a second time under the other
marketplace, which meant knowing it was there and logging in again.
An account now records the further marketplaces it should read, and a scan reads
all of them as a unit. MarketplaceProbe finds them: one request per marketplace,
in sequence, and only when asked for.
Nothing about stored credentials moves. Encrypted tokens are bound to their
marketplace by AES-GCM associated data built from Identity.LocaleName, and the
identity is located in the file by that same name, so both keep resolving to the
registration that already exists. The extra marketplaces are names alone, and are
left out of the file entirely when there are none - so an account with one
marketplace, which is nearly all of them, writes the file it always did.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
PreLoggingCrashLog returned its LongPath straight out, so on Windows the crash
dialog named \\?\C:\Users\...\Log202608.log. The prefix is for the Win32 API,
not for a person being asked to find the file and attach it to a bug report.
Return PathWithoutPrefix instead, and use it for the LibationFiles line inside
the record for the same reason. A test now pins that the reported path carries
no prefix and is usable as-is; on Linux the two forms are identical, which is
why local runs could not see this and the Windows CI job could.
While in there: ReplaceInstallFile moved the old file aside and then wrote the
new one, so a copy that failed after the move left nothing at all where a file
used to be. Put the old one back before reporting the failure.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The rollback restored files, announced 'Libation restored your previous install
files', and deleted the pending marker without ever reading back what it had
written. It also could not tell the difference between an overlay that had not
started, where putting the backup back returns the install to one version, and
an overlay that got partway, where the backup covers a dozen names out of the
few hundred in the folder and cannot.
Grade the outcome instead. Verify each restored file against its backup, and
use the manifest files that did match the upgrade package as the signal that
the overlay had begun. Report which files could not be restored, and keep
restoring the rest rather than abandoning them at the first failure.
Libation has to close either way, since the files underneath it just changed,
so the wording and the offer now follow the grade: back on the previous version
asks to restart, a mixed install asks but says a fresh install would be better,
and a restore that could not finish only offers OK. A relaunched process never
offers again, so a pending marker we failed to delete cannot become a loop.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Completes the adoption of this Dinah.Core release across the
dependency tree: AudibleApi 11.0.5.1 carries the Dinah.Core 10.2.6.1
floor, and Dinah.Core.WindowsDesktop 10.2.6.1 is versioned in step.
No code change in any of them.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The dialog was pinned to 450px tall with the description in an Auto row, which
was fine for the two-line generic text but not for a message that names the
file, the version it has, the version it needs and the recovery steps: the
description pushed the exception box, the report instructions and the OK button
out of the window.
Cap and scroll the description, give the window room to grow, and top-align the
icon so it stops overlapping the text.
The Classic equivalent, MessageBoxLib.ShowAdminAlert, may need the same and
cannot be checked here.
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>
The rollback test holds the file it restores open with the sharing mode .NET
uses for a loaded assembly. Against the old File.Copy(overwrite: true) it fails
with 'the process cannot access the file because it is being used by another
process', which is the same refusal Windows gives for a loaded assembly and the
reason the rollback could never finish.
The rest pin the behaviour that was missing: recovery survives a log sink that
throws on every call, a displaced file is swept up later, a stale or absent
install assembly is recognised and both versions are read, a plain file path is
not mistaken for an assembly reference so the EF Core message keeps its own
wording, and the crash record appends to the newest Log*.log and reports the
path it used.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
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>
Chardonnay's Serilog-free crash record was private to its entry point, so
Classic wrote nothing at all when startup failed before logging. Move it to
LibationFileManager as PreLoggingCrashLog, where both UIs can call it and where
the existing test project can cover it. It needs nothing from AppScaffolding:
Configuration.LibationVersion already holds the version, and ReleaseIdentifier
is not populated this early, so callers pass what they know.
Guard every field, since an unguarded InteropFactory.InteropFunctionsType,
whose static constructor logged through Serilog, was enough for the bare catch
to swallow the whole record and leave no trace of the crash. Return the file
written so the dialog names it: both UIs pointed at LibationCrash.log, which is
not where the record goes when a Log*.log already exists.
RecoverFromIncompleteUpgradeIfNeeded now reports a completed rollback, and both
entry points show it and quit. Continuing meant running the assemblies already
loaded against the older files just restored underneath them. Consuming the
alert at startup also retires the two late call sites that could never fire.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Three separate reasons the recovery added for #1878 could not help the install
in issue #2001:
Classification was by assembly name, covering only EntityFrameworkCore,
Microsoft.Data.Sqlite and LibationUiBase, so a Serilog bind failure reached
neither the emergency rollback nor an actionable message. Match on the assembly
reference instead and compare the requested version against the file on disk,
so the dialog can say which file is stale and what version it should be. A
stale file and an absent one report identically through the loader, which is
why the version has to be read to tell them apart.
RestoreFromBackup wrote over assemblies this process had already loaded and
mapped. That segfaulted the process on Linux and Windows denies the write, so
the rollback never completed either way. Move the loaded file aside first and
sweep the leavings on a later startup.
Serilog.dll and three more assemblies Libation cannot start without were not in
the upgrade manifest, so an overlay could lose one and still verify clean.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
CsvHelper was only used to write headers and flat DTO rows in the csv
exports. The new internal writer reproduces its output byte-for-byte:
the delimiter is the culture's list separator, records end with CRLF,
fields containing the delimiter, quotes, newlines, or leading/trailing
spaces are quoted per RFC 4180, and values are formatted with the
culture. The [Name]/[Ignore] attributes it replaced are now two tiny
local attributes.
Verified byte-identical output against CsvHelper 33.1.0 for both
exporters in en-US, de-DE (';' delimiter), and invariant cultures, and
for a seeded 30-book demo library export before/after.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
FillMissingTags is driven against a real MetadataItems built from a hand-rolled
ilst box: empty, as a Widevine download arrives, and pre-populated, as an .aaxc
download arrives. Reverting any of the three fixes fails a test with the exact
value from the issue report.
MockLibraryBook.CreateBook gains a copyright knob to match UpdateBookDetails.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Libation's releases are ReadyToRun, so a Serilog reference resolves lazily
when the line holding it runs. An install folder whose Serilog.dll is missing
or older than the build therefore threw inside the very catch blocks that were
meant to report the problem, which destroyed the original exception and aborted
the in-app upgrade recovery before it could roll anything back.
Add StartupLog: a Serilog-free, never-throwing buffer for the window before
ConfigureLogging runs, replayed into Serilog once logging exists. Move the
pre-logging call sites onto it, including the crash-message and crash-dialog
paths that run precisely when the install is broken.
Those messages were silent before this change: Serilog.Log.Logger is still
Serilog's silent logger until App.RunMigrations. They now reach the log file.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Libation never had a copyright of its own to fall back on: it only cleaned up
whatever the .aaxc file already embedded. Widevine downloads carry no cprt at
all, so they came out blank.
The copyright line is only returned by the product_details response group, so
request it on the full library scan, store it on Book, and use it as the tag
fallback. Existing libraries pick it up on their next scan, and Audible still
returns null for plenty of titles.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Audible's .aaxc files often ship 01-Jan-2000 in rldt and 2000 in the year atom
instead of the real release date, and the file's tag beat the catalog's date.
Override that one value when the catalog knows better, leaving every other
embedded date alone; the comment records the alternatives considered.
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>
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>
'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>
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>
The four dotnet publish calls share one run: block, and on windows-latest that
block is pwsh. PowerShell does not stop at a native command that fails, and the
step is judged by $LASTEXITCODE after the whole block, so only the last publish
was ever able to fail the build.
That is not hypothetical. LibationWinForms stopped compiling and every run since
stayed green: the WinForms publish errored, the three after it succeeded, the
step exited 0, and the job uploaded a Libation-Classic zip containing Hangover
and the CLI but no Libation.exe at all. upload-artifact's if-no-files-found did
not help, since the zip itself was there.
Check the exit code after each publish, and refuse to package output with no
Libation.exe in it - both UIs build under that name. Linux and macOS are not
affected; their steps are bash, which Actions runs with -e.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
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>
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>
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>
Both are newly worth explaining. Searching a field for a word that names another
field used to raise "Bad filter string", and a numeric title could not be found
at all, so there was nothing to document until now.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
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>
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>
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>
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>
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>
The page said auto-scroll only moves "when you were already looking at the bottom
of the queue". The condition in both UIs is that the item above the one starting is
still on screen, which is not the same claim - you can be well short of the bottom
and still be scrolled along with the queue.
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.
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.
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>
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>
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>
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>
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>
^(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>
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.
docs/features/ has a page per user-facing feature, including the daily
download limit this interacts with. Records the two things that are not
discoverable from the UI: that above 3 concurrent downloads Audible starts
denying content licenses, which arrives as ordinary download failures with
nothing identifying the cause, and that books already downloading run to
completion when the daily limit pauses the queue - the limit stops new
books starting, it does not interrupt work in progress.
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.
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.
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.
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.
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.
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.
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>
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>
Matches the existing usage in PersistentDictionary and
LibationAvalonia.Program; the lock statement now emits
Lock.EnterScope() instead of Monitor.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Commit 065118cf deleted Migrations.migrate_to_v11_5_0, which despite its
name ran on every startup and was the only code that read
QuickFilters.json into QuickFilters.InMemoryState. Since v13.7.6, saved
quick filters were never loaded after a restart, and adding a new filter
overwrote the file.
Make QuickFilters load its state lazily from disk on first access so it
no longer depends on a startup hook. Restore the pre-v11.5.0 format
fallback (plain string filters without names) that the deleted migration
provided, and add regression tests covering load formats and
restart persistence.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
A new DtoImporterService.Tests pinned MSTest 4.2.2, copied from a project file
master has since moved to 4.3.3, so the merge held nine test projects at 4.3.3
and one at 4.2.2. NuGet lifted the shared MSTest.TestFramework to 4.3.3 while the
4.2.2 metapackage kept supplying the adapter, and the adapter refuses to run
against a framework of a different version: 'Zero tests ran' on every platform.
ApplicationServices references DtoImporterService, so its test project already
sees the importers and needs no package reference of its own. One fewer project
file to keep in step is the point.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Counting every title the last scan did not find reports a number no run was going
to act on: most of a large library's absent titles need nothing at all. The
reporter's library would have been told thousands of titles were skipped where 54
would have been attempted.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The SDK supplies Microsoft.SourceLink.Common and the git tasks, so the build
resolved the repository URL and commit but had no host provider to turn them
into fetchable URLs. Every project warned that the generated source link was
empty - 477 times in the last validate run - and shipped PDBs that could not
locate their sources.
Referencing the GitHub provider, as AudibleApi already does, fills the map in:
{"documents":{"/repo/*":"https://raw.githubusercontent.com/rmcrackan/libation/<sha>/*"}}
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The refused-downloads page said the opposite - that a --pdf run is never held
back because a PDF is a different request - and neither page mentioned that a
bulk run now leaves alone the titles the last scan did not find, or what happens
when Audible lists a PDF it will not deliver.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
DownloadPdfLicenseTests drives the step with and without a license in hand. No
Audible account exists in those tests, so a test that completes at all is the
proof that the step did not go asking for a license of its own.
BulkRunSelectionTests drives the real run loop against a real library database:
which titles each pass reaches, that an absent title is left alone by both, that
--force still attempts it, and that a refused title is waited on by the follow-up
pass and reported once rather than twice.
SupplementSyncTests covers the scan rules, in a new DtoImporterService.Tests
project. The two replaced assertions - that a PDF-only title is never waited on,
and that a --pdf run is never held back - encoded the belief that a PDF is a
different request from the audiobook, which is what this branch disproves.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
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>
Libation already runs its tests on Microsoft.Testing.Platform via the MSTest
metapackage; this only moves the version off 4.2.2 so all three repos name the
same MSTest release.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Microsoft.EntityFrameworkCore.Sqlite 10.0.11 is the first 10.0.x servicing
release to depend on SQLitePCLRaw 2.1.12, which drops the pre-3.50.2 SQLite
build flagged by GHSA-2m69-gcr7-jv3q (CVE-2025-6965). DataLayer's Sqlite
reference is where that chain enters the solution, so bumping it clears
NU1903 from all 23 projects that transitively saw it.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The script writes rows straight to SQLite, which leaves Libation's Lucene
index untouched, so every filter on a seeded book came back empty and the
empty-grid states could not be tested: nothing was findable, so every filter
produced 'No books match' whether or not that was the right answer.
Deleting the SearchEngine folder is enough. A missing index is a state
Libation already handles - performSafeQuery catches the FileNotFoundException,
re-indexes from the database and retries - and that rebuild filters IsDeleted,
so trashed books stay out of the index exactly as in a normal session.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The four LinkLabels in the empty-grid block and the clickable trash count
never applied Libation's link colors, so in dark mode they kept the default
WinForms blue that every other link in the app overrides.
trashBinLbl is a ToolStripStatusLabel, which ThemeExtensions did not cover,
so SetLinkLabelColors now has a ToolStripLabel receiver alongside LinkLabel.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
dotnet build Source/Libation.slnx succeeds here with 0 errors, as do
LibationWinForms, HangoverWinForms and both LoadByOS ConfigApp helpers
individually. They target net10.0-windows7.0 but set EnableWindowsTargeting,
which is what lets a non-Windows SDK build them.
I took the old claim at face value and shipped a PR's worth of WinForms edits
without ever compiling them. The bug fixed in the previous commit would have been
caught by review either way, but several of the designer edits could just as
easily have failed to compile.
Record the distinction the old text blurred: everything compiles here, WinForms
still cannot run here, and those are different claims.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Control.Visible returns effective visibility, walking up the parent chain. The
trash line's text was set behind 'if (emptyLibraryTrashLink.Visible)' immediately
after assigning that same property, while noMatchesPanel - its parent - was still
hidden and only shown eight lines further down. So the read-back was false, the
text was never assigned, and the LinkLabel rendered the empty string it carries
from the designer.
That matches the report exactly. The headline and the two action links set their
Text unconditionally, so they appeared; only the trash line was blank. Opening
and closing the trash bin fixed it because by then the panel was already on
screen, so the read-back was true. A restart put it back.
Drive the text off the count instead, and use a local for the panel's own
BringToFront guard rather than reading Visible back there too.
Avalonia was never affected: its XAML binds Text and IsVisible independently.
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>
It is derived from AccountId, AccountName and Locale, so Newtonsoft was writing
a stale copy of three fields that are already in the file. Locale next to it was
already ignored for the same reason; this one was missed.
Harmless, but it is the sort of thing that gets read back one day as if it meant
something. Four tests pin the serialized shape: the two computed properties stay
out, the set of persisted members is asserted whole so a new property has to be
a decision rather than an accident, and a file already carrying the stray key
still loads and is rewritten without it.
Verified against the app rather than only the tests - renaming an account and
saving no longer adds the key.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
A marker property would be tidier, but AudibleUtilities.Account serializes a
fixed set of members and has no [JsonExtensionData], so Newtonsoft ignores an
extra property on the way in and never writes it back. Confirmed by adding
IsDemo, renaming the account in the app and letting it save: the property was
gone, and anything that dirties an account does the same, a token refresh during
a scan included. --clean would then strand demo accounts with no way to find
them. Adding a demo flag to the shipping Account class to serve a dev script is
not a trade worth making.
The id is the better anchor anyway: immutable, always persisted, and example.com
is reserved by RFC 2606 so no real Audible login can collide with it. Verified
that --clean still matches an account after the app renamed it.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Quite a lot of Libation only appears once an account exists, and it is not one
switch but two: nothing, one account, or several. The one/many split changes the
Import and Remove menus, and the new empty-library panel offers 'Add Account' or
'Scan Library' depending on it. None of that was reachable in a test environment
without signing in to Audible, which is how I ended up writing this by hand to
check the empty-library states.
seed-demo-accounts.cs writes structurally valid but meaningless identities, so
Libation counts and lists the accounts and enables everything gated on having
one. Any actual scan is refused by Audible, which the script and the docs both
say plainly.
It appends rather than overwrites, and --clean removes only what it added -
the accounts matching demo*@example.com - because this is the file holding real
Audible tokens. The first account is demo@example.com, the same one
seed-demo-library.cs assigns its books to, so seeding both describes one library
instead of two unrelated ones.
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>
Removing a book was undocumented, so nothing told a user that a missing book
might simply be in the trash - which is what #1925 turned out to be.
Add docs/features/trash-bin.md covering how books get removed, how to find and
restore them, and what the log records. It leads with the two things people
actually get wrong: removing never touches your audio files, and 'Permanently
Delete from Libation' keeps a book away less effectively than the trash does,
because deleting Libation's record of a book means the next scan imports it
again as something it has never seen.
Also add a troubleshooting entry for a book that is in your Audible library but
not in Libation. It starts with the trash bin and an active filter, since those
account for most reports and neither leaves anything in the log, then moves on
to the import settings and the scan tally that would explain the rest.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
seed-demo-library.cs covered every grid state except the one that made #1925
hard to answer: a book that is in the database but in the trash, and therefore
absent from the grid, the search index and every status count at once.
Seed three - a purchased title, a Plus title, and an episode whose series is
still in the library so the trash bin's nesting is exercised too - and report
them under their own heading, since unlike every other seeded row they are
supposed to be missing from the grid.
Document what they are for in docs/development/testing.md: the status bar count,
the menu count, the empty-search hint, and the nested episode.
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>
Both UIs count the whole library and the visible subset as the library loads,
and GetCounts logs the same two lines for each. With no filter applied the two
sets are the same books, so the log shows what looks like duplicated output and
gives no way to tell which set a count describes, or whether a difference
between them is a filter or a bug.
CallerMemberName distinguishes them without touching the call sites or leaving
a label for the next caller to forget.
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>
scanAccountsAsync swallows a per-account failure so the remaining accounts can
still scan. That is right for importing, which only adds and updates, but
FindInactiveBooks treats whatever came back as the whole library and reports
every un-liberated book missing from it as no longer in the account. For a
single-account user a login or network failure therefore yields an empty scan,
and 'Remove Books' pre-checks the entire un-liberated library. ImportAccountAsync
already guards itself with 'if (totalCount == 0) return default;'; this path had
no equivalent.
Track which accounts failed and refuse to identify inactive books when any did,
or when a scan returns nothing while books are at stake. Both grids already
catch and report a failed scan with 'Error scanning library. You may still
manually select books to remove', so throwing lands in the right place and
leaves nothing checked.
Also log how many books the scan found absent, so the count behind a removal
prompt is visible after the fact.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The previous check read the OneDrive environment variables and matched path
prefixes, so it missed Dropbox, Google Drive and iCloud, and had its own
guesswork to get wrong.
Every sync engine that serves files on demand registers a sync root with the
Cloud Files API, and CfGetSyncRootInfoByPath answers directly whether a path
sits under one. That covers every provider without naming any of them, and it
replaces the environment lookups, path canonicalisation and prefix matching
rather than adding to them. Asking for the provider info class also gets the
provider's own name, so the message can say Dropbox rather than a generic phrase.
The buffers are plain byte arrays read by hand: nothing here can be verified
away from Windows, so there is no marshalling to get subtly wrong.
Detection cannot produce a false positive now, since the answer is Windows'
rather than an inference from a folder name. The warning still appears only in
the incomplete-upgrade message, where something has already failed, so a folder
someone deliberately keeps fully downloaded never prompts anything.
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>
Libation has applied to the SignPath Foundation. Turning Smart App Control off
is irreversible, so pointing users at it as one of only two ways out reads
differently when signed builds may be close. The docs now list waiting first and
carry the status, hedged on approval since there is no date.
The dialog says nothing about signing progress and defers to the docs instead:
a shipped string cannot be corrected if the application falls through. It no
longer nudges toward turning Smart App Control off without mentioning that
the page has the current options.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The 'Libation blocked by Windows security' dialog is generated from
GetApplicationControlBlockedMessage, so it repeated the broken Unblock-File
command verbatim and offered a reinstall that cannot clear an Application
Control block. Explain the missing signature instead, and tell the user how to
check which mode Smart App Control is in and what turning it off costs.
Point the incomplete-upgrade message at its own docs anchor now that the two
failures have separate sections.
Tests cover the message text because it is the whole deliverable here: one
asserts Unblock-File cannot come back, one checks the blocked file and cause
are named, and one pins each message to the docs section it links to.
Refs #1967
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The troubleshooting section told users to run 'Unblock-File -Path ... -Recurse',
which is not valid PowerShell (the cmdlet has no -Recurse parameter) and would
not help even in its correct form: Smart App Control gates on the signature and
on cloud reputation, not on Mark-of-the-Web. It also said Evaluation mode
triggers blocks, and advised turning Smart App Control off temporarily and back
on again. Evaluation never blocks, and Windows cannot re-enable Smart App
Control without a reset or reinstall.
Say instead that the Windows builds are unsigned, that Windows has no per-app
exception, and what the two real options are.
Split the section in three, since it served two unrelated failures and the
'extract to a new folder' fix only applies to one of them: a Smart App Control
block, an incomplete in-app upgrade, and installing into a synced folder. Keep
the original anchor on the Smart App Control section so links in released builds
still land somewhere useful.
Refs #1967, #1873, #1876
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Seven references across six projects, all now on published versions.
What the app gains is from Dinah.Core: OsSecretStore.Create bounds how
long it waits for the backend, so a Linux or macOS start with a keyring
that never answers falls through to the portable master key path instead
of hanging there. IdentityTokenStorageWiring.ResolveSecretStore is the
caller, and it runs at startup whenever no key file or env var is set.
AudibleApi 11.0.3.1 carries no code change for us - it is the release
where its nuspec finally declares the Dinah.Core floor its own code needs.
Dinah.Core.WindowsDesktop and Dinah.EntityFrameworkCore move to 10.2.4.1
as well, keeping every Dinah package on one version.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
A Windows CI leg failed with every test passing. FileLiberator.Tests
exited 0xE0434352:
Unhandled exception. System.InvalidOperationException: The collection has
been marked as complete with regards to additions.
at BlockingCollection`1.Add(T item)
at FileManager.BackgroundFileSystem.FileSystemWatcher_Changed(...)
at FileSystemWatcher.ReadDirectoryChangesCallback(...)
Stop() disposes the watcher and then completes the collection, on the
assumption that disposing stops events. It does not stop the ones the OS
has already buffered, and on Windows those arrive on a native completion
callback, where an exception is not a failed call - it is a dead process.
So a Libation run that reinitialises or shuts down its file cache while
the Books directory is busy can take the app with it, which is the same
race the tests hit.
Adding to a completed collection is now caught and the event dropped.
That is the right answer rather than a swallow: whoever called Stop() is
either reinitialising, which rebuilds the cache from disk, or disposing.
Stop() also detaches its handlers before disposing and clears the fields,
which narrows the window and makes a second Stop() harmless - the
collection field is cleared only after CompleteAdding, since the
background scanner is waiting on that.
First tests for the class, since it had none: dispose under a flood of
events, dispose twice, and find a file created before and after
construction. They cannot prove this fix - the crash does not reproduce on
Linux even with the original code, because inotify does not deliver
post-dispose events the way Windows does. Windows CI is the only place
that can, so the guard is aimed there.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The guard lived in AudibleUtilities.Tests, which meant it scanned three
assemblies and found four exception types - a test only sees what its own
project references. Moved to LibationUiBase.Tests, which reaches
ApplicationServices, AppScaffolding, FileLiberator and DataLayer as well,
it now covers 16 exception types across 10 assemblies.
Two of those are the AudibleApi packages, on purpose. Identity is theirs,
so an upstream exception that started carrying one would leak through
Libation, and a version bump is where we would want to find that out
rather than in someone's log. Nothing there reaches one today.
Checked by adding AccountSummary to the forbidden list, which fails the
test naming AuthenticationRequiredException.AccountInfo - so the walk does
reach exception properties rather than passing on an empty set.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Seven tests failed in a Windows CI leg, every one thrown from a
TestInitialize rather than an assertion, and on a path no test in the
failing class had created. The cause is [assembly: Parallelize] over
classes that reach for state which is process-wide by nature: the
Configuration singleton, the LIBATION_FILES_DIR environment variable, and
AudibleFileStorage's static file cache. Two classes running at once means
one swapping the config or deleting its temp directory while the other
enumerates it.
Opting out per class was the previous arrangement and it does not hold:
three classes carried [DoNotParallelize] while two touching the same
state did not, and nothing tells whoever adds the sixth. The attribute is
gone, with a comment saying why, and the existing [DoNotParallelize]
markers stay as a statement of intent if parallelism ever returns.
Costs nothing measurable: 68 tests in about 3.1 seconds serially, against
9.8 seconds for the same assembly in the CI leg that failed. Ran three
times over to check.
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>
The platform compatibility analyzer flagged all four File.GetUnixFileMode /
SetUnixFileMode calls as reachable on Windows. Assert.Inconclusive is not
[DoesNotReturn], so the OperatingSystem.IsLinux/IsMacOS check did not narrow the
platform for anything after it, and narrowing from such a check does not reach
inside a lambda at all, which left the Timer restore callback flagged regardless.
Return explicitly after the skip, and move the body into a method attributed
[SupportedOSPlatform("linux")]/[SupportedOSPlatform("macos")] so the callback
inherits that context. Both files I touch now build warning free.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Windows CI caught the atomic replace failing with UnauthorizedAccessException:
renaming over a file is denied while another handle holds it open, however
generously that handle shares the file. In production the CLI, a second GUI
instance or a virus scanner can each hold Settings.json for a moment, so retry the
replace a few times before letting the caller see the failure. The previous
File.WriteAllText threw on the same holds, so this is strictly more forgiving.
ExternalReaderNeverSeesAPartiallyWrittenFile keeps a handle open almost
continuously, which no retry budget can outlast on Windows, so restrict it to unix
where it actually tests write atomicity. Write_SurvivesATemporarilyUnwritableDirectory
covers the retry instead by revoking write permission on the containing directory.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The last reference still on 11.0.0.1. 11.0.2.1 is the release whose nuspec
declares the Dinah.Core floor its code actually needs, so the package now
agrees with what this repo resolves anyway.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The last reference still on 10.2.2.1, held back until the package was
published. LibationWinForms now resolves both Dinah.Core and
Dinah.Core.WindowsDesktop at 10.2.3.1 rather than relying on NuGet
unifying a lower pin upward.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Dinah.Core 10.2.2.2 gives SecretString a Redacted property, so a
destructured secret carries its length without the logger being told
anything. That was the only thing AsScalar was buying - safety never
depended on it - so the registration and its explanation go away, and the
knowledge lives in the type instead of in this file.
MaskedLogEntryPolicy stays: an ILogMasked has no equivalent property, and
without the policy one logged as {@Account} is still written out property
by property.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
File.WriteAllText truncates the destination before writing, so an interrupted
write leaves a half-written or empty Settings.json, and the in-process lock added
in the previous commit cannot help a reader in another process - the GUI and the
CLI share this file.
Route every write through Dinah.Core.IO.AtomicFileWriter, which writes a sibling
temp file, flushes to disk and renames it over the destination. Validate the temp
file parses as json before the swap, the same way JsonFilePersister<T> already
saves AccountsSettings.json, so a bad payload leaves the existing file untouched.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Same output, but it states the intent: leave a SecretString whole rather
than transform it. The comment now separates the two lines by what they
do, because they are not the same kind of thing - the policy is the
protection, and without it a masked object is written out property by
property, while this line only decides whether a secret reads as its
length or as {"HasValue":true}.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Account.ToString() returned "id - locale", so interpolating an account or
logging a non-destructured {Account} published the address. It now returns
the masked entry, with a DebuggerDisplay keeping the real values visible
while debugging. Nothing in the UI relied on it: both scan dialogs build
their own labels.
For structured logging, an ILogMasked type is reduced to its masked entry
by a destructuring policy, which covers the {@DebugInfo} shape most of
Libation's logging uses. And DecryptKey - the activation bytes - is now a
SecretString, so it has no plaintext for a reflective dump to find at all.
Its JSON stays the bare string it always was, so existing settings files
load unchanged.
A registered policy that nobody notices is missing protects nothing, so
the tests write through a logger built by ConfigureLogging itself rather
than a hand-made one. Deleting either registration fails them: the masked
object comes out whole, and a destructured secret renders as
{"HasValue":true} instead of its length.
The contribute guide now states the rule, since the reason for all of
this is invisible from the code alone: log files get attached to public
issues, so treat what goes in them as published.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Configuration.Instance is a process-wide singleton read and written from the UI
thread, BackgroundWorker callbacks and download workers at the same time, but
PersistentDictionary guarded only its file writes. Its two Dictionary caches were
touched without synchronization, so concurrent inserts eventually corrupted them
and threw "Operations that change non-concurrent collections must have exclusive
access" (issue #1959, reported from MainVM.UpdateCountsBw_Completed reading
AutoDownloadEpisodes while a second GetCounts pass ran).
Reads on the file were unguarded too: Exists/GetJObject could observe a partially
written Settings.json, and readFile responds to empty contents by rewriting the
file, so a reader racing a writer could scramble the settings on disk.
Take one lock across each operation's cache and file access, and keep logging
outside it by having writeFile report whether it rewrote the file.
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>
AudibleApi 11 holds token, key, and cookie values in a SecretString
rather than a string, so nothing public exposes plaintext for a reflective
logger to find. Picking it up is a breaking upgrade: the seven package
references move, and the nine places that read a secret now call Reveal().
Two of those needed thought rather than a mechanical edit. Mkb79Auth
exports to and imports from audible-cli's JSON format, which is plaintext
by definition, so the cookie projections reveal explicitly in both
directions and the file format is unchanged. And the account's own
DecryptKey stays a plain string here: converting it is separate work.
This is the dependency bump only. The log leak it enables fixing - an
AuthenticationRequiredException carrying a live Account, whose address and
activation bytes Serilog.Exceptions writes into a shared log - is still
open, and none of the account-side masking has landed yet.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The settings row was a single line of four groups: Cancel All, the
auto-scroll and concurrency controls, the speed limit, and Clear
Finished. Adding the concurrency control pushed the total past what the
queue panel is usually given, and Clear Finished - last in the row -
was the one that ran off the edge. Narrowing the spinners bought some
room back but not enough to be worth relying on.
The two settings now stack, the way the WinForms panel does, with their
labels right-aligned in a shared column so they line up. That roughly
halves the horizontal space they need. The row grows from 40 to 72 to
hold both lines, and the two buttons stretch to the new height.
WinForms positioned every control in the panel absolutely, mixing
left-anchored and right-anchored ones. At the design width it looked
fine; narrower than that, Clear Finished slid left over the "At once"
spinner - and the DL Limit label had always slid onto Cancel All the
same way, long before this branch. The two rows also never lined up
with each other, because the labels and the spinners each started at
different offsets.
Cancel All and Clear Finished now dock to the panel edges and the
settings sit in a two-row table between them: labels right-aligned in a
fixed column, controls left-aligned in the next, so "At once" and
"DL Limit" line up and stay lined up. Nothing can overlap, because
nothing is placed by hand any more.
Avalonia's row gained a fourth column for the same setting, which left
less room before the star column collapses and Clear Finished clips.
The spinner asked for 90px to show a number between 1 and 10; 72 is
enough and gives most of that width back.
'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>
That the file existed said only that the server sent a body, and an Audible error is
a 200 with a JSON body like any other response. Dinah's downloader renames by
Content-Disposition, so such a body landed in the book's folder under whatever
Audible called it and the title was recorded as having its PDF.
Check the payload: a file named .pdf must carry the PDF header, and nothing may begin
with the opening character of a JSON or markup document. A rejected download is
deleted rather than left in the library, which also lets the empty-folder cleanup
run, and the file is added to the path cache only once it has passed.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
A storefront that no longer lists a title still answers a request for it: HTTP 200,
total_results 1, and a product carrying an asin and a few always-returned flags.
Nothing between the request and the file noticed, so a re-download replaced a
metadata file written while the title was still listed - the only copy of that data -
with the placeholder.
Fetch the product before touching the destination, and leave the file alone when the
product carries no title. Reported in issue #1947, where a Canada-only title produced
{"asin":...,"asset_details":[],"is_preview_enabled":false,"is_vvab":false,"rating":{...}}
against every other storefront.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
<title short> stops at the first colon, so it shortens Audible titles that
contain one just as readily as it drops Audible's subtitle, and distinct books
then collapse onto the same name. A colon cannot be searched for: the analyzer
discards punctuation and Lucene reads a colon in a query as a field separator.
Two bool index fields find the affected books instead.
Document how the two title tags differ, since <audible title> already drops
Audible's subtitle without ever cutting a title, and how to audit for names
that actually collide in a spreadsheet export.
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.
TrackedQueue<T> is a pure data structure with no dependencies beyond
System, so the behaviour parallel downloads relies on can be asserted
directly rather than inferred from the UI.
Every case here needs more than one book active at once, which is what
made these defects unreachable before. The two that mattered: a book
finishing out of order now reports the reorder, so a list bound only to
CollectionChanged stays in step with the queue; and the queue can be
enumerated while it is being mutated, which used to throw.
The sequential path is covered too - one book at a time still raises no
Move, because the book that finishes is already first.
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.
The 'At once' spinner sat at x136-181 on the second row, but the
'DL Limit:' label starts at x148. Moves the label and spinner up to the
first row beside the Auto-scroll checkbox, where there is clear space
between Cancel All and Clear Finished.
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.
Restores the plain 'dotnet publish' invocations in
Scripts/Windows/Build-WindowsInstaller.ps1. Hardcoding
C:\Program Files\dotnet\dotnet.exe is machine-specific and unrelated
to parallel downloads.
Snapshot GetVisible() on the UI thread before handing off to Task.Run,
preventing InvalidOperationException when parallel downloads modify the
collection concurrently. Also fix installer script to use 64-bit dotnet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The dialog names the account in full because it is shown to whoever owns it, but
log files get attached to public issue reports, which is why the codebase has
MaskedLogEntry. Naming the account in the log the same way the dialog does would
have put real email addresses into every shared log.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
This reverts 6daaf33d. Master is 13.7.8 and the next release is the 0.0.1
increment from it, so the original 13.7.9 references were correct.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Both grids restored the last good filter by recursing into the filter handler, which
never terminated once the search index rather than the query was the problem: the
restore fails the same way, and the retry uses the same filter. The user got an
endless run of dialogs, each of them blaming a filter string that was fine. Only an
empty last-good filter broke the loop, because that short-circuits before reaching
the search engine.
The fallback is now a bounded sequence -- last good filter, then no filter -- and
the message distinguishes an index Libation cannot reach from a query it cannot
parse. Only the first failure is reported, so restoring is quiet. A malformed query
never surfaces as an IO-family exception, which QueryFailureShapeTests pins against
the real engine, so a typo is never mistaken for index trouble or made to trigger a
rebuild.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The two path assertions added with the PDF fix compared a path the test built
itself against one that had been through LongPath, which on Windows prefixes a
drive-rooted path with \\?\ so paths past the 260 character limit work. Linux
adds no prefix, so this only showed up on the Windows job.
Normalising both sides is not just about the false failure. The inequality
assertion guarding 'the PDF was saved loose in the Books directory' compared a
raw temp path against a prefixed one, so on Windows it passed on the prefix
alone and would not have caught the bug it exists to catch.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Windows CI caught real over-reach. When another holder has write.lock, Windows
raises the sharing violation before Lucene can turn it into a
LockObtainFailedException, so it arrives as a plain IOException. Repairing anything
that is not a recognised lock conflict then meant deleting the index the other
holder was using -- exactly the second-instance case the retry exists for.
An IOException naming Lucene's write lock now counts as a lock conflict. Matching
the file name rather than the message wording keeps it working on non-English
Windows. The end-to-end test asserts the property instead of the exception type,
since the type legitimately differs by platform.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Releasing the write.lock part way through the retry budget raced with Lucene 3's
own lock bookkeeping: on Windows a competing Obtain left a handle on the file, so
Release and the temp directory cleanup both failed with a sharing violation. Hold
the lock for the whole budget instead and assert what actually matters, that a lock
conflict is retried and leaves the index files alone.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
A PDF is fetched through the same license request as the audiobook, so following
a refusal with a PDF request reproduced, through the PDF, exactly the per-run
refusal the wait exists to stop. The follow-up pass now skips the titles the
first pass deliberately left alone as well as the ones it attempted.
Also stop a failed PDF download leaving an empty folder in the library: a
PDF-only download is the one case that has to create the book's folder before it
has anything to put in it, so it now removes a folder it created and did not
fill. GetProposedDownloadFilePath goes back to being a pure path computation.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Troubleshooting gains an entry for missing and misplaced PDFs, including the
naming-template cause, since a library with no <id> in its folder and file
templates is one Libation cannot recognise the output of at all. The CLI
reference notes what a plain liberate run now covers, and that the
Audiobookshelf upload is deliberately not part of the PDF back-fill.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
A plain 'libationcli liberate' iterates the titles DownloadDecryptBook selects,
and that step selects on '!AudioExists'. A title needing nothing but its PDF was
therefore never reached by the verb documented as 'book and pdf backups' - only
'liberate --pdf' picked it up. For a library that was liberated before its PDFs
were, that is every title with a PDF.
Give the bulk run an optional second pass and have liberate use it for PDFs, the
way the app's Liberate All always has. Skipped when the first pass stopped early
so a run cut short by its download limit does not carry on doing other work, and
titles the first pass attempted are excluded by product id rather than by asking
Validate again, so a step that just failed is not immediately retried.
Left alone: the Audiobookshelf upload stays tied to a fresh liberation. Its
Validate passes for any liberated title, so including it here would walk the
whole library on the next run. 'abs upload' already exists for that.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
getProposedDownloadFilePath looked for the book's audio file and fell back to
the Books directory itself when it found none. That lookup matches on the
product id appearing in the path, so it finds nothing for a library whose
folder and file templates omit <id>, and nothing for a title marked downloaded
whose files are not on this machine. Those PDFs landed in the library root,
where they also shared one namespace and so could collide with each other.
Fall back to the folder template instead - the same folder the audiobook itself
would go in - and create it, since nothing else does on the PDF-only path.
Also give MockLibraryBook a three-field version: ToVersionString formats to at
least three fields, so the two-field default threw as soon as anything rendered
a naming template for a mock book.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Ported from #1949. The reporter's log paused auto-scan on a second account that
had never been logged in, while the dialog blamed an expired session and named no
account, so there was nothing to act on.
AccountCredentialStatus tells a never-registered account apart from one holding an
expired access token, by looking for a refresh token to renew from. AutoScanRunner
now hands the AuthenticationRequiredException to the notification so the prompt can
name the account, which means digging that exception back out of the wrappers the
scan adds on the way up. Same distinction in the log line and in the exception
message ApiExtended throws when interactive login is unavailable, which is what the
CLI and Docker users see.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Ported from #1949, which surfaces the manual recovery steps the maintainer had
been giving out by hand instead of leaving the user with a raw Lucene error.
Adapted to the failure now being contained: with the exception no longer escaping
into the library change, the scan-failure catch blocks #1949 hooked would never
see it, and hooking only those would still miss every other trigger -- removing
books is what crashed the GUI. So the guard moves from AppScaffolding into
SearchEngineCommands next to the update commands it protects, and raises
UpdateFailed from there. Both GUIs subscribe, so any trigger is covered, and the
event carries the exception rather than needing #1949's StackTrace string sniffing
to find it. The dialog is shown once per session: a damaged index fails on every
library change and these steps only need following once.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Fold the pdf-only exclusion into HonorsDeferredRetries instead of also checking
the processable type in the run loop, split the user-facing message building
into its own file next to the store, and leave GC.Collect on the success path
where it was.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Adds a Features page covering the wait schedule, what clears it, and what the
CLI and app show, plus cross-links from the CLI reference, the daily download
limit page and troubleshooting. Troubleshooting also gains an entry for a log
too large to attach to a bug report, which is how the reporter in issue #1947
found this.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Lucene 3's base-36 filename formatter overruns its buffer when segments.gen names
an absurd generation, so the rebuild path has to survive an IndexOutOfRangeException
as well as the IOException shapes. Damage does not always announce itself as IO.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Asserting on the generated JSON alone would pass just as happily with a
misspelled sink argument, which Serilog ignores in silence - and silently not
rolling is the bug. These build a real logger from Libation's own config and
write until it rolls, including a test that pins the old unbounded behaviour so
a future change to the defaults cannot quietly restore it.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The classifier's inputs are the actual denials from the log attached to issue
#1947: owned titles on an inactive account, a Plus title no longer in the
catalog, and an unreleased preorder Audible has no audio for.
The backoff tests also caught a real overflow: first * 2^n exceeds a TimeSpan
long before the cap matters.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
A license denial left no trace: BookStatus stayed NotLiberated, so every
liberate run asked again. Only the GUI's bad-book dialog could mark a title
Error, and license denials take their own path and never reach that dialog, so
a headless install had no way at all to stop the retries. A cron schedule then
re-requested the same refused licenses every run and printed the same warning
block for each one, which is both wasted API traffic and the log noise reported
in issue #1947.
Record the refusal instead, with a wait that doubles per consecutive failure:
one day for an eligibility denial (up to 30), six hours for a title Audible has
no audio for yet such as an unreleased preorder (up to 7 days), one hour when
the denial names GenericError, which the GUI already reads as an outage
(up to 12). Nothing is permanent - every kind is attempted again on its own.
Only failures attributable to Audible are recorded. A dropped connection, a
decrypt error or a full disk keeps being retried on the next run as before.
Naming a title, --force, and setting a download status all clear the record:
asking for a title explicitly overrides the wait.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
A truncated or zero-length segments file is reported by Lucene 3 as a plain
IOException ("read past EOF") rather than a CorruptIndexException, so it was
misclassified as a write.lock conflict: CreateNewIndex burned its whole backoff
budget and rethrew, and the delete-and-rebuild recovery never ran. Passing
create/overwrite to IndexWriter does not repair it either, because
IndexFileDeleter reads every segments_* file in the directory and tolerates only
missing ones, so a single unreadable segments file -- even a stale one from an
older commit -- leaves the index permanently unopenable. The user's only cure
was deleting the SearchEngine folder by hand.
Retries are now reserved for genuine lock conflicts (LockObtainFailedException,
which derives from IOException, and UnauthorizedAccessException), and any other
open failure gets one delete-and-rebuild pass before giving up with a message
that says which folder to remove. The query path recovers too, since
IsRecoverableCorruptIndexException now recognizes the truncated-segments
signature.
Search index updates are also no longer allowed to fail the library change that
triggered them. Both events fire after the database is committed, so an escaping
exception reported a successful scan as "Error importing library" and, being the
first subscriber, stopped the handlers that refresh the grid and backup counts.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The default Serilog config set rollingInterval only, so Serilog's own defaults
applied: no size-based roll and a 1 GB ceiling after which the sink silently
stops writing. A busy install (many accounts scanned several times an hour)
reaches tens of MB in a month, past the point where the log can be attached to
a bug report.
Add fileSizeLimitBytes, rollOnFileSizeLimit and retainedFileCountLimit to the
default File sink, and fill in whichever of the three an existing Settings.json
is missing so installs that already have a Serilog section benefit too. Only
absent keys are written, so a hand-tuned config is left alone.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Reflect community testing: once the subscription is inactive, Libation
cannot obtain a license and will not download those books.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Drop the series-parent reason, which no caller can reach: parents are expanded
into their children before anything is queued. Replace the enum and its three
switches with a record carrying the label and the advice, which is all the
switches were mapping to.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
"Absent from your last library scan (run Scan, ...): 2" buries the number behind a
parenthetical. Lead with the label and count, then the advice.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The menu item is enabled from the grid's display status, so it can be clicked for
episodes the queue will reject - episodes absent from the last scan are the usual
case. Pre-filtering the children threw that reason away and left the queue with an
empty request it could only answer in general terms.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Names the local function ProcessOrStopAsync so it does not shadow
Processable.TryProcessAsync, which means something else entirely, and asserts
the whole sentence a stopped run prints rather than a fragment of it.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Gives the three liberate options their own section on the command line page,
and cross-references them from the daily download limit page so the two
limits are not confused for each other.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Two levels. The pure tests supply a run's history directly and check the
decision to stop: the count and size thresholds, the always-allow-one rule,
and that titles another process downloaded, or that this run attempted
without downloading, are not counted.
The loop tests drive the real run loop against a real library database with
a processable that records downloads the way DownloadDecryptBook does, which
is as close to a limited run as is possible without an Audible account. They
pin the behavior that matters at the boundary: five books under a limit of
two download two, and a run whose books end exactly at its limit reports
nothing, because nothing was cut short.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The multi-book branch of QueueDownloadDecryptAsync returned false with no log
entry and no message whenever UnLiberated() came back empty, so a request
Libation understood and declined looked exactly like a dead button. Callers that
pre-filter with UnLiberated() land here with an empty list, which is the common
way to hit it.
Classify each title that cannot be queued and report the breakdown: already
downloaded, previously failed, absent from the last scan, or a series parent with
no audio of its own. Log it either way; show it only when a person is waiting,
so the automatic post-scan download stays silent.
Fixes#1940
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Adds three mutually exclusive options to `liberate`: --limit-books,
--limit-mb and --limit-gb. Each stops the run once it has downloaded that
much, leaving the rest of the library un-liberated for the next run.
Requested in #1920: a scripted or scheduled run has no way to take only a
slice of a large library, so users resort to feeding the CLI a handful of
ASINs at a time. The GUI needs no equivalent, where selecting rows already
says exactly which titles to download.
Counting reuses the daily limit's history rows rather than a private tally,
so a book and a byte mean the same thing to both limits, and failed,
cancelled and pdf-only work is never counted. Only titles this run attempted
are counted, so a Libation window or a second container downloading at the
same time does not consume this run's allowance.
The limit is checked before each title rather than at the top of the run, so
a run whose books happen to end exactly at the limit reports nothing: nothing
was cut short. The daily download limit keeps applying on top, unchanged.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Cancel All sets a flag the daily-limit wait loop watches. If books were queued
while the cancelled book was still settling, the queue was still running, so
those new books inherited the cancellation at the gate. Queueing work now
clears the flag.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Adds a feature page covering the rolling window, what counts, the MB/GB
estimate and the Docker/CLI keys, plus a one-line pointer from getting started
where a new user with a large Plus library clicks Begin Book Backups.
Scripts/seed-download-history.cs seeds fake completed downloads so the limit
can be exercised without downloading, including dating rows just under 24 hours
old to turn the multi-day pause-and-resume behavior into a one minute test.
Also shortens the queue status text: the process queue column clips rather than
wrapping, so the resume time was being cut off.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Avalonia and WinForms both get a Daily download limit group on the
Download/Decrypt tab: scope drop-down, and when a limit is chosen a quantity
spinner (minimum 1, no practical maximum) plus a Books/MB/GB unit, with the
MB/GB approximation note shown only for those units.
A license denial that looks like Audible throttling now suggests turning the
limit on, quoting the real number of downloads Libation recorded in the last 24
hours. Audible reports no distinct throttling reason, so the suggestion stays
silent unless that record makes throttling plausible.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Records every successful audiobook download in the library database (a new
DownloadHistory table) and, when the user opts in, stops downloading once the
rolling 24 hour window is full.
The history lives in the database rather than a file under LibationFiles
because in Docker only the database is on a volume; a file there is discarded
on every container restart.
The limit is checked immediately before each book downloads, never at queueing
time, so a full queue stays full and the user can raise or disable the limit
mid-run. When nothing in the queue can proceed the queue pauses and re-checks
every 15 seconds, recomputing settings, history and clock from scratch, so a
queue left running for days drip-feeds itself as downloads age out. The CLI
never waits: it skips covered titles and reports a count.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Empty-args usage now lists nested command groups alongside root verbs.
Nested `abs upload --help` brands the public command path instead of the
internal parser verb.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Every Liberate-column icon needs a book in a particular state to show up, and
the yellow lamp additionally needs a partial download on disk. Reproducing that
by hand to eyeball the column is tedious, so script it.
It is a file-based app outside any project directory, so nothing compiles it:
run it with 'dotnet run Scripts/seed-demo-library.cs'.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The Liberate icon offers an action rather than reporting a state, so an expanded
series shows a minus to collapse it and a collapsed one shows a plus to expand
it. Naming the geometry after the state it belonged to had these swapped.
Name it after its shape instead, and pin the direction with a test: the plus is
the minus plus a bar, so it is strictly the inkier of the two.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Cover every icon in the finite set: each renders a valid PNG, no two render
alike, stoplights keep a common height, a PDF overlay only widens them, and
repeat requests are served from the cache.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Replace the hand-composed vector layers with the shared rendering bound through
EntryStatus.ButtonImage, and delete the geometry and stoplight brushes that only
that composition used.
The rendering is per-theme rather than a set of DynamicResource brushes, so each
button re-renders its icon when the actual theme variant changes.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Delete the 24 pre-baked stoplight PNGs and their resx/designer entries, and
register the dark-mode probe the shared generator needs to pick a palette.
The shared renderings are supersampled, so DrawButtonImage takes the scale they
were rendered at and keeps drawing them at their logical size.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Both UIs need the same stoplight/PDF/error/series icons. WinForms selected
pre-baked PNGs by name while Avalonia composed vector layers in XAML, so the
two drifted and every new combination needed another pair of PNGs.
Port the Avalonia artwork and palette into LibationUiBase and rasterize it with
SkiaSharp, keyed by a descriptor of the finite combinations and cached in memory.
EntryStatus.ButtonImage now serves that shared rendering to both UIs.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Matches the codebase convention for case-insensitive string comparison
instead of calling string.Equals with StringComparison.OrdinalIgnoreCase.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Add AccountsSettings.GetAccount case-insensitivity test (issue #1931) and a
SingleInstanceTests suite verifying first/second acquisition, per-folder keying,
release-and-reacquire, and trailing-separator/case-insensitive folder matching.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Concurrent Libation instances against one LibationFiles folder raced on the
SQLite database, Lucene search index, and log file, and the startup routine
unconditionally deleted LibationContext.db-wal/-shm - discarding committed
transactions when a prior run died abruptly (issue #1931).
- Add SingleInstance (named mutex keyed on the LibationFiles folder). Wire it
into the Avalonia and WinForms startup so a second launch shows a message and
exits before any database access, and holds the lock for the process lifetime.
- DeleteOpenSqliteFiles now skips cleanup when the DB is held by another process
and preserves a non-empty (unrecovered) WAL so SQLite can recover it on open.
Also compare AccountId case-insensitively in AccountsSettings.GetAccount so a
capitalization difference no longer causes spurious 'No account found' errors.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
MainWindow_Loaded is an async void handler whose only catch was filtered to
install-folder assembly failures, so any other library-load exception escaped
and crashed Libation at startup (issue #1931). Add a general fallback catch
that logs, shows a non-fatal message, and continues with an empty grid.
Also make UpdateGridAsync rebuild the grid when GridEntries is null instead of
throwing 'Must call BindToGridAsync first', which previously wedged the grid
and spammed 'Library Size Change Error' on every auto-scan after a failed
initial bind.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Reading RunWorkerCompletedEventArgs.Result rethrows any exception from the
count DoWork. In the Avalonia async void handler this became an unhandled
exception that killed the app during library load (issue #1931). Check
e.Cancelled/e.Error first, log once, and degrade to empty stats in both the
Avalonia and WinForms backup-count completion handlers.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Unit tests run on windows-latest, where '|' is an invalid filename
character and is rewritten by ReplacementCharacters, so the assertions
failed on CI. Use ' - ' instead, which is valid on all platforms.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The conditional open tag requires the -> delimiter. Written as
<has series#>, the engine does not recognize a conditional, so the
later <-has> closes the enclosing <if series-> instead and the template
reports "Missing <-if series> closing conditional." while leaking the
literal text "<has series#>" into the name.
Also add a nested-conditional example, since the reference table only
shows each conditional in isolation, plus regression tests for
<has series#-> nested inside <if series-> when a book is in a series
but has no series number.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Editing a fluent palette color removes and re-adds the app's FluentTheme.
Doing that while the theme editor's color picker flyout is open leaves the
flyout's tab content parented to its old presenter, so reopening the picker
throws 'The control Grid already has a visual parent'.
Fixes#1927
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
ConfigureFrom always calls ResolveSecretStore, which falls through to
OsSecretStore.Create(...).IsAvailable when no master key file or env var is
present. That is a blocking libsecret call: on a headless machine, or one whose
login keyring is locked, it waits on a desktop unlock prompt that never gets
answered. Five tests reached it and one reached it twice, so the project took
12+ minutes instead of seconds.
Probing availability first does not help, because the probe is the blocking call.
Tests that only assert which write method gets configured now resolve the master
key from a temp key file, so they short-circuit before the OS store. This also
stops them minting a last-resort key into the real Libation folder.
The two tests that exist to exercise the real OS store are opt-in via
LIBATION_TEST_OS_SECRET_STORE=1.
AudibleUtilities.Tests: 12m 20s -> 1.8s, 72 passed / 2 skipped / 0 failed.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Extract the link-then-prune step out of getItemsAsync so the behavior that
removes a podcast episode from a scan can be exercised directly, and pin it
down with tests, including the season-container case where an episode's parent
is not something Libation treats as a series parent.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Audible's catalog endpoint can answer 200 while omitting products from the
response. getProductsAsync returned whatever came back, so any podcast episode
Audible skipped simply vanished from the scan. The reporter's log shows this:
across 1117 consecutive scans of an unchanged 440-item library, the post-scan
item total drifted between 2147 and 2151.
Re-request the omitted asins before accepting the loss, and warn with the asins
that are still unaccounted for afterwards.
The rest of the scan's exclusions were equally invisible at the default log
level, which is why the reporter found nothing in the log about the missing
book:
- episodes dropped for having no series parent were logged at Debug, without
identifying them. Warn instead, and name them.
- titles excluded by ImportEpisodes / ImportPlusTitles were not logged at all.
Tally them, and record both settings in the startup state block.
Read the two import filters once per scan so a settings change mid-scan can't
produce a half-filtered library.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
FindLongestMountPointPrefix is exercised on Windows CI with Unix-style
paths. Path.DirectorySeparatorChar is '\\' there, so '/var/home' never
matched '/var/home/...'. Hardcode '/' for Unix mount identity.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Path.GetPathRoot always returns "/" for absolute Unix paths, so bulk
backup preflight queried composefs root free space (often 0 on Bazzite)
instead of the filesystem containing Books/In progress.
On Unix, symlink-canonicalize paths (so /home -> /var/home) and pick the
longest DriveInfo.GetDrives() mount prefix. Route GetBackupDriveSpaces
through the shared helper so grouping and free-space queries agree.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
The first slow AudibleUtilities.Tests run was blocked on a desktop keyring
password prompt for Libation's OS secret-store master key. Document that
gotcha and remove the incorrect network-timeout theory.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Auto-upload only fires when a book is liberated. Books liberated before
Audiobookshelf was configured, or while it was disabled, had no path to the
server short of re-downloading the whole library.
'libationcli upload' backfills them from the files already on disk. Bulk or
targeted by ASIN. Nothing is re-downloaded and no local file is deleted.
Also fixes a latent defect this exposes. Validate() reads a database status
(Book.AudioExists) while GetFilesToUpload() read only FilePathCache. A book
liberated long ago passes validation but has no cache entry, so the upload
found no files and returned success having sent nothing. File lookup now uses
AudibleFileStorage.Audio.GetPaths, which unions the cache with a live scan of
the Books directory.
Other changes:
- Validate() now requires LiberatedStatus.Liberated. It previously accepted
Error too, whose partial files should not be uploaded.
- New OutcomeDetermined event classifies each book as Uploaded, AlreadyExists,
NoFilesFound or Failed. Failures travel on this event rather than through
StatusHandler: the GUI process queue treats a non-success StatusHandler as a
bad book and raises the Abort/Retry/Ignore dialog, and an upload problem must
never fail a liberation.
- The verb prints an end-of-run summary and exits 0, matching other verbs.
No database migration. Duplicate detection already runs server-side inside
UploadBookAsync, so repeat runs are safe without local upload state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes xHE-AAC (USAC) seeking in Apple players: AAXClean now writes the
sync sample table (stss) required by ISO/IEC 23003-3 for USAC output,
and chapter-split files are sample-accurate via edit lists
(Mbucari/AAXClean#18, Mbucari/AAXClean#19).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Persist settings: enabled, server URL, API token, library/folder IDs
- Add AudiobookshelfApiService for login, library listing, and multipart upload
- Add UploadToAudiobookshelf post-download processable
- Add settings tab to WinForms and Avalonia with library/folder dropdowns
- Match Avalonia layout to WinForms with aligned columns
Reliability: `hdiutil create` can fail on GitHub’s macOS runners with `Resource busy` even when the app build is fine. The retry wrapper gives it several attempts, waits between tries, and cleans up partial DMGs so transient runner issues are less likely to break the release.
Correctness: The script used to keep going after `hdiutil` failed and still exit successfully, which left `./bundle/` empty and made artifact upload fail with a misleading error. `set -euo pipefail` and explicit failure exits make a DMG creation failure stop the job immediately instead of pretending the bundle step succeeded.
DbContexts.GetContext() runs ApplyMigrations on every context creation,
calling Database.Migrate(). Migrate() acquires the EF migration lock
before checking whether anything is pending. For SQLite that lock is a
persisted row in __EFMigrationsLock with no timeout (SQLite has no
connection-scoped lock that frees on disconnect). A process killed after
acquiring the lock but before releasing it - even during an otherwise
no-op Migrate() - orphans the row, and every later Migrate() then spins
forever in SqliteHistoryRepository.AcquireDatabaseLock(). The row lives
in the db file, so restarts never clear it, matching the "scan hangs,
reboot doesn't help" reports in #1729.
Guard Migrate()/MigrateAsync() behind GetPendingMigrations(), which only
reads __EFMigrationsHistory and never acquires the lock. The steady-state
path (schema already current) no longer touches the lock at all; first
run and real migrations are unchanged.
VitePress 1.6.4 requires vite ^5.4.14, which has no backport for
GHSA-4w7w-66w2-5vf9. An npm override keeps VitePress 1.x while
using a patched vite. Deploy workflow now watches package files.
Co-authored-by: Cursor <cursoragent@cursor.com>
The de-CH row hard-coded U+2019 as the expected thousands separator,
which only matches certain .NET/ICU/CLDR data versions. Linux .NET hosts
return U+0027 (ASCII apostrophe) for the same culture, so every Linux CI
run failed this test regardless of the actual change under test.
Resolve U+2019 in DataRow expectations to the runtime culture's
NumberGroupSeparator before comparison so the test stays stable across
hosts while still verifying the engine respects culture-specific
formatting.
- Added DataRows for de-DE (period) and ja-JP (comma)
- Added Samplerate_template_uses_culture_NumberGroupSeparator as an
explicit regression guard that asserts the engine uses whatever the
runtime CultureInfo reports
Fixes#1813.
Verified locally: 584 tests pass, 0 failed (565 succeeded, 19 skipped
Windows-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Category names were accidently removed during the last DB migration in d67692355f.
Re-add the Names column and modify CategoryImporter to upsert the names on update.
- fixed documentation
- regexp-checks running with timeout and culture-invariant matching
- changed check-building in ConditionalTagCollection to use NonNull parameters. So no warnings occure.
- add tests for <!is ...> and escaped chars
Introduce <is-> Tag. Like <has-> but with additional check on content.
Retrieve objects instead of string for conditions
Pass undefined formats as null instead of empty strings
- **Change:** Capture notarytool stdout+stderr with `|| true` so the script always runs `echo "$RESPONSE"`, then fail the step explicitly if no submission id is found. The job still fails on errors, but the full notarytool output (including stderr) is now visible in the Actions log.
- **Result:** Failures like "you must accept the agreement" show up in the run so you don’t have to reproduce them locally to see the message.
Add GetUnliberated_Flat_NoTracking() which queries only unliberated books/episodes and does not load the entire library.
Fix UnLiberated() query to only return products or episodes (not parents) (#1564)
- Use the main display grid control to display deleted books
- Added search functionality for deleted books. This required creating a temporary search index in the `InProgress` folder. The products grid control now uses an instance of `ISearchEngine` to filter its grid entries. The main grid uses a singleton instance of `MainSearchEngine`, which merely wraps `SearchEngineCommands.Search()`. The TrashBinDialogs use `TempSearchEngine`.
- Users can now batch select `Everyting` as well as `Audible Plus Books`
Avalonia:
- Refactor main grid context menus to no longer require reflection
- Add `IsAudiblePlus` to search engine
- Add `IsAudiblePlus` and `AbsentFromLastScan` properties to library export
- Refactor library export ToXlsx method
- Make nullable
- Improve readability and extensability
- Use same column header names as CSV
- Extend export methods to accept optional list of books (future use)
Add AccessibleDataGridViewColumn which can apply Accessability names and descriptions from the designer.
Create reusable SortBindingList<T> for basic sorting of data-bound items.
All processables are now created with an instance of Configuration, and they use that instance's settings.
Added Configuration.CreateEphemeralCopy() to clone Configuration without persistence.
- Add dark theme icon variants
- Change all light theme icon fill colors to match Chardonnay
Also fixed#1460 by chaing the directory select control to DirectoryOrCustomSelectControl
Change Avalonia's Task-based approach to WinForms' BackgroundWorker approach.
- Reduce number of calls to GetLibrary by adding the Library to the LibraryStats record.
- Remove instance queue. This is a database, after all, and is designed to be accessed and written to concurrently
- Reduce the number of calls to DbContexts.Create()
- Ensure that no LibationContext remains open across an await boundary. Multithread context access is the most likely culprit for past issues.
- Make all Update UserDefinedItem methods asynchronous.
- Update all project runtime targets
- Update all dependencies
- NOTE: Using Npgsql.EntityFrameworkCore.PostgreSQL RTM build from MyGet
- Delete unused pubxml files (they were made redundant by recent workflow changes)
- Replace Libation.sln with Libation.slnx
- Add `LIBATION_FILES_DIR` environment variable to specify LibationFiles directory instead of appsettings.json
- OptionsBase supports overriding setting
- Added `EphemeralSettings` which are loaded from Settings.json once and can be modified with the `--override` command parameter
- Added `get-setting` command
- Prints (editable) settings and their values. Prints specified settings, or all settings if none specified
- `--listEnumValues` option will list all names for a speficied enum-type settings. If no setting names are specified, prints all enum values for all enum settings.
- Prints in a text-based table or bare with `-b` switch
- Added `get-license` command which requests a content license and prints it as a json to stdout
- Improved `liberate` command
- Added `-force` option to force liberation without validation.
- Added support to download with a license file supplied to stdin
- Improve startup performance when downloading explicit ASIN(s)
- Fix long-standing bug where cover art was not being downloading
- Move all settings file logic into new LibationFiles class
- Configuration.LibationFiles is a singleton instance of the LibationFiles class.
- A LibationFiles instance is bound to a single appsettings.json path. All updates to LibationFiles location are updated in that appsettings.json file
- Unify initial setup and settings validation process
- Add LibationSetup which handles all startup validation of settings files and prompts users for setup if needed
- Added a new LibationUiBase.Tests test project with tests for various
- Add new repo variables
- `SIGN_MAC_APP_ON_VALIDATE` will force sign/notarize on the validate workflow (normally only done for releases)
- `WAIT_FOR_NOTARIZE` Causes the build-mac workflow to wait for apple to notarize the bundle so that it can be stapled. This is usually fast (1-2 mis), but can be very long and may cause workflow runners to time out.
This will break _Automatic_ updates for existing mac users (although I'm not sure it worked all that well to begin with. However, the update notification dialog has had a link to download the bundle manually for a long time now. Old users will still be notified of the new release and be given a direct link to download it.
- Added `MockLibraryBook` which contains factories for easily creating mock LibraryBooks and Books
- Added mock Configuration
- New `IPersistentDictionary` interface
- New `MockPersistentDictionary` class which uses a `JObject` as its data store
- Added `public static Configuration CreateMockInstance()`
- This method returns a mock Configuration instance **and also sets the `Configuration.Instance` property**
- Throws an exception if not in debug
- Updated all chardonnay controls to use the mocks in design mode. Previously I was using my actual database and settings file, but that approach is fragile and is unfriendly towards anyone else trying to work on it.
- Use Avalonia-based webview control for Audible login with Chardonnay
- Remove webview interfaces from IInteropFunctions
- Remove Microsoft.Web.WebView2 package from WindowsConfigApp
- Add Microsoft.Web.WebView2 to LibationWinForms
- Remove all other login forms except the external login dialog (fallback in case webview doesn't work). The AudibleApi login with username/password doesn't work anymore. Need to use external browser login method.
Supporting postgres simplifies deployments to environments such as kubernetes. Since sqlite doesn't work well on nfs shares it can be easier for databases to have a dedicated db set up that applications can connect to. Sqlite is easier for most deployments though, so this will default to that if the settings haven't been updated to support it.
This change does the following:
- Separate out SQLite from the DataLayer and adds a Postgres assembly for migrations as well
- Add a configuration setting for a postgres connection string that will be used if it is there, otherwise reverts to the original sqlite string
- Add a copydb command for the cli to bootstrap the postgres db
- A convenience script to update migrations for both dbs at the same time
- Configuration.LibationSettingsAreValid is true if Books property exists and is any non-null, non-empty string.
- If LibationSettingsAreValid is false, Libation will prompt user to set up Libation.
- When the main window is shown, Libation checks if the books directory exists, and if it doesn't, user is notified and prompted to change their setting
- When a user tries to liberate or convert a book, Books directory is validated and user notified if it does not exist.
The shell.nix file is used for both flake and non-flake invocations. The lock file is also set at a version where the project works.
Note the none-flake method will follow the version of the system and isn't guaranteed to work on older installations if they haven't been updated in a while.
Added Documentation for using Nix package manager for development ./Documentation/LinuxDevelopmentSetupUsingNix.md
Signed-off-by: Ayman Jundi <ajundi@gmail.com>
Automatically determine if filename lengths in the Books directory are limited to 255 UTF-16 characters (NTFS) or 255 UTF-8 bytes (pretty much every other file system) (#1260)
In non-Windows environments, determine if the Books directory supports filenames containing characters which are illegal in Windows environments (<>|:*?). If it doesn't, then ensure those characters are included in the user's ReplacementCharacters settings (#1258).
- Add Book.IsSpatial property and add it to search index
- Read audio format of actual output files and store it in UserDefinedItem. Now works with MP3s.
- Store last downloaded audio file version
- Add IsSpatial, file version, and Audio Format to library exports and to template tags. Updated docs.
- Add last downloaded audio file version and format info to the Last Downloaded tab
- Migrated the DB
- Update AAXClean with some bug fixes
- Fixed error converting xHE-AAC audio files to mp3 when splitting by chapter (or trimming the audible branding from the beginning of the file)
- Improve mp3 ID# tags support. Chapter titles are now preserved.
- Add support for reading EC-3 and AC-4 audio format metadata
- Add more null safety
- Fix possible FilePathCache race condition
- Add MoveFilesToBooksDir progress reporting
- All metadata is now downloaded in parallel with other post-success tasks.
- Improve download resuming and file cleanup reliability
- The downloader creates temp files with a UUID filename and does not insert them into the FilePathCache. Created files only receive their final file names when they are moved into the Books folder. This is to prepare for a future plan re naming templates
The ZipFile sink could cause program hangs. Additionally, the only reason it was ever used was to package verbose AudibleApi account login errors, saving the returned Html page as a file. Otherwise, the zip file only contains a .log text file.
- Removed Serilog.Sinks.ZipFile
- Add Serilog configuration migration
- Added a custom destructure to handle logging files. If any files are logged, they will be written to "LogyyyyMM_AdditionalFiles.zip"
Use existing BaseUtil.LoadImage delegate, obviating need for derrived classes to load images
Since GridEntry types are no longer generic, interfaces are unnecessary and deleted.
If quick filters are applied on startup, a race condition was created between the initial library load book counting and the visible books counting. Only display results of the latest book count.
Previously, only some calls to ApiExtended.CreateAsync() would prompt users to login if necessary. Other calls would only work if the account already had a valid identity, and they would throw exceptions otherwise.
Changed ApiExtended so that the UI registers a static ILoginChoiceEager factory delegate that ApiExtended will use in the event that a login is required.
v12.3.0 caused a regression with contributors with a single word name, causing the name to be doubled. This was caused by using that name as both the first and last name, so swap the first name with the (blank) last name rather than duplicate them.
This property was set to the highest quality returned by the library scan. Since adding quality option settings, it is no longer guaranteed to reflect the file that is downloaded. Also, the library scan qualities don't contain spatial audio or widevine-specific qualities., only ADRM.
- Add template tag support for multiple series
- Add series ID and contributor ID to template tags
- <first author> and <first narrator> are now name types with name formatter support
- Properly import contributor IDs into database
- Updated docs
- Theme changes do not require restart
- Fix some text appearing black in dark mode
- Fix dialog boxes not appearing correctly on Windows
- Fix queue vertical scroll bar overlapping items
- Add My Music and Local Application Data to known directories
- Make %localappdata%\Libation the default settings folder on *nix machines
- Make %MyMusic%\Libation\Books the default books folder on *nix machines
LibraryCommands.GetCounts hits the file cache hard. The previous cache implementation was linear list, so finding an entry by ID was (n). When you consider that each book may have many files, the number of cache entries could grow to many multiples of the library size.
The new cache uses a dictionary with the ID as its key, and a CacheEntry list as its value.
There are multiple subscribers to LibraryCommands.LibrarySizeChanged, and each one calls GetLibrary_Flat_NoTracking(). Passing the full library as an event argument speeds up all operations which happen after the library size changes.
Fix initial backup counts
Add version verb with option to check for upgrade
Add Search verb to search the library
Add export file type inference
Add more set-status options
Add console progress bar and ETA
Add processable option to liberate specific book IDs
Scan accounts by nickname or account ID
Improve startup performance for halp and on parsing error
More useful error messages
* Add dotnet test workflow
* main -> master
* Try a different workflow
* Add working-directory
* use windows runner
* use env var
* Fix build and test order
* Specify configuration
* Specify sln instead of working dir
* Specify that DOTNET_SLN is an env var
* Add publish workflow
* Add env.DOTNET_SLN to publish workflow
* Add publish job
* Combine publish into one job
* Just create an artifact
* Remove unused nuget lines
* Add Publish job back
Co-authored-by: Aaron Reisman <areisman@epic.com>
PLEASE FILL OUT THE FOLLOWING. Bug reports with limited information or lacking an attached log file may get limited or delayed help.
___
## Describe the bug
A clear and concise description of what the bug is.
**To Reproduce**
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
@@ -17,15 +21,25 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
**Expected behavior**
## Expected behavior
A clear and concise description of what you expected to happen.
**Screenshots**
## Screenshots
If applicable, add screenshots to help explain your problem.
**Platform**
## Platform
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files**
Attach your Libation log file here.
## Log Files
Attach your Libation log file here. If your user folder contains the file "LibationCrash.log", attach that also.
**Default Log File Locations**
|Platform|Folder|
|-|-|
|Windows|`%userprofile%\Libation`|
|macOS|`~/Library/Application Support/Libation`|
|Linux|`~/.local/share/Libation`|
**macOS:** If that folder does not exist because Libation never starts, try launching from Terminal and say whether it works: `/Applications/Libation.app/Contents/MacOS/Libation` (see [Troubleshooting for macOS](https://github.com/rmcrackan/Libation/blob/main/docs/advanced/troubleshoot.md#macos)).
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.
<pclass="widget-link"><a:href="rec.classicVsChardonnayFaqUrl"target="_blank"rel="noopener">What's the difference between Classic and Chardonnay?</a></p>
warn "symlink '${LINK_ORIGIN}' to '${LINK}' already established"
return0
fi
warn "removing existing symlink '${LINK_ORIGIN}' to '${LINK}'"
rm -f "$LINK"
elif[[ -e $LINK]]
then
error "found blocking file at '${LINK}' - can't create symlink"
exit1
fi
ln -s "${FILE}""${LINK}"
}
run(){
info "scanning accounts"
/libation/LibationCli scan
localscan_exit=$?
if["${scan_exit}" -ne 0];then
error "scan failed (exit ${scan_exit}); skipping liberate. If the log shows Failed to decrypt ExistingAccessToken, see https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken"
# This page has been moved to https://getlibation.com/docs/advanced/advanced
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Advanced: Table of Contents
- [Files and folders](#files-and-folders)
- [Linux and Mac (unofficial)](#linux-and-mac)
- [Settings](#settings)
- [Custom File Naming](#custom-file-naming)
- [Command Line Interface](#command-line-interface)
### Files and folders
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
* In Libation's initial folder are the files that make up the program. Since nothing else is here, just copy new files here to upgrade the program. Delete this folder to delete Libation.
* In a separate folder, Libation keeps track of all of the files it creates like settings and downloaded images. After an upgrade, Libation might think that's its being run for the first time. Just click ADVANCED SETUP and point to this folder. Libation will reload your library and settings.
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
### Linux and Mac
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592), [Linux and WINE](https://github.com/rmcrackan/Libation/issues/28#issuecomment-1161111014))
### Settings
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
### Custom File Naming
In Settings, on the Download/Decrypt tab, you can specify the format in which you want your files to be named. As you edit these templates, a live example will be shown. Parameters are listed for folders, files, and files split by chapter including an explanation of what each naming option means. For instance: you can use template `<title short> - <ch# 0> of <ch count> - <ch title>` to create the file `A Study in Scarlet - 04 of 10 - A Flight for Life.m4b`.
These templates apply to GUI and CLI.
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
# This page has been moved to https://getlibation.com/docs/getting-started
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Getting started: Table of Contents
- [Download Libation](#download-libation-1)
- [Installation](#installation)
- [Create Accounts](#create-accounts)
- [Import your library](#import-your-library)
- [Download your books -- DRM-free!](#download-your-books----drm-free)
- [Download PDF attachments](#download-pdf-attachments)
- [Details of downloaded files](#details-of-downloaded-files)
To install Libation, extract the zip file to a folder, for example `C:\Libation`, and then run Libation.exe from that folder to begin the configuration process and configure your account(s).
### Create Accounts
Create your account(s):

New locale options include many more regions including old audible accounts which pre-date the amazon acquisition

### Import your library
Be default, Libation will periodically scan the accounts you added above with a checkbox next to them. Nothing for you to do. You can also scan manually.
Select Import > Scan Library:

Or if you have multiple accounts, you'll get to choose whether to scan all accounts or just the ones you select:

If this is a new installation, or you're scanning an account you haven't scanned before, you'll be prompted to enter your password for the Audible account.

Enter the password and click Submit. Audible will prompt you with a CAPTCHA image.

Enter the CAPTCHA answer characters and click Submit. If all has gone well, Libation will start scanning the account.
In rare instances, the Captcha image/response will fail in an endless loop. If this happens, delete the problem account, and then click Save. Re-add the account and click Save again. Now try to scan the account again. This time, instead of typing your password, click the link that says "Or click here". This will open the Audible External Login dialog shown below.

You can either copy the URL shown and paste it into your browser or launch the browser directly by clicking Launch in Browser. Audible will display its standard login page. Login, including answering the CAPTCHA on the next page. In some cases, you might have to approve the login from the email account associated with that login, but once the login is successful, you'll see an error message.

This actually means you've successfully logged in. Copy the entire URL shown in your browser and return to Libation. Paste that URL into the text box at the bottom of the Audible External Login window and click Submit.
You'll see this window while it's scanning:

Success! We see how many new titles are imported:

### Download your books -- DRM-free!
Automatically download some or all of your audible books. This shows you how much of your library is not yet downloaded and decrypted:
The stoplights will tell you a title's status:
* Green: downloaded and decrypted
* Yellow: downloaded but still encrypted with DRM
* Red: not downloaded
* PDF icon without arrow: downloaded
* PDF with arrow: not downloaded
Or hover over the button to see the status.

Select Liberate > Begin Book Backups
You can also click on the stop light to download only that title and its PDF

First the original book with DRM is downloaded

Then it's decrypted so you can use it on any device you choose. The very first time you decrypt a book, this step will take a while. Every other book will go much faster. The first time, Libation has to figure out the special decryption key which allows your personal books to be unlocked.

And voila! If you have multiple books not yet liberated, Libation will automatically move on to the next.

The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
### Download PDF attachments
For books which include PDF downloads, Libation can download these for you as well and will attempt to store them with the book. "Book backup" will already download an available PDF. This additional option is useful when Audible adds a PDF to your book after you've already backed it up.
Select Liberate > Begin PDF Backups

The downloads work just like with books, only with no additional decryption needed.

### Details of downloaded files

When you set up Libation, you'll specify a Books directory. Libation looks inside that directory and all subdirectories to look for files or folders with each library book's audible id. This way, organization is completely up to you. When you download + decrypt a book, you get several files
* .m4b: your audiobook in m4b format. This is the most pure version of your audiobook and retains the highest quality. Now that it's decrypted, you can play it on any audio player and put it on any device. If you'd like, you can also use 3rd party tools to turn it into an mp3. The freedom to do what you want with your files was the original inspiration for Libation.
* .cue: this is a file which logs where chapter breaks occur. Many tools are able to use this if you want to split your book into files along chapter lines.
### Export your library

Export your library to Excel, CSV, or JSON
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/getting-started.md))
# This page has been moved to https://getlibation.com/docs/features/searching-and-filtering
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Searching and filtering: Table of Contents
- [Tags](#tags)
- [Searches](#searches)
- [Search examples](#search-examples)
- [Filters](#filters)
### Tags
To add tags to a title, click the tags button

Add as many tags as you'd like. Tags are separated by a space. Each tag can contain letters, numbers, and underscores

Tags are saved non-case specific for easy search. There is one special tag "hidden" which will also grey-out the book

To edit tags, just click the button again.
### Searches
Libation's advanced searching is built on the powerful Lucene search engine. Simple searches are effortless and powerful searches are simple. To search, just type and click Filter or press enter
* Type anything in the search box to search common fields: title, authors, narrators, and the book's audible id
* Use Lucene's "Query Parser Syntax" for advanced searching.
To see only books written by Neil Gaiman where he also narrates his own book. (If you don't include AND, you'll see everything written by Neil Gaiman and also all books in your library which are self-narrated.)

I tagged autobiographies as auto_bio and biographies written by someone else as bio. I can get only autobiographies with \[auto_bio\] or get both by searching \[bio\]
If you have a search you want to save, click Add To Quick Filters to save it in your Quick Filters list. To use it again, select it from the Quick Filters list.
To edit this list go to Quick Filters > Edit quick filters. Here you can re-order the list, delete filters, double-click a filter to edit it, or double-click the bottom blank box to add a new filter.
Check "Quick Filters > Start Libation with 1st filter Default" to have your top filter automatically applied when Libation starts. In this top example, I want to always start without these: at books I've tagged hidden, books I've tagged as free_audible_originals, and books which I have rated.

([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/searching-and-filtering.md))
**Libation** is a free, open-source application for downloading and managing your Audible audiobooks. It decrypts your library, removes DRM, and lets you own your audiobooks forever.
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
> <a href="https://getlibation.com"><img src=".github/download-icon.svg" width="20" height="20" alt="" /></a> **[Which version should I download?](https://getlibation.com)** — get a recommended download for your system on our site.
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
## Community & Support
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.
- **[Issues](https://github.com/rmcrackan/Libation/issues)**: Report bugs or request features.
- **[Donate](https://getlibation.com/donate)**: Support the project if you find it useful.
## License
Libation is released under the GPL-3.0 License
---
If you found this useful, tell a friend. If you found this REALLY useful, you can [donate here](https://getlibation.com/donate)
...or just tell more friends. As long as I'm maintaining this software, it will remain free and open source.
Developer utilities. None of these ship in a Libation install - they exist only in a source checkout.
| Script | Purpose | Documented in |
|--------|---------|---------------|
| `seed-demo-accounts.cs` | Seed fake Audible accounts, to reach what is gated on having one without signing in | [Testing Changes](https://getlibation.com/docs/development/testing) |
| `seed-demo-library.cs` | Seed a library covering every Liberate-column icon, for manual UI testing | [Testing Changes](https://getlibation.com/docs/development/testing) |
| `seed-download-history.cs` | Seed completed downloads so the daily download limit can be tested without downloading | [Testing Changes](https://getlibation.com/docs/development/testing) |
| `Bundle_Debian.sh` | Build the Linux `.deb` package | Used by `.github/workflows/build-linux.yml` |
| `Bundle_Redhat.sh` | Build the Linux RPM package | Used by `.github/workflows/build-linux.yml` |
| `Bundle_MacOS.sh` | Build the macOS app bundle | Used by `.github/workflows/build-mac.yml` |
| `Windows/` | Windows installer (Inno Setup) | [Windows/README.md](Windows/README.md) |
Usage for the testing scripts lives in the docs rather than here, so there is only one copy to keep current.
Step 8.1 of the Windows installers plan: a parameterized Inno Setup script that installs the same flat publish layout as the release zip, under a per-user directory, so the in-app upgrader can still overlay files via `ZipExtractor.exe`.
## Prerequisites
1. [.NET SDK](https://dotnet.microsoft.com/download) (same version as the repo; see `build.yml`).
2. [Inno Setup 6](https://jrsoftware.org/isdl.php) with `ISCC.exe` on PATH or in a default install location.
`BinDir` must already match CI layout (including `ZipExtractor.exe`, without standalone `WindowsConfigApp.exe`).
## Manual ISCC (without the helper script)
After publish and cleanup (remove `WindowsConfigApp.exe`, `WindowsConfigApp.runtimeconfig.json`, `WindowsConfigApp.deps.json` from `bin`), from `Scripts\Windows`:
Classic x64: use `MyReleaseName=classic` and matching `OutputBaseFilename` (`Libation-Classic.{version}-windows-classic-x64-setup`). Install folder and display name are set in `Libation.iss`. GitHub zip assets use `Libation-Classic.{version}-windows-classic-x64.zip`; `.releaseindex.json` also accepts legacy `Classic-Libation.*` zips on older releases.
arm64: set `MyArchitecture=arm64` and `ArchitecturesAllowed=arm64`.
## Installer behavior
- **Per-user install** (`PrivilegesRequired=lowest`, default dir under `{localappdata}`).
- **Shortcuts** target `{app}\Libation.exe` (required for upgrade path detection).
- **Does not** create `appsettings.json` or Libation Files; same as zip install.
- **Includes** full publish output so `ZipExtractor.exe` remains for in-app updates.
- **Settings -> Apps:** shows `Libation (Chardonnay)` or `Libation (Classic)` with version in `DisplayVersion` (Inno at install; synced after in-app zip upgrades via `WindowsUninstallRegistrySync`); icon from `SetupIconFile` / `UninstallDisplayIcon` (`Scripts/Windows/libation.ico` and `{app}\Libation.exe`).
## Next plan step (8.2)
Install via `*-setup.exe`, run the app, then verify an older release can upgrade in-app using the existing zip-based upgrader.
thrownewInvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
keys[0]=keys[kidIndex];
varkeyId=keys[kidIndex].KeyPart1;
varkey=keys[kidIndex].KeyPart2??thrownewInvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2).");
variv=keys[0].KeyPart2??thrownewInvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2).");
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
If the chapter is shorter than 3 seconds long but still has some audio frames, those frames are combined with the following chapter and not split into a new file.
I also implemented file naming by chapter title. When 2 or more consecutive chapters are combined, the first of the combined chapter's title is used in the file name. For example, given an audiobook with the following chapters:
00:00:00 - 00:00:02 | Part 1
00:00:02 - 00:35:00 | Chapter 1
00:35:02 - 01:02:00 | Chapter 2
01:02:00 - 01:02:02 | Part 2
01:02:02 - 01:41:00 | Chapter 3
01:41:00 - 02:05:00 | Chapter 4
The book will be split into the following files:
00:00:00 - 00:35:00 | Book - 01 - Part 1.m4b
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
/// <param name="slowWindow">Total moving average time window</param>
/// <param name="slowSignificance">T-test significance level at which the newest speed will be considered different from the slow window's mean speed.</param>
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
/// The position in <see cref="SaveFilePath"/> that has been written and flushed to disk.
/// </summary>
[JsonProperty(Required = Required.Always)]
publiclongWritePosition{get;privateset;}
/// <summary>
/// The total length of the <see cref="Uri"/> file to download.
/// </summary>
[JsonProperty(Required = Required.Always)]
publiclongContentLength{get;privateset;}
#endregion
#regionPrivateProperties
privateHttpWebRequestHttpRequest{get;set;}
privateFileStream_writeFile{get;}
privateFileStream_readFile{get;}
privateStream_networkStream{get;set;}
privateboolhasBegunDownloading{get;set;}
publicboolIsCancelled{get;privateset;}
privateEventWaitHandledownloadEnded{get;set;}
privateEventWaitHandledownloadedPiece{get;set;}
#endregion
#regionConstants
//Download buffer size
privateconstintDOWNLOAD_BUFF_SZ=32*1024;
//NetworkFileStream will flush all data in _writeFile to disk after every
//DATA_FLUSH_SZ bytes are written to the file stream.
privateconstintDATA_FLUSH_SZ=1024*1024;
#endregion
#regionConstructor
/// <summary>
/// A resumable, simultaneous file downloader and reader.
/// </summary>
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
/// <param name="uri">Http(s) address of the file to download.</param>
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
/// <param name="cookies">A <see cref="SingleUriCookieContainer"/> with cookies to send with the <see cref="HttpWebRequest"/>. It will also be populated with any cookies set by the server. </param>
/// <summary>Result of checking for a new version. Use <see cref="Outcome"/> to show the right message; <see cref="UpgradeProperties"/> is set only when <see cref="Outcome"/> is <see cref="VersionCheckOutcome.UpdateAvailable"/>.</summary>
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: no candidates found for '{Title}' in library {LibraryId}",normalizedTitle,libraryId);
returnfalse;
}
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: found {Count} candidate(s) for '{Title}' in library {LibraryId}",candidates.Count,normalizedTitle,libraryId);
Serilog.Log.Logger.Error("Audiobookshelf upload failed for '{Title}' with status {(int)response.StatusCode} ({StatusCode}). Response body: {ResponseBody}",
Loaded 100 of 1205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.