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>
'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>
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.
- **[PayPal](https://paypal.me/mcrackan?locale.x=en_us)**: 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-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>
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}, ReleaseIdString: {ReleaseIdString}). Version check inconclusive.",ReleaseIdentifier,releaseIdString);
/// <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 1158 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.