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>
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>
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>
'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>
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>
Reported in issue #1973: a scheduled liberate run re-requested a content license
for the same 59 titles every 15 minutes, 1397 refused requests in six hours,
because nothing about a failed PDF was remembered and the PDF step asked Audible
afresh every time.
The two download paths have been converging for a while - a PDF is now named and
placed by the audiobook path's own logic, and verified like one - and every part
of this bug lives where that stopped short.
Both steps ask Audible for the same license. The request asks for pdf_url
alongside the content reference, and LicenseInfo dropped it, so DownloadPdf
turned round and requested an identical license to read the field the first
response had already returned. Carry PdfUrl on LicenseInfo and give both steps
one ILicensedDownload contract: a license may be supplied to a step, and the one
a step ended up using is published for the next step for the same title. The CLI
and the GUI queue hand it on, so a title costs one license request per run
however many steps want something from it. A carried license is retried once
with a fresh one if it does not work, since Audible's links are signed and a long
decrypt can run between the two steps.
Where the audiobook step obtained no license there is nothing to hand on and the
supplement step does not run, which deletes a bug rather than guarding it:
Completed fires from a finally, so a refused audio download was followed at once
by a PDF request that reproduced the refusal.
Error now means the same for a PDF as for a book. The audiobook step has always
skipped LiberatedStatus.Error through AudioExists, and NeedsPdfDownload agrees,
but DownloadPdf selected on PdfExists and so retried an errored PDF forever. A
license that is granted and carries no pdf_url - the 'No PDF URL available' in
the report - is Audible saying the title has no PDF, and is written off that same
way instead of failing identically on every run. It stays resettable by
everything that resets a book: --force, a named title, Set PDF Not Downloaded.
Refusals now reach ProcessSingleAsync, which has always recorded them for
whichever step throws one; DownloadPdf swallowed everything and recorded nothing.
It keeps swallowing what the classifier does not recognise, which is what stopped
a missing PDF from taking the app down with it.
A bulk CLI run leaves alone the titles the last scan did not find, by the same
Downloadable rule every multi-title path in the app already uses, and the PDF
back-fill pass waits on a refused title just as the first pass does. --force and
a named title still attempt everything.
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
Two 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>
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>
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>
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>
- 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
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)