Compare commits

...
192 Commits
Author SHA1 Message Date
Robert McRackan aa829df265 log accounts changes 2026-09-08 00:15:08 -04:00
Robert McRackan e84a0a121a "Import library" 2026-09-06 21:57:46 -04:00
rmcrackan 087c107685 Merge pull request #2038 from rmcrackan/rmcrackan/mbucari-fix
Rmcrackan/mbucari fix
2026-09-06 21:36:09 -04:00
Robert McRackan af7a1e8f69 Increment version to 14.2 2026-09-06 21:27:30 -04:00
Robert McRackan cbbd8b7955 Update docs per @MBurari 's fixes 2026-09-06 21:27:10 -04:00
rmcrackan edc737df4a Merge pull request #2032 from Mbucari/master
Update Deendencies and fix warnings
2026-09-06 09:07:28 -04:00
Mbucari 8050e11a41 Revert SkiaSharp to 3.119.4, matching Avalonia 2026-09-06 01:11:20 -06:00
Mbucari bdf55009b5 Fix tests and warnings from new deps 2026-09-06 00:27:17 -06:00
Mbucari a5a28c9d62 Replace ImageSharp dependency with SkiaSharp 2026-09-05 23:52:14 -06:00
Mbucari 70909a40dc Update Dependencies
Replace obsolete Avalonia.Diagnostics package with AvaloniaUI.DiagnosticsSupport.

Update Avalonia to 12.1, applying necessary fixes.
2026-09-05 23:23:00 -06:00
Mbucari a922cfdf40 Add option to overwrite existing files in DownloadPdf 2026-09-05 16:37:57 -06:00
rmcrackan bc806bada0 Merge pull request #2029 from rmcrackan/rmcrackan/2021-device-registration
Rmcrackan/2021 device registration
2026-09-04 21:05:32 -04:00
Robert McRackanandCursor 36207bbab3 Document experimental device registration and the License Denied workaround.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:51:28 -04:00
Robert McRackanandCursor f4dee94785 Add an experimental device-registration setting so License Denied users can re-register without changing the Android default.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:48:48 -04:00
rmcrackan 3d563a2dcc Merge pull request #2025 from rmcrackan/cursor/2024-podcast-series-number
Do not use Audible's sentinel episode numbers as podcast series order.
2026-09-03 16:26:26 -04:00
Robert McRackanandCursor 67b476eaa0 Do not use Audible's sentinel episode numbers as podcast series order.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 16:18:39 -04:00
rmcrackan a0adf75b53 Merge pull request #2023 from rmcrackan/cursor/throttling-license-ui
Cursor/throttling license UI
2026-09-03 08:18:36 -04:00
Robert McRackan 014beaf71f incr ver 2026-09-02 20:05:35 -04:00
Robert McRackanandCursor 74a028acaa Show a dialog when Audible throttles a download license.
CustomerThrottled was treated as a generic License Denied, so non-Plus titles got no explanation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 20:04:44 -04:00
rmcrackan 66bf8b9de9 Merge pull request #2022 from rmcrackan/rmcrackan/2020-download-store-locale
License extra-marketplace titles against the store they were scanned from
2026-09-02 15:18:56 -04:00
Robert McRackanandCursor 635780a4cf Bump version to 14.0.1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:10:59 -04:00
Robert McRackanandCursor 82fb235de9 Drop the ApiExtendedFunc test hook. Pass the book's marketplace straight to CreateAsync.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:06:43 -04:00
Robert McRackanandCursor 336a01db01 Request download licenses from the marketplace the book was scanned from, not only the account's home store.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 14:52:44 -04:00
rmcrackan a9df4add33 Merge pull request #2019 from rmcrackan/rmcrackan/dirty-accounts
Warn if accounts dialog is dirty when user attempts to close it
2026-08-31 17:26:38 -04:00
Robert McRackan 129486ce89 Warn if accounts dialog is dirty when user attempts to close it. This includes editing items from the Marketplaces child-dialog 2026-08-31 17:15:56 -04:00
rmcrackan 3e7191adc7 Merge pull request #2017 from rmcrackan/rmcrackan/2015-handle-bad-download
Rmcrackan/2015 handle bad download
2026-08-30 21:45:35 -04:00
Robert McRackan 4cd784787e avoid infinite retry loops 2026-08-30 21:40:38 -04:00
Robert McRackan 5503a9d768 Allow the download to resume from the last flushed position when TLS corruption occurs, just like it already does for premature connection closures. 2026-08-30 21:36:13 -04:00
rmcrackan 094e207c0b Merge pull request #2016 from rmcrackan/rmcrackan/cull-dead-code
Rmcrackan/cull dead code
2026-08-30 14:24:38 -04:00
Robert McRackan 09be09bd1b Cull dead code. Simplify 2026-08-30 14:16:27 -04:00
Robert McRackan 19a867488b Update dependencies 2026-08-30 14:15:35 -04:00
rmcrackan 9bfdaa9632 Merge pull request #2009 from rmcrackan/cursor/release-14-0-0-073d
Bump version to 14.0.0
2026-08-26 14:29:40 -04:00
Cursor Agentandrmcrackan eca013e780 Bump version to 14.0.0
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 18:14:08 +00:00
rmcrackan 5f814a55eb Update README.md 2026-08-26 14:04:31 -04:00
rmcrackan f980600a2c Merge pull request #2008 from rmcrackan/cursor/multi-marketplace-accounts-6d3e
Let one account scan more than one Audible marketplace
2026-08-26 13:45:05 -04:00
rmcrackan 75c25fefe4 Merge branch 'master' into cursor/multi-marketplace-accounts-6d3e 2026-08-26 13:32:12 -04:00
Robert McRackan 7538491f32 update dependencies 2026-08-26 13:31:18 -04:00
Cursor Agentandrmcrackan 5268e0c375 Drop the registered marketplace from the extras on the way out too
Newtonsoft applies values in the order the document lists them, so a settings
file naming AdditionalLocaleNames before its IdentityTokens has no registered
marketplace to compare against yet, and the duplicate check on the way in does
nothing. That account would then scan one marketplace twice.

Filtering on read makes the order irrelevant.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 16:49:13 +00:00
Cursor Agentandrmcrackan e1449c9b1e Document marketplaces in the docs and the tour
The tour mentions marketplaces at the end rather than at the accounts step: that
step runs before any login, so the button is necessarily disabled while the user
is looking at it.

The FAQ entry is filed under the symptom - books missing from the scan - because
that is what someone in this position knows. Nothing tells them a marketplace
they have never heard of is holding titles.

The accounts screenshots in getting-started now predate the Marketplaces column
and need retaking on Windows.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 16:44:46 +00:00
Cursor Agentandrmcrackan c7f6815014 Add marketplaces to Classic and to list-accounts
WinForms gets the same per-row button and dialog Chardonnay has, and its scan
picker lists an account's marketplaces for the same reason.

list-accounts grows an 'Also scans' column, because printing one locale for an
account that reads three misreports what a scan will do. It appears only when
some account has more than one marketplace, so the ordinary table is unchanged;
--bare always emits the field, last, so a script reading the first five keeps
working.

Compile-checked only for WinForms: it builds on Linux via EnableWindowsTargeting
but cannot run there, so the new dialog's layout still needs a look on Windows.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 16:23:52 +00:00
Cursor Agentandrmcrackan 5b2c620bf5 Add the marketplaces dialog to the accounts grid
The accounts grid gains a Marketplaces button per row, enabled once the account
has credentials to check with, and a dialog that asks each marketplace what it
holds and lets the user tick the ones to scan. The scan picker now lists every
marketplace an account reads, since one checkbox there can scan several.

A marketplace that could not be reached is reported as unchecked rather than
empty. Calling it empty would recreate the exact silence this feature exists to
break: titles present, and nothing anywhere in the app to suggest it.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 16:17:47 +00:00
Cursor Agentandrmcrackan 72927943c2 Let one account scan more than one marketplace
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>
2026-08-26 15:59:48 +00:00
rmcrackan b02aedb579 Merge pull request #2007 from rmcrackan/cursor/serilog-startup-resilience-3b83
Survive an install folder that cannot load Serilog (#2001)
2026-08-26 10:51:46 -04:00
Cursor Agentandrmcrackan 3ab22a9796 Report the crash log path without the Windows long-path prefix
PreLoggingCrashLog returned its LongPath straight out, so on Windows the crash
dialog named \\?\C:\Users\...\Log202608.log. The prefix is for the Win32 API,
not for a person being asked to find the file and attach it to a bug report.

Return PathWithoutPrefix instead, and use it for the LibationFiles line inside
the record for the same reason. A test now pins that the reported path carries
no prefix and is usable as-is; on Linux the two forms are identical, which is
why local runs could not see this and the Windows CI job could.

While in there: ReplaceInstallFile moved the old file aside and then wrote the
new one, so a copy that failed after the move left nothing at all where a file
used to be. Put the old one back before reporting the failure.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 14:42:27 +00:00
Cursor Agentandrmcrackan 47980e1f86 Check the rollback's own work, and offer a restart when it went well
The rollback restored files, announced 'Libation restored your previous install
files', and deleted the pending marker without ever reading back what it had
written. It also could not tell the difference between an overlay that had not
started, where putting the backup back returns the install to one version, and
an overlay that got partway, where the backup covers a dozen names out of the
few hundred in the folder and cannot.

Grade the outcome instead. Verify each restored file against its backup, and
use the manifest files that did match the upgrade package as the signal that
the overlay had begun. Report which files could not be restored, and keep
restoring the rest rather than abandoning them at the first failure.

Libation has to close either way, since the files underneath it just changed,
so the wording and the offer now follow the grade: back on the previous version
asks to restart, a mixed install asks but says a fresh install would be better,
and a restore that could not finish only offers OK. A relaunched process never
offers again, so a pending marker we failed to delete cannot become a loop.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 14:23:48 +00:00
rmcrackan 673f8f3b06 Merge pull request #2005 from rmcrackan/cursor/adopt-dinah-core-10-2-6-1-b4c7
Adopt Dinah.* 10.2.6.1 and AudibleApi 11.0.5.1
2026-08-26 09:58:17 -04:00
rmcrackan 52325e7ae8 Merge pull request #2003 from rmcrackan/cursor/fix-widevine-metadata-tags-2f5b
Fix metadata tags on the Widevine download path (#2002)
2026-08-26 09:52:25 -04:00
Cursor Agentandrmcrackan ebb1d9ff4c Retrigger CI: transient corrupted ref assembly on the macOS x64 runner
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 13:49:17 +00:00
rmcrackan 3fbd0e1286 Merge pull request #2004 from rmcrackan/cursor/replace-csvhelper-internal-writer-b4c7
Replace CsvHelper with a minimal internal CSV writer
2026-08-26 09:45:31 -04:00
Cursor Agentandrmcrackan 0c0b72cd87 Update AudibleApi to 11.0.5.1 and Dinah.Core.WindowsDesktop to 10.2.6.1
Completes the adoption of this Dinah.Core release across the
dependency tree: AudibleApi 11.0.5.1 carries the Dinah.Core 10.2.6.1
floor, and Dinah.Core.WindowsDesktop 10.2.6.1 is versioned in step.
No code change in any of them.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 13:42:43 +00:00
Cursor Agentandrmcrackan c22efa2c0f Retrigger CI: Dinah.Core and Dinah.EntityFrameworkCore 10.2.6.1 are now on nuget.org
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 13:19:47 +00:00
Cursor Agentandrmcrackan bfaab98eff Fit a longer startup message in the crash dialog
The dialog was pinned to 450px tall with the description in an Auto row, which
was fine for the two-line generic text but not for a message that names the
file, the version it has, the version it needs and the recovery steps: the
description pushed the exception box, the report instructions and the OK button
out of the window.

Cap and scroll the description, give the window room to grow, and top-align the
icon so it stops overlapping the text.

The Classic equivalent, MessageBoxLib.ShowAdminAlert, may need the same and
cannot be checked here.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 13:15:53 +00:00
Cursor Agentandrmcrackan bc72aad30c refactor(metadata): flatten Audible's HTML at the API boundary, not on read
Parsing HTML belongs before the data reaches the data layer, not in it. Move the
flattening to AudibleUtilities, where the rest of the Item translation lives, so
Book.Description holds plain text and DataLayer no longer references
HtmlAgilityPack.

Import now updates a description as well as creating one, which it never did
before: books imported while descriptions were stored as raw HTML are still
holding markup, and a re-scan is what clears it. A scan that reports no summary
leaves the stored one alone.

This also cleans up the CSV/Excel export, which was emitting raw HTML too.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 13:15:02 +00:00
Cursor Agentandrmcrackan 31ad21f69d Update Dinah.EntityFrameworkCore to 10.2.6.1
Versioned in step with Dinah.Core for this release; no code change in
the package.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 13:12:31 +00:00
Cursor Agentandrmcrackan 1ab18f5f60 Cover the startup paths that issue #2001 showed were untested
The rollback test holds the file it restores open with the sharing mode .NET
uses for a loaded assembly. Against the old File.Copy(overwrite: true) it fails
with 'the process cannot access the file because it is being used by another
process', which is the same refusal Windows gives for a loaded assembly and the
reason the rollback could never finish.

The rest pin the behaviour that was missing: recovery survives a log sink that
throws on every call, a displaced file is swept up later, a stale or absent
install assembly is recognised and both versions are read, a plain file path is
not mistaken for an assembly reference so the EF Core message keeps its own
wording, and the crash record appends to the newest Log*.log and reports the
path it used.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 13:01:23 +00:00
Cursor Agentandrmcrackan 77cb6bf78e chore: keep the two Dinah package references adjacent
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:59:18 +00:00
Cursor Agentandrmcrackan 1c32149c2b Update Dinah.Core to 10.2.6.1
No code change needed. 10.2.6.1 replaces the Pluralize.NET dependency
with an internal pluralizer inside Dinah.Core; the Pluralize and
PluralizeWithCount extensions this repo uses behave the same, which the
existing tests that assert on their output confirm.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:58:28 +00:00
Cursor Agentandrmcrackan 06356f249a Share one crash log between both UIs, and stop after a rollback
Chardonnay's Serilog-free crash record was private to its entry point, so
Classic wrote nothing at all when startup failed before logging. Move it to
LibationFileManager as PreLoggingCrashLog, where both UIs can call it and where
the existing test project can cover it. It needs nothing from AppScaffolding:
Configuration.LibationVersion already holds the version, and ReleaseIdentifier
is not populated this early, so callers pass what they know.

Guard every field, since an unguarded InteropFactory.InteropFunctionsType,
whose static constructor logged through Serilog, was enough for the bare catch
to swallow the whole record and leave no trace of the crash. Return the file
written so the dialog names it: both UIs pointed at LibationCrash.log, which is
not where the record goes when a Log*.log already exists.

RecoverFromIncompleteUpgradeIfNeeded now reports a completed rollback, and both
entry points show it and quit. Continuing meant running the assemblies already
loaded against the older files just restored underneath them. Consuming the
alert at startup also retires the two late call sites that could never fire.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:56:44 +00:00
Cursor Agentandrmcrackan c4b4fa6971 Recognise any install-folder assembly failure, and let the rollback finish
Three separate reasons the recovery added for #1878 could not help the install
in issue #2001:

Classification was by assembly name, covering only EntityFrameworkCore,
Microsoft.Data.Sqlite and LibationUiBase, so a Serilog bind failure reached
neither the emergency rollback nor an actionable message. Match on the assembly
reference instead and compare the requested version against the file on disk,
so the dialog can say which file is stale and what version it should be. A
stale file and an absent one report identically through the loader, which is
why the version has to be read to tell them apart.

RestoreFromBackup wrote over assemblies this process had already loaded and
mapped. That segfaulted the process on Linux and Windows denies the write, so
the rollback never completed either way. Move the loaded file aside first and
sweep the leavings on a later startup.

Serilog.dll and three more assemblies Libation cannot start without were not in
the upgrade manifest, so an overlay could lose one and still verify clean.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:49:32 +00:00
Cursor Agentandrmcrackan 5c5d1bd946 Replace CsvHelper with a minimal internal CSV writer
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>
2026-08-26 12:48:13 +00:00
Cursor Agentandrmcrackan 23ecdf56ee test(metadata): cover both shapes of file Audible delivers
FillMissingTags is driven against a real MetadataItems built from a hand-rolled
ilst box: empty, as a Widevine download arrives, and pre-populated, as an .aaxc
download arrives. Reverting any of the three fixes fails a test with the exact
value from the issue report.

MockLibraryBook.CreateBook gains a copyright knob to match UpdateBookDetails.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:46:56 +00:00
Cursor Agentandrmcrackan 1729f84dbb Log startup recovery without Serilog so a broken install cannot silence it
Libation's releases are ReadyToRun, so a Serilog reference resolves lazily
when the line holding it runs. An install folder whose Serilog.dll is missing
or older than the build therefore threw inside the very catch blocks that were
meant to report the problem, which destroyed the original exception and aborted
the in-app upgrade recovery before it could roll anything back.

Add StartupLog: a Serilog-free, never-throwing buffer for the window before
ConfigureLogging runs, replayed into Serilog once logging exists. Move the
pre-logging call sites onto it, including the crash-message and crash-dialog
paths that run precisely when the install is broken.

Those messages were silent before this change: Serilog.Log.Logger is still
Serilog's silent logger until App.RunMigrations. They now reach the log file.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:44:36 +00:00
Cursor Agentandrmcrackan 178b7ee1d8 feat(metadata): carry the publisher's copyright line from the API to the tag
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>
2026-08-26 12:40:04 +00:00
Cursor Agentandrmcrackan 5ff1a4f813 fix(metadata): replace Audible's placeholder 01-Jan-2000 release date
Audible's .aaxc files often ship 01-Jan-2000 in rldt and 2000 in the year atom
instead of the real release date, and the file's tag beat the catalog's date.
Override that one value when the catalog knows better, leaving every other
embedded date alone; the comment records the alternatives considered.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:39:33 +00:00
Cursor Agentandrmcrackan 272241bed6 fix(metadata): flatten Audible's HTML description before writing it to a tag
Book.Description is publisher_summary verbatim, which is HTML. Audible's .aaxc
files embed an already-flattened copy, so the fallback only fired on Widevine
downloads - whose DASH files carry no description tags at all - and wrote raw
<p> markup into the file's comment and long-description atoms.

Move the grid's existing HtmlAgilityPack flattening into a shared helper that
also de-entitizes, and use it for the tag fallback. Extract the tag-filling body
of the RetrievedMetadata handler so it can be exercised directly by a test.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:38:56 +00:00
rmcrackan f39751da17 Merge pull request #2000 from rmcrackan/cursor/opt-out-update-checker-efc8
Let users turn off the startup update check
2026-08-25 21:13:22 -04:00
Cursor Agentandrmcrackan 612ec05b19 Stop a download-only upgrade notice from starting an install
When Libation cannot install an upgrade itself - a portable or AppImage
install, a Linux build with no package-manager symlink, macOS outside
/Applications - the prompt is a notice with a download link, and Chardonnay
relabels its button from "Yes" to "OK" to say so. The button still closed with
DialogResult.OK, which MainWindow and the About window both read as "yes,
install it", so acknowledging the notice downloaded the release and ran the
auto-upgrader anyway. That could only fail, and it failed loudly: an "Upgrade
Failed" error box in answer to dismissing a notice.

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

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

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

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

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

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

Closes #1999

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

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

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

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

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

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

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

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

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

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

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 22:37:43 +00:00
rmcrackan c01e83f844 Merge pull request #1996 from rmcrackan/cursor/release-13-7-11-c334
Bump version to 13.7.11
2026-08-24 18:13:37 -04:00
Cursor Agentandrmcrackan 04a7103ae8 Bump version to 13.7.11
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 22:07:52 +00:00
rmcrackan df04be9dec Merge pull request #1995 from rmcrackan/cursor/fail-the-windows-build-on-a-failed-publish-011e
ci: fail the Windows build when a publish fails
2026-08-24 17:36:26 -04:00
Cursor Agentandrmcrackan 1520647659 Merge remote-tracking branch 'origin/master' into cursor/fail-the-windows-build-on-a-failed-publish-011e
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 21:30:23 +00:00
rmcrackan 5956c83041 Merge pull request #1994 from rmcrackan/cursor/fix-winforms-visible-books-build-011e
fix: restore the LibationWinForms build
2026-08-24 17:23:00 -04:00
Cursor Agentandrmcrackan b1452dabc3 ci: fail the Windows build when a publish fails
The four dotnet publish calls share one run: block, and on windows-latest that
block is pwsh. PowerShell does not stop at a native command that fails, and the
step is judged by $LASTEXITCODE after the whole block, so only the last publish
was ever able to fail the build.

That is not hypothetical. LibationWinForms stopped compiling and every run since
stayed green: the WinForms publish errored, the three after it succeeded, the
step exited 0, and the job uploaded a Libation-Classic zip containing Hangover
and the CLI but no Libation.exe at all. upload-artifact's if-no-files-found did
not help, since the zip itself was there.

Check the exit code after each publish, and refuse to package output with no
Libation.exe in it - both UIs build under that name. Linux and macOS are not
affected; their steps are bash, which Actions runs with -e.

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

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

Thread the parameter through. The whole solution builds again.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 20:51:09 +00:00
rmcrackan 798602c56f Merge pull request #1993 from rmcrackan/cursor/drop-neil-gaiman-from-examples-011e
docs: drop Neil Gaiman from the examples
2026-08-24 16:37:24 -04:00
Cursor Agentandrmcrackan a0045156c1 docs: drop Neil Gaiman from the examples
Replaced with Stephen Fry, who the search and naming-template docs already use
as a narrator and who writes and narrates his own books, so the author-narrator
examples still say what they meant to.

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

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

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

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

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

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 20:14:44 +00:00
Cursor Agentandrmcrackan 5c61ebd741 docs: how to tell a search keyword from the word, and a number from a title
Both are newly worth explaining. Searching a field for a word that names another
field used to raise "Bad filter string", and a numeric title could not be found
at all, so there was nothing to document until now.

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

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

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

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

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

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 18:41:40 +00:00
rmcrackan bf78563746 Merge pull request #1990 from rmcrackan/cursor/queue-notification-cleanups-8e7b
Queue notification cleanups left over from #1958
2026-08-24 14:39:39 -04:00
Cursor Agentandrmcrackan 7aecda4259 revert: drop the searching-and-filtering note added with the index repair
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 18:38:18 +00:00
Cursor Agentandrmcrackan a0ef5c3d95 fix: repair a search index that is missing books
A book the index does not hold cannot be found by any positive filter, and
every negated filter - which resolves to every document in the index - drops
it from the grid instead. That is why issue #1989 reads as half a working
filter: Absent found nothing while -Absent removed exactly the absent books.

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

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

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

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

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

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

No behaviour change.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 17:52:32 +00:00
Cursor Agentandrmcrackan bb593fe8d1 docs(queue): say what auto-scroll actually waits for
The page said auto-scroll only moves "when you were already looking at the bottom
of the queue". The condition in both UIs is that the item above the one starting is
still on screen, which is not the same claim - you can be well short of the bottom
and still be scrolled along with the queue.

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

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

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

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

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

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

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

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

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

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 14:56:50 +00:00
Cursor Agentandrmcrackan edf0d32508 docs: troubleshooting entry for a Books folder that returns I/O errors
See issue #1984.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-24 14:47:31 +00:00
rmcrackan 5f7df75c85 Merge pull request #1987 from rmcrackan/cursor/fix-flaky-anchored-backtracking-row-362f
Fix flaky CatastrophicBacktracking_AnchoredRepeated (#1982)
2026-08-24 10:28:32 -04:00
Cursor Agentandrmcrackan 1839eebb20 feat: say so when the Books folder is there but cannot be read
A pulled or failing drive still answers that it is a directory, and a listing of
it comes back empty rather than refusing, so Libation would start a download
against an unreadable folder and report a full library as having nothing
downloaded. Check before queueing and name the drive as the likely cause.

See issue #1984.

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

See issue #1984.

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

See issue #1984.

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

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

See issue #1984.

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

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

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

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

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

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

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

Comments still named TrackedQueue.Current and .Active after both were deleted, the Configuration doc comment still called itself the bound on a spinner when nothing binds it any more, and the ClaimAbort comment said the winner was the book that answered the dialog when it is really whichever book gets there first.
2026-08-21 06:33:31 -07:00
Allamagoosa 1f38ba8abf Document parallel downloads
docs/features/ has a page per user-facing feature, including the daily
download limit this interacts with. Records the two things that are not
discoverable from the UI: that above 3 concurrent downloads Audible starts
denying content licenses, which arrives as ordinary download failures with
nothing identifying the cause, and that books already downloading run to
completion when the daily limit pauses the queue - the limit stops new
books starting, it does not interrupt work in progress.
2026-08-20 17:13:46 -07:00
Allamagoosa 8b166312c6 Pin down the dispatch loop with tests
All the risk parallel downloads added lives in the dispatch loop, and none
of it was reachable while the only way to run a book was to download one.
Driven through ProcessBookHandler, every book here finishes when the test
says so and never touches the network, the database or the disk. The loop
is the real one.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 20:12:44 +00:00
Cursor Agentandrmcrackan 49e8164b6f Use System.Threading.Lock for the QuickFilters locker
Matches the existing usage in PersistentDictionary and
LibationAvalonia.Program; the lock statement now emits
Lock.EnterScope() instead of Monitor.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 20:12:44 +00:00
rmcrackan a030853d6b Merge pull request #1976 from rmcrackan/cursor/fix-pdf-download-retry-storm-a274
fix(pdf): stop re-requesting a license for a PDF Audible will not give
2026-08-19 15:59:08 -04:00
Cursor Agentandrmcrackan 1d9b41c2a4 Fix QuickFilters not loading from disk after restart (#1979)
Commit 065118cf deleted Migrations.migrate_to_v11_5_0, which despite its
name ran on every startup and was the only code that read
QuickFilters.json into QuickFilters.InMemoryState. Since v13.7.6, saved
quick filters were never loaded after a restart, and adding a new filter
overwrote the file.

Make QuickFilters load its state lazily from disk on first access so it
no longer depends on a startup hook. Restore the pre-v11.5.0 format
fallback (plain string filters without names) that the deleted migration
provided, and add regression tests covering load formats and
restart persistence.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 19:46:21 +00:00
Cursor Agentandrmcrackan 1e81c86f65 Merge remote-tracking branch 'origin/master' into cursor/fix-pdf-download-retry-storm-a274
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 19:31:14 +00:00
Cursor Agentandrmcrackan 5ce147ef83 test: keep the supplement sync tests out of a project of their own
A new DtoImporterService.Tests pinned MSTest 4.2.2, copied from a project file
master has since moved to 4.3.3, so the merge held nine test projects at 4.3.3
and one at 4.2.2. NuGet lifted the shared MSTest.TestFramework to 4.3.3 while the
4.2.2 metapackage kept supplying the adapter, and the adapter refuses to run
against a framework of a different version: 'Zero tests ran' on every platform.

ApplicationServices references DtoImporterService, so its test project already
sees the importers and needs no package reference of its own. One fewer project
file to keep in step is the point.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 19:31:08 +00:00
Cursor Agentandrmcrackan 7e89a96ffc fix(cli): count only the absent titles a run would have attempted
Counting every title the last scan did not find reports a number no run was going
to act on: most of a large library's absent titles need nothing at all. The
reporter's library would have been told thousands of titles were skipped where 54
would have been attempted.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 19:20:24 +00:00
rmcrackan 8c1b754180 Merge pull request #1978 from rmcrackan/cursor/sourcelink-github-provider-accb
Generate real source links for the built assemblies
2026-08-19 15:12:19 -04:00
Cursor Agentandrmcrackan 0cecaad686 Generate real source links for the built assemblies
The SDK supplies Microsoft.SourceLink.Common and the git tasks, so the build
resolved the repository URL and commit but had no host provider to turn them
into fetchable URLs. Every project warned that the generated source link was
empty - 477 times in the last validate run - and shipped PDBs that could not
locate their sources.

Referencing the GitHub provider, as AudibleApi already does, fills the map in:
  {"documents":{"/repo/*":"https://raw.githubusercontent.com/rmcrackan/libation/<sha>/*"}}

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 19:07:22 +00:00
rmcrackan 63dc8fc988 Merge pull request #1977 from rmcrackan/cursor/adopt-dinah-10-2-5-1-audibleapi-11-0-4-1-accb
Adopt Dinah.Core 10.2.5.1 and AudibleApi 11.0.4.1
2026-08-19 14:56:22 -04:00
Cursor Agentandrmcrackan 6a166b23fb Merge master
LibationFileManager.csproj conflicted because both sides edited adjacent
PackageReference lines: this branch bumped AudibleApi, master's #1974 bumped
Microsoft.Extensions.Configuration.Json. Kept both.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 18:55:42 +00:00
rmcrackan ed20356af9 Merge pull request #1975 from rmcrackan/cursor/align-mstest-testing-platform-accb
Update MSTest to 4.3.3
2026-08-19 14:53:19 -04:00
rmcrackan 316099b42f Merge pull request #1974 from rmcrackan/cursor/sync-net10-servicing-10-0-11-accb
Move the .NET 10 package references to 10.0.11
2026-08-19 14:53:07 -04:00
Cursor Agentandrmcrackan 71e8d8eea1 Adopt Dinah.Core 10.2.5.1 and AudibleApi 11.0.4.1
Picks up the .NET 10.0.11 dependency floors both now declare.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 18:51:50 +00:00
Cursor Agentandrmcrackan cb8c1b3a3e docs: a PDF is the same license request as the audiobook
The refused-downloads page said the opposite - that a --pdf run is never held
back because a PDF is a different request - and neither page mentioned that a
bulk run now leaves alone the titles the last scan did not find, or what happens
when Audible lists a PDF it will not deliver.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 18:49:22 +00:00
Cursor Agentandrmcrackan d4b5a48b79 test: cover the supplement download's license, status and selection rules
DownloadPdfLicenseTests drives the step with and without a license in hand. No
Audible account exists in those tests, so a test that completes at all is the
proof that the step did not go asking for a license of its own.

BulkRunSelectionTests drives the real run loop against a real library database:
which titles each pass reaches, that an absent title is left alone by both, that
--force still attempts it, and that a refused title is waited on by the follow-up
pass and reported once rather than twice.

SupplementSyncTests covers the scan rules, in a new DtoImporterService.Tests
project. The two replaced assertions - that a PDF-only title is never waited on,
and that a --pdf run is never held back - encoded the belief that a PDF is a
different request from the audiobook, which is what this branch disproves.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 18:48:07 +00:00
Cursor Agentandrmcrackan f0d344099a fix(scan): keep a book's supplement in step with what the last scan says
Only a newly imported book ever recorded a supplement, so a title that gained a
PDF after its first import never got one, and a title that lost its PDF went on
claiming one - which in issue #1973 is why three titles Audible has no PDF for
were still being asked for.

Sync from updateBook as well, and give Book set-semantics for the one supplement
Audible reports per title. The duplicate guard compared the incoming url to
itself, so it happened to mean 'this book already has a supplement' and a url
that had changed was silently ignored.

A supplement is dropped only when Audible says outright that no supplement url is
available. A missing url says nothing by itself: episodes come from the catalog,
which is never asked for pdf_url, so there it means 'not asked'. A PDF already
downloaded is left alone either way, since the file is on disk and the library
should go on saying so.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 18:36:02 +00:00
Cursor Agentandrmcrackan a60299efe6 fix(queue): wait on a title that needs only its PDF, like any other
A multi-title download left a waited-on title out only when it needed its
audiobook, on the grounds that the audiobook download was what Audible had
refused. A PDF is fetched through the same license request, so a title needing
nothing but its PDF was requested again on every run of the very thing the wait
exists to stop.

The skip reason's wording now comes from the shared message, so the app and the
CLI say the same thing about the same skip.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 18:30:00 +00:00
Cursor Agentandrmcrackan e97c68257d fix(pdf): fetch a supplement from the license the audiobook download already has
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>
2026-08-19 18:29:52 +00:00
Cursor Agentandrmcrackan afd970c672 Update MSTest to 4.3.3
Libation already runs its tests on Microsoft.Testing.Platform via the MSTest
metapackage; this only moves the version off 4.2.2 so all three repos name the
same MSTest release.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 18:17:57 +00:00
Cursor Agentandrmcrackan 4a138bb42f Move the .NET 10 package references to 10.0.11
Microsoft.EntityFrameworkCore.Sqlite 10.0.11 is the first 10.0.x servicing
release to depend on SQLitePCLRaw 2.1.12, which drops the pre-3.50.2 SQLite
build flagged by GHSA-2m69-gcr7-jv3q (CVE-2025-6965). DataLayer's Sqlite
reference is where that chain enters the solution, so bumping it clears
NU1903 from all 23 projects that transitively saw it.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 17:55:55 +00:00
rmcrackan 3b33ee2c43 Merge pull request #1972 from rmcrackan/cursor/1925-winforms-trash-line-visible-readback-7ce7
bugfix: WinForms defects found while testing #1970
2026-08-19 13:28:11 -04:00
Cursor Agentandrmcrackan 88243825eb seed script: drop the search index so filters find the seeded books
The script writes rows straight to SQLite, which leaves Libation's Lucene
index untouched, so every filter on a seeded book came back empty and the
empty-grid states could not be tested: nothing was findable, so every filter
produced 'No books match' whether or not that was the right answer.

Deleting the SearchEngine folder is enough. A missing index is a state
Libation already handles - performSafeQuery catches the FileNotFoundException,
re-indexes from the database and retries - and that rebuild filters IsDeleted,
so trashed books stay out of the index exactly as in a normal session.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 17:19:36 +00:00
Cursor Agentandrmcrackan 0ca01c338d theme the new empty-state links and the status bar trash count
The four LinkLabels in the empty-grid block and the clickable trash count
never applied Libation's link colors, so in dark mode they kept the default
WinForms blue that every other link in the app overrides.

trashBinLbl is a ToolStripStatusLabel, which ThemeExtensions did not cover,
so SetLinkLabelColors now has a ToolStripLabel receiver alongside LinkLabel.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 15:35:15 +00:00
Cursor Agentandrmcrackan d23b87d2ad docs: AGENTS.md was wrong that WinForms cannot build on Linux
dotnet build Source/Libation.slnx succeeds here with 0 errors, as do
LibationWinForms, HangoverWinForms and both LoadByOS ConfigApp helpers
individually. They target net10.0-windows7.0 but set EnableWindowsTargeting,
which is what lets a non-Windows SDK build them.

I took the old claim at face value and shipped a PR's worth of WinForms edits
without ever compiling them. The bug fixed in the previous commit would have been
caught by review either way, but several of the designer edits could just as
easily have failed to compile.

Record the distinction the old text blurred: everything compiles here, WinForms
still cannot run here, and those are different claims.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 14:45:40 +00:00
Cursor Agentandrmcrackan 955c5ca9b2 bugfix: show the trash line the first time the library goes empty
Control.Visible returns effective visibility, walking up the parent chain. The
trash line's text was set behind 'if (emptyLibraryTrashLink.Visible)' immediately
after assigning that same property, while noMatchesPanel - its parent - was still
hidden and only shown eight lines further down. So the read-back was false, the
text was never assigned, and the LinkLabel rendered the empty string it carries
from the designer.

That matches the report exactly. The headline and the two action links set their
Text unconditionally, so they appeared; only the trash line was blank. Opening
and closing the trash bin fixed it because by then the panel was already on
screen, so the read-back was true. A restart put it back.

Drive the text off the count instead, and use a local for the panel's own
BringToFront guard rather than reading Visible back there too.

Avalonia was never affected: its XAML binds Text and IsVisible independently.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-19 14:45:40 +00:00
rmcrackan 0934701d75 Merge pull request #1970 from rmcrackan/cursor/1925-guard-inactive-book-scan-7ce7
Guard removal against a failed scan; make the trash bin visible; explain the empty grid
2026-08-18 16:52:59 -04:00
Cursor Agentandrmcrackan cb01b9b3b1 Keep pointing at the trash when the library itself is empty
Two states were wrong, both when the live library is empty but the trash is not.
GetLibrary() filters IsDeleted, so an all-trashed library reads as empty and the
getting-started panel took over.

Without a filter it said 'Libation is empty' while 29 books sat in the trash -
the books are not gone, they are one click away. The panel now carries a trash
line when there is anything in there.

With a filter it was worse: searching for a book that IS in the trash showed
'Libation is empty. Add your Audible account' and suppressed the trash hint
entirely, which is the exact failure #1925 was about, reintroduced in the one
case where the library is empty.

The rule is now that someone searching is not someone getting started, so a
filter always answers for itself. That leaves one merely-suboptimal case, an
empty library filtered to something in neither place, which says 'no books
match' - true, and better than claiming the library is empty when it is being
filtered.

Also fixes what made this invisible while testing: GettingStartedVisible depends
on the filter but nothing raised it when the filter changed, so the panel never
re-evaluated.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 20:17:32 +00:00
Cursor Agentandrmcrackan fc973d4f5f Keep MaskedLogEntry out of AccountsSettings.json
It is derived from AccountId, AccountName and Locale, so Newtonsoft was writing
a stale copy of three fields that are already in the file. Locale next to it was
already ignored for the same reason; this one was missed.

Harmless, but it is the sort of thing that gets read back one day as if it meant
something. Four tests pin the serialized shape: the two computed properties stay
out, the set of persisted members is asserted whole so a new property has to be
a decision rather than an accident, and a file already carrying the stray key
still loads and is rewritten without it.

Verified against the app rather than only the tests - renaming an account and
saving no longer adds the key.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 19:42:28 +00:00
Cursor Agentandrmcrackan 2aa6d8c078 Record why demo accounts are identified by id, not a flag
A marker property would be tidier, but AudibleUtilities.Account serializes a
fixed set of members and has no [JsonExtensionData], so Newtonsoft ignores an
extra property on the way in and never writes it back. Confirmed by adding
IsDemo, renaming the account in the app and letting it save: the property was
gone, and anything that dirties an account does the same, a token refresh during
a scan included. --clean would then strand demo accounts with no way to find
them. Adding a demo flag to the shipping Account class to serve a dev script is
not a trade worth making.

The id is the better anchor anyway: immutable, always persisted, and example.com
is reserved by RFC 2606 so no real Audible login can collide with it. Verified
that --clean still matches an account after the app renamed it.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 19:32:24 +00:00
Cursor Agentandrmcrackan bc297330ad Add a script that seeds fake Audible accounts
Quite a lot of Libation only appears once an account exists, and it is not one
switch but two: nothing, one account, or several. The one/many split changes the
Import and Remove menus, and the new empty-library panel offers 'Add Account' or
'Scan Library' depending on it. None of that was reachable in a test environment
without signing in to Audible, which is how I ended up writing this by hand to
check the empty-library states.

seed-demo-accounts.cs writes structurally valid but meaningless identities, so
Libation counts and lists the accounts and enables everything gated on having
one. Any actual scan is refused by Audible, which the script and the docs both
say plainly.

It appends rather than overwrites, and --clean removes only what it added -
the accounts matching demo*@example.com - because this is the file holding real
Audible tokens. The first account is demo@example.com, the same one
seed-demo-library.cs assigns its books to, so seeding both describes one library
instead of two unrelated ones.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 19:23:18 +00:00
Cursor Agentandrmcrackan b959d878aa Offer a way forward when the library is empty
The blank grid said nothing, and the only pointer anywhere was the status bar's
'No books. Begin by importing your library' - which is the wrong advice for
someone who has not added an account yet, since Import has nothing to scan.

An empty library has two causes with different next steps, and AccountsCount
already knows which one applies, so say the one that fits and offer the action
that goes with it:

  no accounts   Libation is empty.
                Add your Audible account, then scan your library ...
                [Add Account] [Take a Guided Tour]

  no books      No books in your library yet.
                Scan your Audible account to bring your books in.
                [Scan Library] [Take a Guided Tour]

The buttons run the same commands the menu items do, so the two cannot drift.
The tour is there because it is offered once on first launch and never again
once declined; Settings > Take a Guided Tour is not somewhere a new user looks.

Held back until LibraryStats has been counted at least once, which is what keeps
'Add your Audible account' off the screen in the moment between the window
appearing and a full library loading, and held back mid-scan. An empty library
takes precedence over an empty filter result, since 'no books match' is true but
useless when there are no books at all.

Both grid empty states now share one set of strings in GridEmptyStateUi rather
than the no-matches pair living in TrashBinUi, which was already the wrong home
for them and would only get worse with a third.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 19:17:10 +00:00
Cursor Agentandrmcrackan cc50f183c0 docs: document the trash bin
Removing a book was undocumented, so nothing told a user that a missing book
might simply be in the trash - which is what #1925 turned out to be.

Add docs/features/trash-bin.md covering how books get removed, how to find and
restore them, and what the log records. It leads with the two things people
actually get wrong: removing never touches your audio files, and 'Permanently
Delete from Libation' keeps a book away less effectively than the trash does,
because deleting Libation's record of a book means the next scan imports it
again as something it has never seen.

Also add a troubleshooting entry for a book that is in your Audible library but
not in Libation. It starts with the trash bin and an active filter, since those
account for most reports and neither leaves anything in the log, then moves on
to the import settings and the scan tally that would explain the rest.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 19:00:34 +00:00
Cursor Agentandrmcrackan bf9b85331b Seed books in the trash for manual testing
seed-demo-library.cs covered every grid state except the one that made #1925
hard to answer: a book that is in the database but in the trash, and therefore
absent from the grid, the search index and every status count at once.

Seed three - a purchased title, a Plus title, and an episode whose series is
still in the library so the trash bin's nesting is exercised too - and report
them under their own heading, since unlike every other seeded row they are
supposed to be missing from the grid.

Document what they are for in docs/development/testing.md: the status bar count,
the menu count, the empty-search hint, and the nested episode.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 18:51:58 +00:00
Cursor Agentandrmcrackan 6288bc543e Log trash changes, and reveal a searched-for book that is in the trash
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>
2026-08-18 18:46:30 +00:00
rmcrackan d000a8ee19 Merge pull request #1971 from rmcrackan/cursor/tag-book-count-log-with-caller-3e8d
Name the caller in the book and PDF count logs
2026-08-18 14:36:55 -04:00
Cursor Agentandrmcrackan 12cbc7e07d Name the caller in the book and PDF count logs
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>
2026-08-18 18:26:31 +00:00
Cursor Agentandrmcrackan 4df08d383a Show when the trash is not empty
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>
2026-08-18 18:19:37 +00:00
rmcrackan a42c332cf4 Merge pull request #1969 from rmcrackan/cursor/gate-in-app-upgrade-under-app-control-3e8d
Detect Application Control enforcement and stop upgrading into a broken install
2026-08-18 14:08:14 -04:00
Cursor Agentandrmcrackan 240454efa8 bugfix: don't offer to remove books based on a scan that failed
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>
2026-08-18 17:58:51 +00:00
Cursor Agentandrmcrackan 099b01af14 Ask Windows which folders are synced instead of guessing from OneDrive variables
The previous check read the OneDrive environment variables and matched path
prefixes, so it missed Dropbox, Google Drive and iCloud, and had its own
guesswork to get wrong.

Every sync engine that serves files on demand registers a sync root with the
Cloud Files API, and CfGetSyncRootInfoByPath answers directly whether a path
sits under one. That covers every provider without naming any of them, and it
replaces the environment lookups, path canonicalisation and prefix matching
rather than adding to them. Asking for the provider info class also gets the
provider's own name, so the message can say Dropbox rather than a generic phrase.

The buffers are plain byte arrays read by hand: nothing here can be verified
away from Windows, so there is no marshalling to get subtly wrong.

Detection cannot produce a false positive now, since the answer is Windows'
rather than an inference from a folder name. The warning still appears only in
the incomplete-upgrade message, where something has already failed, so a folder
someone deliberately keeps fully downloaded never prompts anything.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 17:55:06 +00:00
rmcrackan af163df6ca Merge pull request #1968 from rmcrackan/cursor/fix-smart-app-control-guidance-3e8d
Correct the Smart App Control guidance in the docs and the in-app dialog
2026-08-18 13:29:52 -04:00
Cursor Agentandrmcrackan 0efe50b57a Give Classic a one-line reason that fits its dialog
Classic's upgrade dialog has a single line above the release notes box, so the
full explanation would have rendered on top of them. Carry both a one-line
summary and the full text, and let each UI take the one it has room for.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 16:34:38 +00:00
Cursor Agentandrmcrackan a0ca574974 Detect Application Control enforcement and stop upgrading into a broken install
An in-app upgrade overlays files Windows has never seen. Smart App Control
blocks unsigned files it does not recognise, so upgrading in place under
enforcement is precisely how a working install becomes one that cannot start,
which is what #1873, #1876 and #1967 all describe.

Read VerifiedAndReputablePolicyState under HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy
to find out. The read needs no elevation and cannot raise a UAC prompt: UAC
prompts only on an explicit elevation request, and HKLM is readable by standard
users. Only the value 1 counts as enforcing. A missing key, a missing value, or
anything unrecognised counts as not enforcing, because the cost of guessing
wrong is telling someone to disable Smart App Control, which cannot be undone,
on a PC that was never blocking anything.

When enforcing, the upgrade notification becomes a notice with the download
link instead of an update prompt, and the flow stops before downloading even if
a UI ignores that. Classic honoured no such flag at all, so its dialog now takes
one; its two prompt labels had to be promoted from designer locals to fields to
carry the explanation.

The blocked-file dialog now states the setting it found rather than asking the
user to go and look, and startup logs the state, the install folder, and any
cloud sync root containing it, so a report answers these without a round trip.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 16:26:56 +00:00
Cursor Agentandrmcrackan d240ab12ec Present waiting for signed builds as an option now that signing is in motion
Libation has applied to the SignPath Foundation. Turning Smart App Control off
is irreversible, so pointing users at it as one of only two ways out reads
differently when signed builds may be close. The docs now list waiting first and
carry the status, hedged on approval since there is no date.

The dialog says nothing about signing progress and defers to the docs instead:
a shipped string cannot be corrected if the application falls through. It no
longer nudges toward turning Smart App Control off without mentioning that
the page has the current options.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 15:50:36 +00:00
Cursor Agentandrmcrackan cc90740de4 docs: fix a garbled sentence in the cloud-sync section
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 15:47:04 +00:00
Cursor Agentandrmcrackan 6fa65238db Apply the same Smart App Control correction to the in-app dialog
The 'Libation blocked by Windows security' dialog is generated from
GetApplicationControlBlockedMessage, so it repeated the broken Unblock-File
command verbatim and offered a reinstall that cannot clear an Application
Control block. Explain the missing signature instead, and tell the user how to
check which mode Smart App Control is in and what turning it off costs.

Point the incomplete-upgrade message at its own docs anchor now that the two
failures have separate sections.

Tests cover the message text because it is the whole deliverable here: one
asserts Unblock-File cannot come back, one checks the blocked file and cause
are named, and one pins each message to the docs section it links to.

Refs #1967

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 15:38:19 +00:00
Cursor Agentandrmcrackan c827b9bb8d docs: correct the Smart App Control guidance
The troubleshooting section told users to run 'Unblock-File -Path ... -Recurse',
which is not valid PowerShell (the cmdlet has no -Recurse parameter) and would
not help even in its correct form: Smart App Control gates on the signature and
on cloud reputation, not on Mark-of-the-Web. It also said Evaluation mode
triggers blocks, and advised turning Smart App Control off temporarily and back
on again. Evaluation never blocks, and Windows cannot re-enable Smart App
Control without a reset or reinstall.

Say instead that the Windows builds are unsigned, that Windows has no per-app
exception, and what the two real options are.

Split the section in three, since it served two unrelated failures and the
'extract to a new folder' fix only applies to one of them: a Smart App Control
block, an incomplete in-app upgrade, and installing into a synced folder. Keep
the original anchor on the Smart App Control section so links in released builds
still land somewhere useful.

Refs #1967, #1873, #1876

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-18 15:38:12 +00:00
Allamagoosa 63778135c6 Stack Chardonnay's queue settings so Clear Finished fits
The settings row was a single line of four groups: Cancel All, the
auto-scroll and concurrency controls, the speed limit, and Clear
Finished. Adding the concurrency control pushed the total past what the
queue panel is usually given, and Clear Finished - last in the row -
was the one that ran off the edge. Narrowing the spinners bought some
room back but not enough to be worth relying on.

The two settings now stack, the way the WinForms panel does, with their
labels right-aligned in a shared column so they line up. That roughly
halves the horizontal space they need. The row grows from 40 to 72 to
hold both lines, and the two buttons stretch to the new height.
2026-08-16 18:06:33 -07:00
Allamagoosa 383d768f46 Lay the queue settings out so they stop colliding
WinForms positioned every control in the panel absolutely, mixing
left-anchored and right-anchored ones. At the design width it looked
fine; narrower than that, Clear Finished slid left over the "At once"
spinner - and the DL Limit label had always slid onto Cancel All the
same way, long before this branch. The two rows also never lined up
with each other, because the labels and the spinners each started at
different offsets.

Cancel All and Clear Finished now dock to the panel edges and the
settings sit in a two-row table between them: labels right-aligned in a
fixed column, controls left-aligned in the next, so "At once" and
"DL Limit" line up and stay lined up. Nothing can overlap, because
nothing is placed by hand any more.

Avalonia's row gained a fourth column for the same setting, which left
less room before the star column collapses and Clear Finished clips.
The spinner asked for 90px to show a number between 1 and 10; 72 is
enough and gives most of that width back.
2026-08-16 18:03:49 -07:00
Allamagoosa 381cf307f7 Stop the spinner's bound from overwriting the stored concurrency
Bounding the persisted setting by processor count was only half of it.
Both spinners take their maximum from the same machine-derived number
and are two-way, so a NumericUpDown handed a value above its maximum
coerces the display down and writes the coerced value straight back.
A user who chose 8 on their desktop and opened the queue panel on a
two-core laptop still ended up with 8 replaced by 2 - the same defect,
one layer up, and not fixed by the configuration change alone.

The bound is now the machine's capability or the stored value,
whichever is higher, so the spinner can never coerce what is already
there. Lowering it remains the user's to do, and what actually runs is
still held down to what the machine can manage at the point of use.
2026-08-16 15:47:46 -07:00
Allamagoosa d7a794e557 Pin down TrackedQueue ordering and notifications under concurrency
TrackedQueue<T> is a pure data structure with no dependencies beyond
System, so the behaviour parallel downloads relies on can be asserted
directly rather than inferred from the UI.

Every case here needs more than one book active at once, which is what
made these defects unreachable before. The two that mattered: a book
finishing out of order now reports the reorder, so a list bound only to
CollectionChanged stays in step with the queue; and the queue can be
enumerated while it is being mutated, which used to throw.

The sequential path is covered too - one book at a time still raises no
Move, because the book that finishes is already first.
2026-08-16 15:42:37 -07:00
Allamagoosa 524e1ced50 Credit SirBiggin and dmatlock171 as contributors 2026-08-16 14:14:51 -07:00
Allamagoosa 2b7214e3dd Take a copy of the active books before applying the speed limit
Same unlocked-enumeration problem as the others: the speed limit is
changed from the UI thread while book tasks are starting and finishing,
and Active is the live list.
2026-08-16 14:14:51 -07:00
Allamagoosa 325af3a491 Address the remaining review points on concurrent processing
Faulted book tasks were dropped unobserved. The reaping pass removes any
completed task from the active set before the closing WhenAll can
rethrow, so an exception out of ProcessOneAsync - which can happen via
GetFailureActionAsync in its finally - went nowhere. In the sequential
loop it reached the outer catch and was logged. It is logged again.

The bad-book dialog did not survive concurrency. The license and
Widevine messages are guarded to appear once per run, but the "skip this
book?" dialog is per book and its "apply to all remaining books" answer
lands in a shared BadBookSessionContext. Three books failing together
put three modals on screen racing to set the same override. The dialog
is now serialised on the session, and each book re-checks the override
after its turn comes: if the book ahead answered "apply to all", the
question is not asked again.

Machine capability no longer overwrites the stored concurrency setting.
MaxAllowedConcurrentDownloads clamped both the getter and the setter, so
a user who chose 8 and then opened the same config on a two-core machine
read back 2 - and had 2 written over their 8. The stored value is now
bounded only by the hard limit, which is machine-independent; processor
count bounds the spinner and is applied at the point of use, where the
loop decides how many books to run.
2026-08-16 14:14:51 -07:00
Allamagoosa ac19e9e80b Make the daily download limit work with a concurrent queue
The opt-in daily download limit landed after this branch was written and
lives inside the sequential while (Queue.MoveNext()) loop this change
replaces. It is sequential by construction, so rebasing alone leaves it
subtly wrong rather than merely conflicted.

The gate now runs in the dispatch loop, between taking a book off the
queue and starting its task. That keeps the existing semantics - checked
as a book is about to start so the queue keeps its contents and the
limit can be changed mid-run - while books already in flight carry on.
Putting it inside the book task instead would have every blocked book
polling the history at once.

Three concrete defects that fell out of the collision:

RequeueLast deferred a book with Queue.ClearCurrent(), which drops
Active[0]. With one book at a time that is the book being deferred; with
three in flight it is somebody else's download, so deferring the second
active book silently evicted the first. It now removes the book it was
given.

A book cancelled at the gate was left on the active list. The sequential
loop retired it on the next MoveNext(); there is no next MoveNext(), so
it is marked completed explicitly.

CancelAllAsync existed twice after the rebase - the sequential version
that sets cancelAllRequested and cancels Queue.Current, and this
branch's version that cancels every active book. Unified into one that
does both. The flag matters: a queue paused on the limit is sitting in
WaitForDailyLimitAsync and that flag is how it learns to stop.

AnyOtherQueuedBookAllowed enumerates the queue with Queue.Any(...) while
book tasks mutate it; that is safe now that GetAllItems snapshots under
the lock, and it also now takes a copy of the active list to cancel.
2026-08-16 14:12:00 -07:00
Allamagoosa e49f410544 Fix TrackedQueue notification and enumeration under concurrency
Both of these are reachable only once more than one book is active at a
time, and both were found by compiling TrackedQueue<T> on its own.

MarkCompleted moved a book from Active to Completed and raised only
CompletedCountChanged. The display index of an active book is derived
from Completed.Count, so when the second of two active books finishes
first the two swap places - with no CollectionChanged, a bound list
keeps painting the old order and rows show the wrong book's progress.
Out-of-order completion is the normal case with parallel downloads. Now
raises a Move for the book whose position actually changed; with one
book at a time the indices are equal and nothing is raised, so the
sequential path behaves exactly as before.

GetAllItems built a lazy Concat inside the lock and returned it, so the
enumeration ran unlocked. Any foreach or LINQ over the queue while a
book task mutated it threw InvalidOperationException. It now returns a
snapshot taken while the lock is held. This is the source of the crash
that setLiberatedVisibleMenuItem was patched for at the symptom.

RemoveActive and ClearCurrent also read _completed.Count outside the
lock when computing the index to report; both now capture it inside.
2026-08-16 14:11:25 -07:00
Allamagoosa 9383a0456e Fill available slots when books are queued after the loop starts
The queue loop only woke when a book finished, so books queued a moment
after it started sat idle until an in-flight download happened to
complete. Observed as one book downloading alone for 15 seconds with a
limit of 5, then four starting within 37ms of the first finishing.

The loop now waits on whichever comes first: an active task completing,
or a new book being queued. The enqueue signal is captured before the
queue is inspected, so a book queued between TryDequeueNext and the wait
completes the token already held rather than a fresh one - otherwise the
wakeup is lost and the loop sleeps until something else finishes. The
'nothing left, exit' path re-checks the same signal for the same reason.
2026-08-16 14:08:49 -07:00
Allamagoosa 5ca43895f2 Fix overlapping controls in the WinForms process queue panel
The 'At once' spinner sat at x136-181 on the second row, but the
'DL Limit:' label starts at x148. Moves the label and spinner up to the
first row beside the Auto-scroll checkbox, where there is clear space
between Cancel All and Clear Finished.
2026-08-16 14:08:49 -07:00
Allamagoosa 986dda5eaa Review #1885: Avalonia parity, and cap concurrency by processor count
Chardonnay had parallel downloads with no way to configure them, since
the queue logic lives in shared UI code but each UI supplies its own
controls. Adds the Auto-scroll toggle and the 'At once' spinner to
Chardonnay's queue panel, bound to the same view model properties the
WinForms panel uses.

Also uses Environment.ProcessorCount as the spinner's ceiling rather
than its default: min(ProcessorCount, 10). Downloading is bound by
Audible's license throttling rather than local CPU, so core count says
nothing about how many concurrent downloads will succeed - it only
bounds how many decrypts can usefully run at once. The default stays 3.

Spinner bounds are bound rather than hardcoded, so the two UIs cannot
drift apart.
2026-08-16 14:08:49 -07:00
Allamagoosa 2db63a8edd Review #1885: one concurrency setting with a safe default and a numeric control
Replaces the MultiThreadEnabled checkbox and the ProcessorCount default
with a single MaxConcurrentDownloads value where 1 means serial - the
behaviour Libation had before parallel downloads existed.

The old pairing could not express 'off' at all: the bool was hardcoded
true in the view model constructor and the int setter clamped to a
minimum of 2, so there was no way back to one-at-a-time downloads.
Collapsing both into one value makes that state unreachable rather than
merely fixed, and removes the risk of the two settings disagreeing.

Bounds live in Configuration as named constants: minimum 1, default 3,
maximum 10. Audible throttles license requests, so the default is
deliberately conservative and the cap keeps users from choosing a number
that produces license denials instead of speed.

WinForms swaps the 'Parallel downloads' checkbox for an 'At once:'
spinner that reads its bounds from those constants.
2026-08-16 14:08:49 -07:00
Allamagoosa ad59032693 Review #1885: cancel in-flight downloads on abort and disk full
ClearQueue() only stops new work from starting. With parallel downloads,
books already running kept going after an abort or a disk-full result,
so the queue reported itself stopped while downloads continued.

Adds ProcessQueueViewModel.CancelAllAsync(), which clears the queue and
cancels every active book, and uses it for both the abort and disk-full
paths. Both Cancel All buttons now route through it as well - the Avalonia
one previously cancelled only Queue.Current, so it missed every book but
the first.
2026-08-16 14:08:49 -07:00
Allamagoosa e11186da99 Review #1885: keep the machine-specific installer change out of the PR
Restores the plain 'dotnet publish' invocations in
Scripts/Windows/Build-WindowsInstaller.ps1. Hardcoding
C:\Program Files\dotnet\dotnet.exe is machine-specific and unrelated
to parallel downloads.
2026-08-16 14:08:39 -07:00
SirBigginandClaude Sonnet 4.6 e90850f16d Fix thread-safety crash in setLiberatedVisibleMenuItem during parallel downloads
Snapshot GetVisible() on the UI thread before handing off to Task.Run,
preventing InvalidOperationException when parallel downloads modify the
collection concurrently. Also fix installer script to use 64-bit dotnet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-16 14:08:39 -07:00
SirBiggin d93d325913 Added Multi Threading for downloads and Auto Scroll to view current downloads 2026-08-16 14:08:39 -07:00
284 changed files with 14278 additions and 1190 deletions

No files matched your search

+19 -4
View File
@@ -66,10 +66,20 @@ jobs:
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
"-p:SelfContained=true")
dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
# PowerShell does not stop at a native command that fails, and the step is judged by the exit code
# of the last one, so a publish that fails anywhere but the end passes as a green build. That is not
# hypothetical: LibationWinForms failed to compile for a while and every run stayed green, uploading
# a Libation-Classic zip with no Libation.exe in it. Check each one.
$projects = @(
"Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj",
"LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj",
"LibationCli/LibationCli.csproj",
"Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj")
foreach ($project in $projects) {
dotnet publish "$project" $PUBLISH_ARGS
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed for $project" }
}
- name: Zip artifact
id: zip
@@ -81,6 +91,11 @@ jobs:
"WindowsConfigApp.deps.json")
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
# Both UIs publish as Libation.exe. upload-artifact's if-no-files-found only proves the zip exists,
# which it does whether or not the app is inside it, so say outright that it has to be.
if (-not (Test-Path "Libation.exe")) { throw "Libation.exe is missing from the publish output" }
$artifact="${{ matrix.artifact_stem }}.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path * -DestinationPath "$artifact"
+6
View File
@@ -99,6 +99,10 @@ export default defineConfig({
link: "/docs/features/daily-download-limit",
},
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
{
text: "Parallel Downloads",
link: "/docs/features/parallel-downloads",
},
{
text: "Retrying Refused Downloads",
link: "/docs/features/retrying-refused-downloads",
@@ -111,6 +115,7 @@ export default defineConfig({
text: "Easy guide to searching",
link: "/docs/features/lucene",
},
{ text: "Trash Bin", link: "/docs/features/trash-bin" },
],
},
{
@@ -119,6 +124,7 @@ export default defineConfig({
items: [
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
{ text: "Command Line Interface", link: "/docs/advanced/command-line-interface" },
{ text: "Device registration", link: "/docs/advanced/device-registration" },
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" },
{ text: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
],
+10 -4
View File
@@ -15,10 +15,16 @@ plus a headless CLI (`LibationCli`) that shares the same config and SQLite libra
`dotnet restore Source/Libation.slnx`. No other setup is needed.
- The solution file is the new XML format: `Source/Libation.slnx` (there is no classic `.sln`).
### Building — Linux only builds the cross-platform projects
- Do **not** build the whole solution on Linux. Several projects are Windows/OS-specific and will
not build here: `LibationWinForms`, `HangoverWinForms` (target `net10.0-windows7.0`) and the
`LoadByOS/{Windows,MacOS}ConfigApp` helpers.
### Building — everything compiles on Linux, only some of it runs
- `dotnet build Source/Libation.slnx` succeeds on Linux with 0 errors. So do
`LibationWinForms`, `HangoverWinForms` and the `LoadByOS/{Windows,MacOS}ConfigApp` helpers,
individually: they target `net10.0-windows7.0` but set `EnableWindowsTargeting`, which is what makes
a non-Windows SDK build them. **Always compile-check a change to the WinForms projects** — an earlier
version of this file said they could not be built here, and a whole PR's worth of WinForms edits went
out unverified on the strength of that.
- Compiling is not running. WinForms has no Linux runtime, so behaviour and layout in
`LibationWinForms` / `HangoverWinForms` still need a human on Windows. Say so explicitly rather than
implying a WinForms change was tested.
- Build the runnable cross-platform apps directly:
- `dotnet build Source/LibationAvalonia/LibationAvalonia.csproj`
- `dotnet build Source/LibationCli/LibationCli.csproj`
+8
View File
@@ -2,4 +2,12 @@
<PropertyGroup>
<EnableMSTestRunner>true</EnableMSTestRunner>
</PropertyGroup>
<ItemGroup>
<!-- The SDK supplies Microsoft.SourceLink.Common and the git tasks, which is enough to find the
repository but not to turn its URL into fetchable source URLs. Without a host provider every
project warns that the generated source link is empty and ships PDBs that cannot locate their
sources. -->
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.301" PrivateAssets="All" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -47,7 +47,7 @@ We welcome contributions!
## Community & Support
- **[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.
- **[Donate](https://getlibation.com/donate)**: Support the project if you find it useful.
## License
+1
View File
@@ -4,6 +4,7 @@ Developer utilities. None of these ship in a Libation install - they exist only
| Script | Purpose | Documented in |
|--------|---------|---------------|
| `seed-demo-accounts.cs` | Seed fake Audible accounts, to reach what is gated on having one without signing in | [Testing Changes](https://getlibation.com/docs/development/testing) |
| `seed-demo-library.cs` | Seed a library covering every Liberate-column icon, for manual UI testing | [Testing Changes](https://getlibation.com/docs/development/testing) |
| `seed-download-history.cs` | Seed completed downloads so the daily download limit can be tested without downloading | [Testing Changes](https://getlibation.com/docs/development/testing) |
| `Bundle_Debian.sh` | Build the Linux `.deb` package | Used by `.github/workflows/build-linux.yml` |
+236
View File
@@ -0,0 +1,236 @@
#:package AudibleApi@11.0.3.1
using AudibleApi;
using AudibleApi.Authorization;
using AudibleApi.Cryptography;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
// Adds fake Audible accounts to AccountsSettings.json, so the parts of Libation that only appear once an
// account exists can be tested without signing in to Audible.
//
// dotnet run Scripts/seed-demo-accounts.cs [--count 1] [--clean] [path-to-Libation-files-folder]
//
// Run Libation at least once first so AccountsSettings.json exists, and close it before seeding: the file is
// read at startup and written back on save, so a running app would overwrite whatever this wrote.
//
// The accounts carry structurally valid but meaningless tokens. Libation will count them, list them and
// enable everything that is gated on having an account, but any scan or download attempt fails at Audible.
//
// Real accounts in the file are left alone. --clean removes only the accounts this script added, which are
// the ones whose AccountId matches demo*@example.com.
//
// Keep the package version above in step with AudibleApi in Source/AudibleUtilities/AudibleUtilities.csproj,
// so the JSON this writes is the shape the app expects to read.
// Fixture material. A syntactically valid RSA key and ADP token so the identity parses; neither authenticates.
const string SamplePrivateKey = @"
-----BEGIN RSA PRIVATE KEY-----
MIIEpgIBAAKCAQEA5nPbGSVDmlEH2tJa6kz/P2HI8IeirhfPHdmi+X/nsb9i3WNf
tmEdZxfK26IValQDXvBH17a1gr0HD6pYse1XsV2w0HxiW1RW+ZnjL8/fzPdkSOb+
4xKlqRopCueBSdDGgAF06spZ3IeHLfEFOJX4dO1Y73pFBUkA0k53LT12L2Tjay/r
buZHJqIzxmwja7/nkiWL0Xo7UySHtQACYsKEatu6yHBS+cPTlGR/qeUpeJTHwDLP
7ZQ7kWzJGY1mfInYekjlZLsMsWswso3pg1vPyHgxzM2BWhY8m6mlXQ9G/USxBTib
MNuMtpR73XsgamneFCc+Uv1cxw7ofZ41YOOAbQIDAQABAoIBAQDIre8HkKm0Aggj
B7df/TjxCsgenR6PF/Cmf9UqC7XJ1W3UeCrq+NrP4aonZJfdhdeBnyAQuuyJMu6p
N6ARISuSKpJEm2xTN7idluJ9yjmLlYtg6LbhKmXUQhGniz3M999DrQERTLDAF80h
tpbjVcWMnPsrX4AnQBFVEjs5zCHU1hD+X463EmUHBWyT975jbZ8Fy7/fTzkdzLnn
qE5lROALr2MCAAwQRFbRE6dd52vnXaBrVcAtRzjATts3WG3+SNi2Fm/OrYqQcY9e
lBexNviT8VcldOAMrO10E2u0d+tvxFzwB3ABMvaVamrEZky4XSfB6aLzpD0JJj1s
UHnIiVwJAoGBAPl8nLll/J9rud/N2HiAX2YkP0MC0HW4yM3KxLtXKyXrP5qBpaci
wTDUmSWEEE3GUJMM1Z4d9tl9Lz2MhU2KqkEvLI3kQ7aUu33PYUBGMVcUzhFQ49lU
Nzz8YB183iqo31o/DKk2Cr5gI7SykQZ0gn/urZkEJeErLzlhPXcyeY5jAoGBAOx4
CGucVdv5MbdXZP8jVzxuvUlSp7BIQJ2phQXDFBNApFKnZn7yBYBx7dqzleymGm+R
INZAurg3SNw4nvbQc3Z2dJ8I+n5ErjFCKp1IedVxx1eMEfecTwrQZuUwLISIyjqF
czSJNwcNqzCx67z397/Cg5K/0pu6uIe0r7xozcbvAoGBAOOvZ9CDVPOg+rdXQvFm
Jqou9lUPonNtOkUlgjl+qfAnK5q0KxvHSgxoWYO1bLOuAybQlbuBmSCPcKd5MMa9
f/eRN9YetfVQ83Mz6YshBDJ22EFRUz+p7eeIY6dFp/PCvmO8Gq/qlA996dglBtmf
RuG+T0vQT0mZgbWaGuBHfkwFAoGBAMOLg1MRxgKRMKavk6pU3EfyP3+J5XemWCDI
1WLtbgV5uClNmzmxBBGypQHs7jbzKPtHpULn5kB+HzdVb0clG8ZDsK7u6s5OF0pO
sBS+oVl7rF/eSeFcFhUYP26ZhsbWo3z/bERuj926VO2AxDPRTsP5o3pQPGZhY0V9
irGgbUJrAoGBAOseS3J4BqYM4R3Hr7cRAhvzSjIkeTcDF1zTOa4FZDHBxZ6g2PNq
8ekhtfn1zPczsPTF1vNuqEISKLxaPkVPiw0mtaZQjVwpF/IOxMNjWVLp6oJf8Mm2
BxlXqPnQ4mG66oqSFQgDEmFdMhRb2of6xL1gYYL62C80G2T7QtmPfSab
-----END RSA PRIVATE KEY-----
";
const string SampleAdpToken = "{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}";
const string DemoAccountSuffix = "@example.com";
const string DemoAccountPrefix = "demo";
var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase);
var count = IntArg("--count") ?? 1;
var pathArg = args.FirstOrDefault(a => !a.StartsWith("--") && !int.TryParse(a, out _));
if (count < 1)
{
Console.Error.WriteLine("--count must be at least 1.");
return 1;
}
if (FindLibationFiles(pathArg) is not string libationFiles)
{
Console.Error.WriteLine("Could not find a Libation accounts file.");
Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:");
Console.Error.WriteLine(@" dotnet run Scripts/seed-demo-accounts.cs C:\Users\me\AppData\Local\Libation");
return 1;
}
var accountsPath = Path.Combine(libationFiles, "AccountsSettings.json");
Console.WriteLine($"Accounts file: {accountsPath}");
var root = JObject.Parse(File.ReadAllText(accountsPath));
if (root["Accounts"] is not JArray accounts)
{
accounts = [];
root["Accounts"] = accounts;
}
/// <summary>
/// Whether this script wrote the account, decided from its id rather than a marker property.
/// </summary>
/// <remarks>
/// A property would be tidier but does not survive: AudibleUtilities.Account serializes exactly five members
/// and has no [JsonExtensionData], so Newtonsoft ignores an extra property on the way in and never writes it
/// back out. Verified by adding one and renaming the account in the app - the save dropped it. Anything that
/// dirties an account does the same, including a token refresh during a scan, and --clean would then leave
/// demo accounts behind with no way to find them. Giving the shipping Account class a demo flag to serve a
/// dev script is not a trade worth making.
///
/// The id is the right anchor instead: it is immutable, always persisted, and example.com is reserved by
/// RFC 2606, so no real Audible login can collide with it.
/// </remarks>
static bool IsDemo(JToken account)
=> account["AccountId"]?.Value<string>() is string id
&& id.StartsWith(DemoAccountPrefix, StringComparison.OrdinalIgnoreCase)
&& id.EndsWith(DemoAccountSuffix, StringComparison.OrdinalIgnoreCase);
if (clean)
{
var removed = accounts.Where(IsDemo).ToList();
foreach (var account in removed)
account.Remove();
File.WriteAllText(accountsPath, root.ToString(Formatting.Indented));
Console.WriteLine($"Removed {removed.Count} demo account(s). {accounts.Count} account(s) remain.");
return 0;
}
var realAccounts = accounts.Count(a => !IsDemo(a));
if (realAccounts > 0)
Console.WriteLine($"Leaving {realAccounts} real account(s) untouched.");
var added = 0;
foreach (var accountId in DemoAccountIds(count))
{
if (accounts.Any(a => string.Equals(a["AccountId"]?.Value<string>(), accountId, StringComparison.OrdinalIgnoreCase)))
{
Console.WriteLine($" {accountId} already present, skipping");
continue;
}
accounts.Add(BuildAccount(accountId));
added++;
}
File.WriteAllText(accountsPath, root.ToString(Formatting.Indented));
Console.WriteLine($"Added {added} demo account(s). Libation will now see {accounts.Count} account(s).");
Console.WriteLine();
Console.WriteLine("Start Libation. What the account count changes:");
Console.WriteLine(" 1 account Import > Scan Library, Import > Remove Library Books");
Console.WriteLine(" 2 or more Import > Scan Library of All / Some Accounts, and the matching Remove items");
Console.WriteLine(" any an empty library offers 'Scan Library' rather than 'Add Account'");
Console.WriteLine();
Console.WriteLine("Do not scan or download: these accounts have meaningless tokens and Audible will refuse them.");
return 0;
/// <summary>
/// The first account matches the one seed-demo-library.cs assigns its books to, so seeded books and a
/// seeded account describe the same library rather than two unrelated ones.
/// </summary>
static IEnumerable<string> DemoAccountIds(int count)
{
yield return $"{DemoAccountPrefix}{DemoAccountSuffix}";
for (var n = 2; n <= count; n++)
yield return $"{DemoAccountPrefix}{n}{DemoAccountSuffix}";
}
/// <summary>
/// An account entry carrying a registered-looking identity. The values are fixture material, the same shape
/// AudibleApi writes after a real registration, so the file parses and the account loads.
/// </summary>
static JObject BuildAccount(string accountId)
{
var identity = new Identity(Localization.Get("us"));
identity.Update(
new PrivateKey(SamplePrivateKey),
new AdpToken(SampleAdpToken),
new AccessToken($"Atna|_DEMO_ACCESS_{accountId}", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
new RefreshToken($"Atnr|_DEMO_REFRESH_{accountId}"),
[new KeyValuePair<string, Dinah.Core.Security.SecretString>("session-id", "demo-cookie-value")],
deviceSerialNumber: "demo-device-serial",
deviceType: "demo-device-type",
amazonAccountId: "demo-amazon-account",
deviceName: "Demo Device",
storeAuthenticationCookie: "demo-store-auth-cookie");
// Write the tokens as plaintext rather than reaching for the OS secret store, which this script has no
// business touching. Libation reads either, whatever its own Token storage preference is set to.
IdentityTokenStorage.Configure(TokenStorageMethod.Plaintext, protector: null);
return new JObject
{
["AccountId"] = accountId,
["AccountName"] = $"Demo ({accountId})",
["LibraryScan"] = true,
["DecryptKey"] = "",
["IdentityTokens"] = JObject.Parse(JsonConvert.SerializeObject(identity, Identity.GetJsonSerializerSettings()))
};
}
int? IntArg(string name)
{
var i = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
return i >= 0 && i + 1 < args.Length && int.TryParse(args[i + 1], out var value) ? value : null;
}
/// <summary>Locate the Libation files folder the same way Libation itself does.</summary>
static string? FindLibationFiles(string? explicitPath)
{
var candidates = new List<string>();
if (explicitPath is not null)
candidates.Add(explicitPath);
foreach (var folder in KnownLibationFolders())
{
candidates.Add(folder);
// appsettings.json may redirect the files folder somewhere else entirely.
var appSettings = Path.Combine(folder, "appsettings.json");
if (!File.Exists(appSettings))
continue;
try
{
if (System.Text.Json.JsonDocument.Parse(File.ReadAllText(appSettings)).RootElement
.TryGetProperty("LibationFiles", out var redirect) && redirect.GetString() is string path)
candidates.Add(path);
}
catch (System.Text.Json.JsonException) { }
}
return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "AccountsSettings.json")));
}
static IEnumerable<string> KnownLibationFolders()
{
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation");
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation");
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Libation");
yield return Path.Combine(Path.GetTempPath(), $"Libation-{Environment.UserName}");
yield return AppContext.BaseDirectory;
}
+88 -4
View File
@@ -14,6 +14,9 @@ using System.Text.Json;
// Reproducing every icon by hand is tedious, and two of them are not database state at all:
// the yellow lamp means "a partial download is sitting on disk", so this writes placeholder
// .aaxc files as well.
//
// Writing rows directly also leaves Libation's search index stale, which makes every filter on the
// seeded books come back empty, so this deletes the index and lets Libation rebuild it.
const int NotLiberated = 0, Liberated = 1, Error = 2;
const int Product = 1, Episode = 2, Parent = 4;
@@ -52,6 +55,7 @@ if (clean)
File.Delete(partial);
Console.WriteLine($"Removed {removed} demo book(s) and their placeholder downloads.");
InvalidateSearchIndex();
return 0;
}
@@ -97,12 +101,13 @@ foreach (var book in books)
Execute(
"""
insert into LibraryBooks (BookId, AbsentFromLastScan, Account, DateAdded, IncludedUntil, IsAudiblePlus, IsDeleted)
values ($bookId, $isAbsent, 'demo@example.com', '2026-08-13 00:00:00', $includedUntil, $isPlus, 0)
values ($bookId, $isAbsent, 'demo@example.com', '2026-08-13 00:00:00', $includedUntil, $isPlus, $isDeleted)
""",
("$bookId", bookId),
("$isAbsent", book.IsAbsent ? 1 : 0),
("$includedUntil", book.IsPlus ? "2027-01-31 00:00:00" : (object)DBNull.Value),
("$isPlus", book.IsPlus ? 1 : 0));
("$isPlus", book.IsPlus ? 1 : 0),
("$isDeleted", book.IsDeleted ? 1 : 0));
Execute(
"insert into BookContributor (BookId, ContributorId, Role, [Order]) values ($bookId, $contributorId, 1, 0)",
@@ -135,6 +140,7 @@ foreach (var book in books)
transaction.Commit();
Console.WriteLine($"Added {added} book(s).");
InvalidateSearchIndex();
if (partials.Count > 0)
{
@@ -146,10 +152,26 @@ if (partials.Count > 0)
Console.WriteLine($"Wrote {partials.Count} placeholder download(s) to {inProgress}");
}
Console.WriteLine();
Console.WriteLine("Filtering covers these books. Try HasSubtitle , TitleHasColon , or Trashed for no matches.");
Console.WriteLine();
Console.WriteLine("Start Libation and sort by Title. Expected Liberate column, top to bottom:");
foreach (var book in books)
foreach (var book in books.Where(b => !b.IsDeleted))
Console.WriteLine($" {book.Title,-46} {book.Expectation}");
var trashed = books.Where(b => b.IsDeleted).ToList();
if (trashed.Count > 0)
{
Console.WriteLine();
Console.WriteLine($"{trashed.Count} seeded book(s) are in the trash, so they are NOT in the grid:");
foreach (var book in trashed)
Console.WriteLine($" {book.Title,-46} {book.Expectation}");
Console.WriteLine();
Console.WriteLine(" - the status bar should end with a clickable \"N in trash\"");
Console.WriteLine(" - Settings > Trash Bin should carry the same count");
Console.WriteLine(" - filtering for Trashed should find nothing and offer to open the trash bin");
}
Console.WriteLine();
Console.WriteLine("Do not click the stoplights: these books are not real and cannot be downloaded.");
return 0;
@@ -264,6 +286,42 @@ static List<DemoBook> BuildDemoBooks()
Expectation: expectation,
IsAbsent: true));
// Books in the trash. Removal is a soft delete, and a trashed book is filtered out of the grid, the
// search index and every status count at once, so nothing on screen distinguishes one from a book that
// was never imported. These rows are how that is checked: they must stay out of the grid while the
// status bar, the Settings > Trash Bin menu item and the empty-search hint all account for them.
foreach (var (label, bookStatus, isPlus, expectation) in new (string, int, bool, string)[]
{
("purchased", NotLiberated, false, "in the trash bin only, red lamp there"),
("PLUS", Liberated, true, "in the trash bin only, green lamp with badge there"),
})
books.Add(new DemoBook(
Asin: $"{AsinPrefix}{++n:000}",
Title: $"{n:00} Trashed | {label}",
ContentType: Product,
BookStatus: bookStatus,
PdfStatus: null,
IsPlus: isPlus,
NeedsPartialDownload: false,
Expectation: expectation,
IsDeleted: true));
// A trashed episode whose parent is still in the library. The trash bin asks for every parent, not just
// deleted ones, so this should appear nested under the series there while the series keeps its other
// episodes in the main grid.
books.Add(new DemoBook(
Asin: $"{AsinPrefix}{++n:000}",
Title: $"{n:00} Demo Series - episode 3 (trashed)",
ContentType: Episode,
BookStatus: NotLiberated,
PdfStatus: null,
IsPlus: false,
NeedsPartialDownload: false,
Expectation: "nested under Demo Series in the trash bin, absent from the grid",
SeriesAsin: seriesAsin,
SeriesOrder: "3",
IsDeleted: true));
// Titles that subtitle removal changes. <title short> stops at the first colon, so it cuts a colon in
// Audible's own title just as readily as it drops Audible's subtitle field, and the grid's Title column
// shows both the same way. The HasSubtitle and TitleHasColon filters are the only way to tell them apart.
@@ -347,6 +405,31 @@ string DownloadsInProgress()
return Path.Combine(inProgress, "DownloadsInProgress");
}
/// <summary>Delete the Lucene index so Libation rebuilds it from the database.</summary>
/// <remarks>
/// This script writes rows straight to SQLite, which leaves the index untouched, so every filter on the
/// main grid came back empty no matter what had been seeded. Libation treats a missing index as a full
/// re-index rather than an error, so deleting the folder is enough: the first search rebuilds it from the
/// library, and the rebuild excludes trashed books the same way the grid does.
/// </remarks>
void InvalidateSearchIndex()
{
var searchEngine = Path.Combine(libationFiles, "SearchEngine");
if (!Directory.Exists(searchEngine))
return;
try
{
Directory.Delete(searchEngine, recursive: true);
Console.WriteLine("Deleted the search index. Libation rebuilds it on the first search or filter.");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Console.Error.WriteLine($"Could not delete the search index at {searchEngine}: {ex.Message}");
Console.Error.WriteLine("Close Libation and delete that folder by hand, or filtering will not find the seeded books.");
}
}
/// <summary>Look up a row's id by its Audible id, inserting it first if it isn't there yet.</summary>
/// <remarks>
/// Neither Contributors.AudibleContributorId nor Series.AudibleSeriesId is unique, so an upsert
@@ -396,4 +479,5 @@ record DemoBook(
string? SeriesAsin = null,
string? SeriesOrder = null,
bool IsAbsent = false,
string Subtitle = "");
string Subtitle = "",
bool IsDeleted = false);
+4
View File
@@ -21,4 +21,8 @@
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
+23 -5
View File
@@ -179,13 +179,17 @@ public class NetworkFileStream : Stream, IUpdatable
DownloadTask = Task.Run(() => DownloadLoopInternal(client, response), _cancellationSource.Token);
}
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
// avoid infinite retry loops if the server consistently fails for a particular title
private const int MAX_TLS_RETRIES = 5;
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
{
try
{
long startPosition = WritePosition;
var startPosition = WritePosition;
while (WritePosition < ContentLength && !IsCancelled)
var tlsRetryCount = 0;
while (WritePosition < ContentLength && !IsCancelled)
{
try
{
@@ -206,8 +210,22 @@ public class NetworkFileStream : Stream, IUpdatable
blockResponse = await RequestNextByteRangeAsync(client);
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
}
}
}
catch (IOException e)
when (e.InnerException is System.ComponentModel.Win32Exception { NativeErrorCode: -2146893008 }
&& WritePosition != startPosition
&& WritePosition < ContentLength && !IsCancelled
&& ++tlsRetryCount <= MAX_TLS_RETRIES)
{
Serilog.Log.Logger.Warning($"TLS decryption failure at position 0x{WritePosition:X10}. Reconnecting and resuming download.");
_writeFile.Position = startPosition = WritePosition;
blockResponse.Dispose();
blockResponse = await RequestNextByteRangeAsync(client);
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
}
}
}
catch (Exception ex)
{
+4 -1
View File
@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>13.7.10</Version>
<Version>14.2.0</Version>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
@@ -16,6 +16,9 @@
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
+39 -2
View File
@@ -208,12 +208,34 @@ public static class LibationScaffolding
private static void ensureSerilogConfig(Configuration config)
=> config.EnsureSerilogConfig();
/// <summary>
/// Hands everything <see cref="StartupLog"/> collected before this point to Serilog, and points it at
/// Serilog from here on. Startup runs long before logging exists, so without this those diagnostics are
/// simply dropped. This is also the only place in Libation that maps a startup entry onto a Serilog level.
/// </summary>
private static void replayStartupLog()
=> StartupLog.ReplayTo(entry =>
{
var level = entry.Level switch
{
StartupLogLevel.Debug => Serilog.Events.LogEventLevel.Debug,
StartupLogLevel.Warning => Serilog.Events.LogEventLevel.Warning,
StartupLogLevel.Error => Serilog.Events.LogEventLevel.Error,
_ => Serilog.Events.LogEventLevel.Information,
};
// The message is already rendered: startup cannot build a Serilog template without
// referencing Serilog, which is the whole point of StartupLog. See issue #2001.
Log.Logger.Write(level, entry.Exception, "[startup {StartupTimestamp:HH:mm:ss.fff}] {StartupMessage}", entry.Timestamp, entry.Message);
});
// to restore original: Console.SetOut(origOut);
private static TextWriter origOut { get; } = Console.Out;
private static void configureLogging(Configuration config)
{
config.ConfigureLogging();
replayStartupLog();
Log.Information(
"Paths: LibationFiles={LibationFiles} AppsettingsJson={AppsettingsJson} SQLiteDb={SqliteDb}",
config.LibationFiles.Location,
@@ -289,11 +311,20 @@ public static class LibationScaffolding
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
// -1 means the count could not be taken. Listing a directory no longer throws when it stops being
// readable partway through, so an incomplete walk has to be asked about rather than caught: a count
// that silently means "as many as I managed to read" is worse than no count in a bug report.
static int fileCount(FileManager.LongPath? longPath)
{
if (longPath is null)
return -1;
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
var complete = true;
try
{
var count = FileManager.FileUtility.SaferEnumerateFiles(longPath, onIncomplete: _ => complete = false).Count();
return complete ? count : -1;
}
catch { return -1; }
}
@@ -305,6 +336,11 @@ public static class LibationScaffolding
Configuration.OS,
Environment.OSVersion,
InteropFactory.InteropFunctionsType,
// A file Windows refused to load, or a half-applied upgrade, is usually explained by one
// of these three, and none of them is visible anywhere else in a bug report.
InstallFolder = Configuration.ProcessDirectory,
ApplicationControl = ApplicationControlPolicy.GetState(),
InstallFolderCloudSync = CloudSyncedFolders.GetSyncStatus(Configuration.ProcessDirectory),
Mode = mode,
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
@@ -318,7 +354,8 @@ public static class LibationScaffolding
config.ImportEpisodes,
config.ImportPlusTitles,
config.DownloadEpisodes,
config.BetaOptIn,
// Off means no startup upgrade prompt, which is otherwise indistinguishable from a broken check
config.CheckForUpgradesAtStartup,
config.UseCoverAsFolderIcon,
config.LibationFiles,
AudibleFileStorage.BooksDirectory,
@@ -0,0 +1,25 @@
using Dinah.Core;
namespace ApplicationServices;
/// <summary>
/// What to tell the user about titles a run left alone because the last library scan did not find them.
/// <para>
/// Audible will not license a title it no longer lists for the account - a returned title, or one that has
/// left the Plus catalog - so attempting one collects a refusal every run and downloads nothing. Both hosts
/// say it in these words so that the same skip does not read as two different things.
/// </para>
/// </summary>
public static class AbsentFromLastScanUserMessage
{
/// <summary>The reason, short enough to head a list of skip reasons.</summary>
public const string Label = "Absent from your last library scan";
/// <summary>What to do about it.</summary>
public const string Advice = "run Scan, or `libationcli scan`, then try again";
/// <summary>The line a CLI run prints in place of attempting them.</summary>
public static string BuildCliSkippedSummary(int count)
=> $"Skipped {"title".PluralizeWithCount(count)} absent from your last library scan. "
+ $"Audible will not license a title it no longer lists, so {Advice}. To attempt them anyway: libationcli liberate --force.";
}
@@ -6,10 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0">
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
</PackageReference>
<PackageReference Include="ClosedXML" Version="0.105.0">
<PackageReference Include="ClosedXML" Version="0.105.1">
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
</PackageReference>
</ItemGroup>
@@ -21,6 +18,10 @@
<ProjectReference Include="..\DataLayer.Sqlite\DataLayer.Sqlite.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
@@ -60,7 +60,7 @@ public class BulkSetDownloadStatus
if (books2change.Any())
actionSets.Add((
$"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'",
$"{"book".PluralizeWithCount(books2change.Count)} to 'Download Pending'",
LiberatedStatus.NotLiberated,
books2change));
}
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
namespace ApplicationServices;
/// <summary>Column header for csv and xlsx export. Mirrors the CsvHelper attribute it replaced.</summary>
[AttributeUsage(AttributeTargets.Property)]
internal sealed class NameAttribute(params string[] names) : Attribute
{
public string[] Names { get; } = names;
}
/// <summary>Excludes a property from csv export. Mirrors the CsvHelper attribute it replaced.</summary>
[AttributeUsage(AttributeTargets.Property)]
internal sealed class IgnoreAttribute : Attribute { }
/// <summary>
/// Minimal write-only CSV serializer, output-compatible with the way this project used
/// CsvHelper: 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. Columns are the
/// public instance properties of each record's runtime type, in declaration order,
/// honoring <see cref="NameAttribute"/> and <see cref="IgnoreAttribute"/>.
/// </summary>
internal sealed class CsvWriter(TextWriter writer, CultureInfo culture) : IDisposable
{
private readonly string delimiter = culture.TextInfo.ListSeparator;
private readonly Dictionary<Type, List<(string Header, PropertyInfo Property)>> columnCache = new();
private bool rowHasFields;
public void WriteHeader(Type type)
{
foreach (var (header, _) in getColumns(type))
writeField(header);
}
public void NextRecord()
{
writer.Write("\r\n");
rowHasFields = false;
}
public void WriteRecords<T>(IEnumerable<T> records) where T : notnull
{
foreach (var record in records)
{
foreach (var (_, property) in getColumns(record.GetType()))
writeField(toString(property.GetValue(record)));
NextRecord();
}
}
public void Dispose() => writer.Dispose();
private void writeField(string field)
{
if (rowHasFields)
writer.Write(delimiter);
writer.Write(escape(field));
rowHasFields = true;
}
private string escape(string field)
{
if (field.Length == 0)
return field;
var needsQuoting
= field.Contains('"')
|| field[0] == ' '
|| field[^1] == ' '
|| field.Contains(delimiter)
|| field.Contains('\r')
|| field.Contains('\n');
return needsQuoting ? $"\"{field.Replace("\"", "\"\"")}\"" : field;
}
private string toString(object? value)
=> value switch
{
null => "",
string s => s,
IFormattable formattable => formattable.ToString(null, culture),
_ => value.ToString() ?? ""
};
private List<(string Header, PropertyInfo Property)> getColumns(Type type)
{
if (!columnCache.TryGetValue(type, out var columns))
columnCache[type] = columns = type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.GetMethod is not null
&& p.GetIndexParameters().Length == 0
&& p.GetCustomAttribute<IgnoreAttribute>() is null)
.Select(p => (p.GetCustomAttribute<NameAttribute>()?.Names.FirstOrDefault() ?? p.Name, p))
.ToList();
return columns;
}
}
+17
View File
@@ -132,6 +132,23 @@ public static class DbContexts
return context.GetDeletedLibraryBooks();
}
/// <summary>How many books belong in the search index. A row count only; no entities are loaded.</summary>
public static int GetIndexableBookCount()
{
using var context = GetContext();
return context.GetIndexableBookCount();
}
/// <summary>
/// How many <see cref="LibraryBook"/> rows are in the trash. A row count only; no entities are loaded,
/// so this is cheap enough to refresh whenever the library changes.
/// </summary>
public static int GetTrashedBookCount()
{
using var context = GetContext();
return context.GetLibraryBookCountsByTrashFlag().InTrash;
}
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
{
using var context = GetContext();
+1 -2
View File
@@ -1,5 +1,4 @@
using CsvHelper.Configuration.Attributes;
using DataLayer;
using DataLayer;
using Newtonsoft.Json;
using System;
using System.Linq;
+136 -60
View File
@@ -11,6 +11,7 @@ using Serilog;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -62,33 +63,31 @@ public static class LibraryCommands
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true);
var scan = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true);
logTime($"post {nameof(scanAccountsAsync)} all");
var libraryItems = scan.Items;
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
return existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
var existing = existingLibrary?.ToList() ?? [];
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
EnsureScanCanIdentifyInactiveBooks(totalCount, existing.Count, scan.FailedAccounts);
var inactive = existing.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
Log.Logger.Information(
"Books absent from the library scan. {@DebugInfo}",
new { InactiveCount = inactive.Count, CandidateCount = existing.Count, ScannedCount = totalCount });
return inactive;
}
catch (Exception ex)
catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx))
{
Log.Logger.Error(lfEx, "Error scanning library. Authentication failed.");
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
@@ -107,6 +106,22 @@ public static class LibraryCommands
}
}
/// <summary>
/// Refuse to call anything "no longer in your library" from a scan that could not see the whole library.
/// Importing tolerates a partial scan because it only adds and updates; removal cannot, because a book
/// missing from one bad scan is indistinguishable from a book the user no longer owns.
/// </summary>
/// <exception cref="LibraryScanIncompleteException">The scan cannot support that decision.</exception>
public static void EnsureScanCanIdentifyInactiveBooks(int scannedItemCount, int existingBookCount, IReadOnlyCollection<string>? failedAccounts)
{
if (failedAccounts?.Count > 0)
throw LibraryScanIncompleteException.ForFailedAccounts(failedAccounts);
// An account whose every book vanished at once is a broken scan, not an emptied library.
if (scannedItemCount == 0 && existingBookCount > 0)
throw LibraryScanIncompleteException.ForEmptyScan(existingBookCount);
}
#region FULL LIBRARY scan and import
public static Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
=> ImportAccountAsync(accounts, allowInteractiveLogin: true);
@@ -136,10 +151,14 @@ public static class LibraryCommands
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
| LibraryOptions.ResponseGroupOptions.IsFinished,
| LibraryOptions.ResponseGroupOptions.IsFinished
//product_details is the only group that returns the copyright line, which Libation
//needs for Widevine downloads: those files arrive without an embedded cprt tag.
| LibraryOptions.ResponseGroupOptions.ProductDetails,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
};
var importItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin);
//Importing only adds and updates, so a partially scanned library is still worth importing.
var importItems = (await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin)).Items;
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count;
@@ -156,25 +175,12 @@ public static class LibraryCommands
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx))
{
Log.Logger.Error(lfEx, "Error scanning library. Authentication failed.");
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
@@ -275,9 +281,18 @@ public static class LibraryCommands
return null;
}
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin)
/// <summary>Outcome of scanning one or more accounts.</summary>
/// <param name="Items">Everything the accounts that did scan returned.</param>
/// <param name="FailedAccounts">
/// Accounts that could not be scanned. Their books are absent from <paramref name="Items"/> for that reason
/// alone, so a caller deciding what to remove must not treat the result as the whole library.
/// </param>
private sealed record ScanResult(List<ImportItem> Items, IReadOnlyCollection<string> FailedAccounts);
private static async Task<ScanResult> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin)
{
var tasks = new List<Task<List<ImportItem>>>();
var failedAccounts = new List<string>();
await using LogArchiver? archiver
= Log.Logger.IsDebugEnabled()
@@ -290,11 +305,22 @@ public static class LibraryCommands
{
try
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin);
// An account is scanned as a unit: all of its marketplaces or none of them. A partial scan would
// leave the marketplaces that did not run looking empty, and AbsentFromLastScan - which is keyed
// by account id, not by marketplace - would mark their titles absent.
var accountTasks = new List<Task<List<ImportItem>>>();
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
// ScanLocales leads with the account's own marketplace, so any login happens once, up front
foreach (var locale in account.ScanLocales)
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin, storeLocale: locale);
// add scanAccountAsync as a TASK: do not await
accountTasks.Add(scanAccountAsync(apiExtended, account, locale, libraryOptions, archiver));
}
tasks.AddRange(accountTasks);
}
catch (Exception ex) when (!allowInteractiveLogin && AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
{
@@ -304,32 +330,39 @@ public static class LibraryCommands
{
//Catch to allow other accounts to continue scanning.
Log.Logger.Error(ex, "Failed to scan account");
failedAccounts.Add(account.MaskedLogEntry ?? account.AccountId ?? "[unknown account]");
}
}
// import library in parallel
var arrayOfLists = await Task.WhenAll(tasks);
var importItems = arrayOfLists.SelectMany(a => a).ToList();
return importItems;
return new ScanResult(importItems, failedAccounts);
}
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver)
/// <param name="locale">
/// The marketplace being read. Usually the account's own; for an account holding titles under a second
/// storefront, each of those in turn. Books are tagged with it, which is how a download later finds its way
/// back to the right store.
/// </param>
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, Locale locale, LibraryOptions libraryOptions, LogArchiver? archiver)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
var locale = ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
ArgumentValidator.EnsureNotNull(locale, nameof(locale));
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
Account = account.MaskedLogEntry ?? "[null]"
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = locale.Name
});
logTime($"pre scanAccountAsync {account.AccountName}");
logTime($"pre scanAccountAsync {account.AccountName} {locale.Name}");
try
{
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions);
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
logTime($"post scanAccountAsync {account.AccountName} {locale.Name} qty: {dtoItems.Count}");
await logDtoItemsAsync(dtoItems);
@@ -346,12 +379,13 @@ public static class LibraryCommands
{
if (archiver is not null)
{
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json";
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry} {locale.Name}.json";
var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson)));
var scanFile = new JObject
{
{ "Account", account.MaskedLogEntry },
{ "Locale", locale.Name },
{ "ScannedDateTime", DateTime.Now.ToString("u") },
};
@@ -404,13 +438,39 @@ public static class LibraryCommands
#endregion
#region remove/restore books
/// <summary>
/// Record every change to the trash. Removal is a soft delete, so a book can leave the library and sit
/// out of sight indefinitely; without this the log cannot say when that happened or how much is in there.
/// </summary>
private static void logTrashChange(string action, int qtyChanges)
{
if (qtyChanges < 1)
return;
try
{
Log.Logger.Information("Trash bin changed. {@DebugInfo}", new
{
Action = action,
Books = qtyChanges,
BooksInTrash = DbContexts.GetTrashedBookCount()
});
}
catch (Exception ex)
{
//The change itself already succeeded. Never fail it over a log line.
Log.Logger.Warning(ex, "Trash bin changed ({action}, {qtyChanges}) but the trash count could not be read", action, qtyChanges);
}
}
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook?>? idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
private static int removeBooks(IEnumerable<LibraryBook?>? removeLibraryBooks)
{
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
return DoDbSizeChangeOperation(ctx =>
var qtyChanges = DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks.OfType<LibraryBook>())
@@ -419,6 +479,9 @@ public static class LibraryCommands
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
});
logTrashChange("Moved to trash", qtyChanges);
return qtyChanges;
}
public static Task<int> RestoreBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => restoreBooks(idsToRemove));
@@ -428,7 +491,7 @@ public static class LibraryCommands
return 0;
try
{
return DoDbSizeChangeOperation(ctx =>
var qtyChanges = DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
@@ -437,6 +500,9 @@ public static class LibraryCommands
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
});
logTrashChange("Restored from trash", qtyChanges);
return qtyChanges;
}
catch (Exception ex)
{
@@ -452,11 +518,14 @@ public static class LibraryCommands
return 0;
try
{
return DoDbSizeChangeOperation(ctx =>
var qtyChanges = DoDbSizeChangeOperation(ctx =>
{
ctx.LibraryBooks.RemoveRange(libraryBooks.OfType<LibraryBook>());
ctx.Books.RemoveRange(libraryBooks.OfType<LibraryBook>().Select(lb => lb.Book));
});
logTrashChange("Permanently deleted", qtyChanges);
return qtyChanges;
}
catch (Exception ex)
{
@@ -673,7 +742,12 @@ public static class LibraryCommands
}
}
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
/// <param name="requestedBy">
/// Names the caller in the log. Both UIs count the whole library and the visible subset as the
/// library loads, and the two produce identical lines whenever no filter is applied, so without
/// this there is no way to tell which set a given count describes.
/// </param>
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null, [CallerMemberName] string requestedBy = "")
{
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
@@ -689,7 +763,7 @@ public static class LibraryCommands
var booksError = results.Count(r => r.status == LiberatedStatus.Error);
var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
Log.Logger.Information("Book counts for {RequestedBy}. {@DebugInfo}", requestedBy, new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
var pdfResults = libraryBooks
.AsParallel()
@@ -699,9 +773,11 @@ public static class LibraryCommands
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
// Error means Audible has a supplement listed for the title but will not deliver one, so the count it
// belongs in is the one for PDFs Libation cannot get. Without this such a title is in no count at all.
var pdfsUnavailable = pdfResults.Count(r => r.status == LiberatedStatus.Error || (r.absent && r.status == LiberatedStatus.NotLiberated));
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
Log.Logger.Information("PDF counts for {RequestedBy}. {@DebugInfo}", requestedBy, new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable, libraryBooks);
}
@@ -1,6 +1,4 @@
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration.Attributes;
using DataLayer;
using Newtonsoft.Json;
using System.Collections.Generic;
@@ -0,0 +1,30 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
namespace ApplicationServices;
/// <summary>
/// A library scan finished without throwing but cannot be trusted to say what is no longer in the account.
/// Deciding "inactive" from such a scan would offer to remove books the user still owns.
/// </summary>
public class LibraryScanIncompleteException : Exception
{
/// <summary>Accounts that could not be scanned, if that is why the scan is untrustworthy.</summary>
public IReadOnlyCollection<string> FailedAccounts { get; }
public LibraryScanIncompleteException(string message, IReadOnlyCollection<string>? failedAccounts = null)
: base(message)
=> FailedAccounts = failedAccounts ?? [];
internal static LibraryScanIncompleteException ForFailedAccounts(IReadOnlyCollection<string> failedAccounts)
=> new(
$"{"account".PluralizeWithCount(failedAccounts.Count)} could not be scanned, so Libation cannot tell which books are no longer in your library: "
+ string.Join(", ", failedAccounts),
failedAccounts);
internal static LibraryScanIncompleteException ForEmptyScan(int existingBookCount)
=> new(
$"The library scan returned no books while Libation still holds {"book".PluralizeWithCount(existingBookCount)}. "
+ "Treating that as an empty Audible library would offer to remove books you still own.");
}
@@ -1,6 +1,5 @@
using AudibleApi.Common;
using ClosedXML.Excel;
using CsvHelper;
using DataLayer;
using Newtonsoft.Json.Linq;
using System;
@@ -23,6 +23,7 @@ public static class SearchEngineCommands
lock (IndexLock)
{
var engine = new SearchEngine();
repairShortIndex(engine);
try
{
return func(engine);
@@ -40,6 +41,59 @@ public static class SearchEngineCommands
}
}
}
/// <summary>Set once the index has been measured against the library, so the check costs one query per run.</summary>
private static bool indexCounted;
/// <summary>
/// Rebuilds an index that holds fewer books than the library does.
/// <para>
/// A book the index never received is not merely unsearchable. Filtering intersects the grid with the
/// query's hits, so a positive term such as <c>Absent</c> cannot return it, while the negated <c>-Absent</c>
/// resolves to "every document in the index" and therefore drops it from the grid - which looks exactly
/// like the negated filter working correctly. Reported as issue #1989.
/// </para>
/// <para>
/// Nothing else notices: the index is only ever written as a whole, and <see cref="tryUpdate"/>
/// deliberately swallows a rebuild that fails so a bad index cannot fail a good scan. A short index
/// therefore stays short until something happens to change the library again.
/// </para>
/// </summary>
private static void repairShortIndex(SearchEngine engine)
{
if (indexCounted)
return;
// before the work, not after: a check that throws must not run on every query for the rest of the session
indexCounted = true;
try
{
var indexed = engine.GetIndexedBookCount();
// no index yet, or one too damaged to read. Both already have their own recovery.
if (indexed < 0)
return;
var expected = DbContexts.GetIndexableBookCount();
// more documents than books is stale rather than harmful: an extra document matches no grid row.
if (indexed >= expected)
return;
Log.Warning("The search index holds {Indexed} of {Expected} books. Rebuilding it: search cannot find the rest, and a negated filter hides them.", indexed, expected);
var failures = fullReIndex(engine);
if (failures > 0)
Log.Error("{Failures} book(s) could not be added to the search index. Search cannot find them, and a negated filter hides them.", failures);
}
catch (Exception ex)
{
// Searching with a short index still beats not searching at all.
Log.Error(ex, "Could not check the search index against the library.");
}
}
#endregion
public static event EventHandler? SearchEngineUpdated;
@@ -92,7 +146,7 @@ public static class SearchEngineCommands
}
}
public static void FullReIndex() => performSafeCommand(fullReIndex);
public static void FullReIndex() => performSafeCommand(e => fullReIndex(e));
public static void FullReIndex(List<LibraryBook> libraryBooks)
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
@@ -147,13 +201,15 @@ public static class SearchEngineCommands
}
}
private static void fullReIndex(SearchEngine engine)
/// <returns>How many books could not be indexed.</returns>
private static int fullReIndex(SearchEngine engine)
{
var library = DbContexts.GetLibrary_Flat_NoTracking();
fullReIndex(engine, library);
return fullReIndex(engine, library);
}
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
/// <returns>How many books could not be indexed.</returns>
private static int fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
=> engine.CreateNewIndex(libraryBooks);
#endregion
}
@@ -0,0 +1,53 @@
using DataLayer;
using Serilog;
using System.Collections.Generic;
using System.Linq;
namespace ApplicationServices;
/// <summary>
/// Answers "is the book you searched for in the trash?".
/// A trashed book is filtered out of the library and out of the search index, so searching for one returns
/// nothing at all and looks exactly like a book that was never imported (issue #1925).
/// </summary>
public static class TrashBinSearch
{
/// <summary>
/// Books in the trash matching <paramref name="searchString"/>, using the same query syntax as the
/// library search so a fielded query means the same thing in both places.
/// </summary>
/// <remarks>
/// Indexes the trash on the spot rather than keeping an index in step with it. Call only once a library
/// search has already come back empty: that happens on Enter, at human speed, so the work is rare.
/// Returns nothing rather than throwing; this only ever powers a hint.
/// </remarks>
public static List<LibraryBook> Search(string? searchString)
{
if (string.IsNullOrWhiteSpace(searchString))
return [];
try
{
//GetDeletedLibraryBooks also returns parents so the trash grid can nest episodes; only the
//genuinely deleted are candidates here.
var trashed = DbContexts.GetDeletedLibraryBooks().Where(lb => lb.IsDeleted).ToList();
if (trashed.Count == 0)
return [];
var searchEngine = new TempSearchEngine();
if (!searchEngine.ReindexSearchEngine(trashed))
return [];
if (searchEngine.GetSearchResultSet(searchString) is not { } results)
return [];
var matchedIds = results.Docs.Select(d => d.ProductId).ToHashSet();
return trashed.Where(lb => matchedIds.Contains(lb.Book.AudibleProductId)).ToList();
}
catch (System.Exception ex)
{
Log.Logger.Error(ex, "Failed to search the trash bin for {searchString}", searchString);
return [];
}
}
}
+116
View File
@@ -5,7 +5,9 @@ using Dinah.Core.Security;
using LibationFileManager;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace AudibleUtilities;
@@ -86,9 +88,121 @@ public class Account : IUpdatable, ILogMasked
}
}
/// <summary>
/// The marketplace this account is registered with: where it logs in, and the only place its tokens can be
/// refreshed. Every account has exactly one.
/// </summary>
[JsonIgnore]
public Locale? Locale => IdentityTokens?.Locale;
private readonly List<string> _additionalLocaleNames = new();
/// <summary>
/// <para>
/// Further marketplaces this same login holds a library in, beyond <see cref="Locale"/>. A title bought while
/// an Amazon address was temporarily set to another country stays in that country's library forever, and only
/// a scan of that marketplace will ever see it.
/// </para>
/// <para>
/// Only marketplace names live here - no credentials. Audible honors one device registration across every
/// marketplace, so these are read with the very tokens <see cref="Locale"/> registered.
/// </para>
/// <para>
/// Written as null rather than <c>[]</c> when empty, so that the settings file of an account with one
/// marketplace - which is nearly all of them - is exactly what it was before this property existed.
/// </para>
/// </summary>
[JsonProperty(PropertyName = "AdditionalLocaleNames", NullValueHandling = NullValueHandling.Ignore)]
private List<string>? _additionalLocaleNames_json
{
get => _additionalLocaleNames.Count == 0 ? null : _additionalLocaleNames;
// 'set' is only used by json deser
set
{
if (value is null)
return;
_additionalLocaleNames.Clear();
foreach (var name in value.Select(canonicalize).OfType<string>())
if (!_additionalLocaleNames.Contains(name) && name != Locale?.Name)
_additionalLocaleNames.Add(name);
}
}
/// <summary>
/// The additional marketplaces, resolved. Names that no longer match a known locale are dropped, as is the
/// registered marketplace: json is applied in document order, so a file listing these before its
/// IdentityTokens would slip a duplicate past the check on the way in, and this marketplace would then be
/// scanned twice.
/// </summary>
[JsonIgnore]
public IReadOnlyList<Locale> AdditionalLocales
=> _additionalLocaleNames
.Select(Localization.Get)
.Where(l => !string.IsNullOrEmpty(l.CountryCode) && l.Name != Locale?.Name)
.ToList();
/// <summary>
/// Every marketplace a scan of this account should read: its own, then any extras. One account is scanned as
/// a unit, so that a title found under one marketplace is never counted absent because another was scanned.
/// </summary>
[JsonIgnore]
public IReadOnlyList<Locale> ScanLocales
=> Locale is null ? AdditionalLocales : new[] { Locale }.Concat(AdditionalLocales).ToList();
/// <summary>True if <paramref name="localeName"/> is this account's own marketplace or one of its extras.</summary>
public bool HasMarketplace(string? localeName)
=> canonicalize(localeName) is string name
&& (name == Locale?.Name || _additionalLocaleNames.Contains(name));
/// <summary>Adds an extra marketplace. No-op if it is already this account's own, or already added.</summary>
public bool AddMarketplace(string? localeName)
{
if (canonicalize(localeName) is not string name || HasMarketplace(name))
return false;
_additionalLocaleNames.Add(name);
update();
return true;
}
public bool RemoveMarketplace(string? localeName)
{
if (canonicalize(localeName) is not string name || !_additionalLocaleNames.Remove(name))
return false;
update();
return true;
}
/// <summary>Replaces the extra marketplaces wholesale. Used by the accounts dialog, which edits a copy.</summary>
public void SetAdditionalMarketplaces(IEnumerable<string?> localeNames)
{
var replacement = (localeNames ?? [])
.Select(canonicalize)
.OfType<string>()
.Where(n => n != Locale?.Name)
.Distinct()
.ToList();
if (replacement.SequenceEqual(_additionalLocaleNames))
return;
_additionalLocaleNames.Clear();
_additionalLocaleNames.AddRange(replacement);
update();
}
/// <summary>
/// Store the locale's canonical name, so that a country code ('de') and an internal name ('germany') cannot
/// end up in the list as two separate marketplaces.
/// </summary>
private static string? canonicalize(string? localeName)
{
var locale = Localization.Get(localeName);
return string.IsNullOrEmpty(locale.CountryCode) ? null : locale.Name;
}
public Account(string accountId)
{
AccountId = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountId, nameof(accountId)).Trim();
@@ -101,6 +215,8 @@ public class Account : IUpdatable, ILogMasked
/// </summary>
public override string ToString() => MaskedLogEntry;
/// <summary>Derived from the fields above, so persisting it would only add a stale copy of them.</summary>
[JsonIgnore]
public string MaskedLogEntry => @$"AccountId={mask(AccountId)}|AccountName={mask(AccountName)}|Locale={Locale?.Name ?? "[empty]"}";
private static string mask(string? str)
=> str is null ? "[null]"
+41 -16
View File
@@ -97,6 +97,7 @@ public class AccountsSettings : IUpdatable
public void Add(Account account)
{
_add(account);
Serilog.Log.Logger.Information("Added Audible account {Account}", account.MaskedLogEntry);
update_no_validate();
}
@@ -108,6 +109,12 @@ public class AccountsSettings : IUpdatable
account.Updated += update;
}
/// <summary>
/// The account that can speak to <paramref name="locale"/> for this login: the one registered with that
/// marketplace, or failing that the one carrying it as an extra marketplace. Callers hand this a book's
/// marketplace and get back the credentials that can license it, which is why extras have to resolve here
/// as well as the registered marketplace does.
/// </summary>
public Account? GetAccount(string accountId, string? locale)
{
if (locale is null)
@@ -116,9 +123,32 @@ public class AccountsSettings : IUpdatable
// AccountId is compared case-insensitively: Audible/library data has been observed to differ
// only by letter case (e.g. a stored id capitalized differently than settings), which caused
// spurious "No account found" failures that blocked every affected book. See issue #1931.
return Accounts.SingleOrDefault(a =>
var registered = Accounts.SingleOrDefault(a =>
a.AccountId.EqualsInsensitive(accountId)
&& a.Locale?.Name == locale);
if (registered is not null)
return registered;
return Accounts.FirstOrDefault(a =>
a.AccountId.EqualsInsensitive(accountId)
&& a.HasMarketplace(locale));
}
/// <summary>
/// The account already scanning <paramref name="localeName"/> for this login, if any. Adding a marketplace,
/// importing an audible-cli file, and probing all need to know whether a marketplace is spoken for - including
/// by a second row for the same login, which is how multiple marketplaces were handled before one account
/// could hold several.
/// </summary>
public Account? GetAccountClaimingMarketplace(string accountId, string? localeName, Account? excluding = null)
{
var name = Localization.Get(localeName).Name;
return Accounts.FirstOrDefault(a =>
!ReferenceEquals(a, excluding)
&& a.AccountId.EqualsInsensitive(accountId)
&& a.HasMarketplace(name));
}
public bool Delete(string accountId, string locale)
@@ -136,28 +166,23 @@ public class AccountsSettings : IUpdatable
account.Updated -= update;
var result = _accounts_backing.Remove(account);
if (result)
Serilog.Log.Logger.Information("Removed Audible account {Account}", account.MaskedLogEntry);
update_no_validate();
return result;
}
/// <summary>
/// No two rows for one login may scan the same marketplace, or a scan would import it twice and a download
/// would have two accounts to choose from. Extra marketplaces count: claiming 'us' as an extra collides with
/// another row registered with 'us' just as surely as two 'us' registrations would.
/// </summary>
private void validate(Account account)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
var accountId = account.AccountId;
var locale = account?.IdentityTokens?.Locale?.Name;
var acct = GetAccount(accountId, locale);
// new: ok
if (acct is null)
return;
// same account instance: ok
if (acct == account)
return;
// same account id + locale, different instance: bad
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
foreach (var locale in account.ScanLocales)
if (GetAccountClaimingMarketplace(account.AccountId, locale.Name, excluding: account) is not null)
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
}
}
+91 -15
View File
@@ -5,13 +5,9 @@ using LibationFileManager;
using Newtonsoft.Json.Linq;
using Polly;
using Polly.Retry;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Globalization;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace AudibleUtilities;
@@ -36,7 +32,11 @@ public class ApiExtended
public static Task<ApiExtended> CreateAsync(Account account)
=> CreateAsync(account, allowInteractiveLogin: true);
public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin)
/// <param name="storeLocale">
/// The marketplace to read, when it is not the one the account is registered with. The account's own tokens
/// are used either way; only the store host changes. Null means the account's own marketplace.
/// </param>
public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin, Locale? storeLocale = null)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
@@ -46,13 +46,15 @@ public class ApiExtended
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
AccountMaskedLogEntry = account.MaskedLogEntry
AccountMaskedLogEntry = account.MaskedLogEntry,
StoreLocaleName = (storeLocale ?? locale).Name
});
var api = await EzApiCreator.GetApiAsync(
locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
account.GetIdentityTokensJsonPath(),
storeLocale);
return new ApiExtended(api);
}
catch (Exception ex)
@@ -102,7 +104,17 @@ public class ApiExtended
LoginChoiceFactory(account),
locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
account.GetIdentityTokensJsonPath(),
Configuration.Instance.GetDeviceRegistrationProfile());
// login happens against the account's own marketplace, so the api it hands back reads that one.
// re-create once the tokens exist if some other marketplace is the one actually wanted.
if (storeLocale is not null && storeLocale.Name != locale.Name)
api = await EzApiCreator.GetApiAsync(
locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath(),
storeLocale);
return new ApiExtended(api);
}
@@ -429,21 +441,21 @@ public class ApiExtended
}
int lastEpNum = -1, dupeCount = 0;
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
foreach (var child in children.OrderBy(i => UsableEpisodeNumber(i)).ThenBy(i => i.PublicationDateTime))
{
string sequence;
if (child.EpisodeNumber is null)
var episodeNumber = UsableEpisodeNumber(child);
if (episodeNumber is null)
{
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
sequence = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
sequence = FallbackSeriesSequence(parent, child);
}
else
{
//multipart episodes may have the same episode number
if (child.EpisodeNumber == lastEpNum)
if (episodeNumber == lastEpNum)
dupeCount++;
else
lastEpNum = child.EpisodeNumber.Value;
lastEpNum = episodeNumber.Value;
sequence = (lastEpNum + dupeCount).ToString();
}
@@ -462,5 +474,69 @@ public class ApiExtended
};
}
}
/// <summary>
/// Nine digits. Big enough for YYYYMMDD-style numbering; too small for unix timestamps,
/// Integer.MAX_VALUE, and the other sentinel integers Audible has sent as episode order (issue #2024).
/// </summary>
private const long MaxPlausibleSeriesOrder = 1_000_000_000;
private static bool IsPlausibleSeriesOrder(long n)
=> n >= 0 && n < MaxPlausibleSeriesOrder;
/// <summary>
/// Audible sometimes serializes a missing episode_number as a sentinel integer (Integer.MAX_VALUE
/// was the one in #2024) instead of omitting the field. Treat any implausibly large value the same way.
/// </summary>
private static int? UsableEpisodeNumber(Item child)
=> child.EpisodeNumber is int n && IsPlausibleSeriesOrder(n) ? n : null;
/// <summary>
/// When episode_number is missing or implausibly large, use relationship sort/sequence
/// or the catalog series sequence. Prefer the parent's child relationship (the historical source),
/// then the child's parent relationship, then any series sequence Audible already attached.
/// </summary>
private static string FallbackSeriesSequence(Item parent, Item child)
{
var fromParent = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin);
if (UsableRelationshipOrder(fromParent) is string parentOrder)
return parentOrder;
var fromChild = child.Relationships?.FirstOrDefault(r => r.Asin == parent.Asin);
if (UsableRelationshipOrder(fromChild) is string childOrder)
return childOrder;
var catalogSequence = child.Series?.FirstOrDefault(s => s.Asin == parent.Asin)?.Sequence;
if (IsUsableOrderString(catalogSequence))
return catalogSequence!;
return "0";
}
private static string? UsableRelationshipOrder(Relationship? relationship)
{
if (relationship is null)
return null;
if (IsUsableSort(relationship.Sort))
return relationship.Sort!.Value.ToString();
if (IsUsableOrderString(relationship.Sequence))
return relationship.Sequence;
return null;
}
private static bool IsUsableSort(long? sort)
=> sort is long n && IsPlausibleSeriesOrder(n);
/// <summary>
/// Bare integers that are too large are sentinels or timestamps. Mixed forms like "1-6" or "2.1"
/// are real series orders and are left alone.
/// </summary>
private static bool IsUsableOrderString(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value == "-1")
return false;
return !long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|| IsPlausibleSeriesOrder(n);
}
#endregion
}
@@ -7,8 +7,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="11.0.3.1" />
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
<PackageReference Include="Google.Protobuf" Version="3.36.1">
<PrivateAssets>all</PrivateAssets>
<PublicAssets>runtime</PublicAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -28,4 +30,8 @@
<DependentUpon>Cdm.cs</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -1,5 +1,3 @@
using AudibleApi.Authentication;
namespace AudibleUtilities;
public static class AuthenticationExceptionHelper
@@ -14,7 +12,7 @@ public static class AuthenticationExceptionHelper
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is AuthenticationRequiredException or LoginFailedException)
if (current is AuthenticationRequiredException)
return true;
if (current is InvalidOperationException { Message: var message }
+20
View File
@@ -20,5 +20,25 @@ public static class Extensions
.Select(p => p.EndDate)
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
?.DateTime;
/// <summary>
/// The book's summary with Audible's HTML markup flattened away, ready to be stored and used
/// as-is. See <see cref="HtmlText.ToPlainText"/>.
/// </summary>
public string PlainTextDescription()
=> HtmlText.ToPlainText(item.Description);
/// <summary>
/// The publisher's copyright line, e.g. "&#169;2024 Bentley Little (P)2025 Journalstone".
/// Only present when the product_details response group was requested, and often null anyway.
/// </summary>
/// <remarks><see cref="Item.Copyright"/> is typed as <see cref="object"/> because Audible has
/// never been observed returning anything but a string or null there. Anything else is
/// discarded rather than stringified, so a shape we do not understand cannot reach a file tag.
/// </remarks>
public string? CopyrightString()
=> item.Copyright is string copyright && !string.IsNullOrWhiteSpace(copyright)
? copyright.Trim()
: null;
}
}
+48
View File
@@ -0,0 +1,48 @@
using System.Net;
using System.Text.RegularExpressions;
namespace AudibleUtilities;
/// <summary>
/// Audible returns a book's summary as HTML. Nothing downstream of the API renders markup - not the
/// library grid, not the exports, and least of all the metadata tags written into an audio file - so
/// the markup is flattened here, on the way in, rather than carried around and stripped at each use.
/// </summary>
public static partial class HtmlText
{
/// <param name="paragraphSeparator">Joins the block-level runs. A single newline matches what
/// Audible itself embeds in the description tags of its .aaxc files.</param>
public static string ToPlainText(string? html, string paragraphSeparator = "\n")
{
if (string.IsNullOrWhiteSpace(html))
return "";
// Not every summary is marked up. Running the unmarked ones through the parser risks a stray
// '<' swallowing the rest of the sentence, and buys nothing.
if (!html.Contains('<'))
return (WebUtility.HtmlDecode(html) ?? html).Trim();
// Replace block-level boundaries with newlines, then strip all remaining tags.
var stripped = StripTags().Replace(BlockBoundary().Replace(html, "\n"), "");
var text = WebUtility.HtmlDecode(stripped) ?? stripped;
var paragraphs = text
.Replace("\r\n", "\n")
.Replace('\r', '\n')
.Split('\n')
.Select(line => line.Trim())
.Where(line => line.Length > 0);
return string.Join(paragraphSeparator, paragraphs);
}
/// <summary>Every tag that ends a run of text. A line break counts as a paragraph break because
/// Audible's summaries mark up paragraphs and nothing finer.</summary>
[GeneratedRegex(@"<\s*br\s*/?\s*>|<\s*/\s*(?:p|div|li|tr|blockquote|h[1-6])\s*>", RegexOptions.IgnoreCase)]
private static partial Regex BlockBoundary();
/// <summary>Matches any HTML/XML tag.</summary>
[GeneratedRegex(@"<[^>]+>")]
private static partial Regex StripTags();
}
+151
View File
@@ -0,0 +1,151 @@
using AudibleApi;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace AudibleUtilities;
public enum MarketplaceProbeOutcome
{
/// <summary>Already scanned: this account's own marketplace, or one it has already been given.</summary>
AlreadyScanned,
/// <summary>Scanned by a different account row for the same login - the pre-existing way to hold two marketplaces.</summary>
ScannedByAnotherAccount,
/// <summary>The marketplace answered, and holds titles.</summary>
TitlesFound,
/// <summary>The marketplace answered, and holds nothing.</summary>
Empty,
/// <summary>The marketplace could not be asked. Says nothing either way about what is there.</summary>
Failed
}
/// <param name="TitleCount">Titles the marketplace reports, or null when it was not asked or did not answer.</param>
/// <param name="ClaimedBy">For <see cref="MarketplaceProbeOutcome.ScannedByAnotherAccount"/>, the account already scanning it.</param>
public record MarketplaceProbeResult(
Locale Locale,
MarketplaceProbeOutcome Outcome,
int? TitleCount = null,
string? ClaimedBy = null,
string? Error = null)
{
/// <summary>Whether adding this marketplace to the account is something the user can choose to do.</summary>
public bool CanAdd => Outcome is MarketplaceProbeOutcome.TitlesFound or MarketplaceProbeOutcome.Empty;
}
/// <summary>
/// <para>
/// Asks each Audible marketplace whether this login holds anything there.
/// </para>
/// <para>
/// 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 will never see it - no error, no warning, the titles are
/// simply absent. One device registration is honored by every marketplace, so the only thing standing between
/// those titles and a scan is knowing which marketplace to look in. That is what this answers.
/// </para>
/// <para>
/// One request per marketplace, in sequence: enough to get a count, and no more traffic or concurrency than a
/// user who clicked a button should generate. Nothing here runs on its own - the accounts dialog asks for it.
/// </para>
/// </summary>
public static class MarketplaceProbe
{
/// <summary>Space between requests. A deliberate trickle rather than a fan-out.</summary>
public static TimeSpan RequestSpacing { get; set; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// The marketplaces worth asking about for this account. Pre-Amazon locales and modern ones are separate
/// worlds with their own logins, so only the account's own kind is probed; asking about the rest would double
/// the traffic to learn nothing.
/// </summary>
public static IReadOnlyList<Locale> CandidateLocales(Account account)
{
var withUsername = account?.Locale?.WithUsername ?? false;
return Localization.Locales
.Where(l => l.WithUsername == withUsername)
.OrderBy(l => l.Name)
.ToList();
}
/// <summary>
/// Probe every candidate marketplace, yielding each result as it arrives so a dialog can fill in a row at a
/// time. Never throws for a single marketplace: one that cannot be reached is reported as such and the rest
/// carry on.
/// </summary>
public static async IAsyncEnumerable<MarketplaceProbeResult> ProbeAsync(
Account account,
AccountsSettings accountsSettings,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
ArgumentValidator.EnsureNotNull(accountsSettings, nameof(accountsSettings));
var first = true;
foreach (var locale in CandidateLocales(account))
{
cancellationToken.ThrowIfCancellationRequested();
if (account.HasMarketplace(locale.Name))
{
yield return new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.AlreadyScanned);
continue;
}
if (accountsSettings.GetAccountClaimingMarketplace(account.AccountId, locale.Name, excluding: account) is { } other)
{
yield return new MarketplaceProbeResult(
locale,
MarketplaceProbeOutcome.ScannedByAnotherAccount,
ClaimedBy: AccountCredentialStatus.FormatAccountLabel(other));
continue;
}
if (!first)
await Task.Delay(RequestSpacing, cancellationToken);
first = false;
yield return await probeOneAsync(account, locale);
}
}
private static async Task<MarketplaceProbeResult> probeOneAsync(Account account, Locale locale)
{
try
{
// no interactive login: the whole premise is that this account's existing tokens already work here
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin: false, storeLocale: locale);
var count = await apiExtended.Api.GetItemsCountAsync(
new LibraryOptions { PurchasedAfter = new DateTime(1970, 1, 1) });
Serilog.Log.Logger.Information(
"Marketplace probe: {LocaleName} reported {TitleCount} titles. {@DebugInfo}",
locale.Name,
count,
new { Account = account.MaskedLogEntry });
// -1 means Audible answered without the count header. it answered, so the marketplace is reachable;
// treat it as reachable-but-unknown rather than claiming there is nothing there
return count switch
{
> 0 => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.TitlesFound, count),
0 => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Empty, 0),
_ => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Empty)
};
}
catch (Exception ex)
{
Serilog.Log.Logger.Information(
ex,
"Marketplace probe: {LocaleName} could not be checked. {@DebugInfo}",
locale.Name,
new { Account = account.MaskedLogEntry });
return new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Failed, Error: ex.Message);
}
}
}
+6
View File
@@ -237,6 +237,12 @@ public partial class Mkb79Auth
return account;
}
/// <summary>
/// The exported file names one marketplace - the one this account is registered with - because that is all
/// the format holds: a single <c>locale_code</c> alongside a single device registration. audible-cli switches
/// marketplaces on its own from those same tokens, so nothing is lost to it. Any additional marketplaces
/// Libation reads for this account are its own bookkeeping and have no slot here.
/// </summary>
public static Mkb79Auth FromAccount(Account account)
=> new()
{
+35 -7
View File
@@ -1,4 +1,4 @@
using System.Linq;
using System;
using System.Threading.Tasks;
namespace AudibleUtilities;
@@ -10,10 +10,37 @@ public enum Mkb79ImportOutcome
InvalidFile,
}
public sealed record Mkb79ImportResult(Mkb79ImportOutcome Outcome, Account? Account = null, string? Message = null);
/// <param name="ClaimedBy">
/// For <see cref="Mkb79ImportOutcome.DuplicateAccount"/>, the account already scanning that marketplace. It may
/// be one registered with it, or one carrying it as an additional marketplace.
/// </param>
public sealed record Mkb79ImportResult(
Mkb79ImportOutcome Outcome,
Account? Account = null,
string? Message = null,
Account? ClaimedBy = null);
public static class Mkb79AuthImporter
{
/// <summary>
/// Why a duplicate import was refused, in the same words everywhere it is refused. Naming the account that
/// already reads the marketplace matters now that it need not be a row registered with it - it may be one
/// reading it as an additional marketplace, which is not obvious from the accounts grid.
/// </summary>
public static string DuplicateMessage(Mkb79ImportResult result)
{
var locale = result.Account?.Locale?.Name ?? "[unknown]";
if (result.ClaimedBy is { } claimedBy && claimedBy.Locale?.Name != locale)
return $"The '{locale}' marketplace is already scanned by the account "
+ $"{AccountCredentialStatus.FormatAccountLabel(claimedBy)}, as an additional marketplace. "
+ "Nothing was imported.";
return "An account with that account id and country already exists."
+ $"{Environment.NewLine}Account ID: {result.Account?.AccountId}"
+ $"{Environment.NewLine}Country: {locale}";
}
/// <summary>
/// Deserialize mkb79/audible-cli JSON, refresh tokens, and add the account if not already present.
/// </summary>
@@ -32,11 +59,12 @@ public static class Mkb79AuthImporter
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
if (persister.AccountsSettings.Accounts.Any(a =>
a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name))
{
return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account);
}
// An mkb79 file names one marketplace, and a marketplace can only be scanned by one account. Ask about
// every claim on it, not just registrations: an existing account may already be reading this marketplace
// as an additional one, in which case importing would scan it twice.
var claimedBy = persister.AccountsSettings.GetAccountClaimingMarketplace(account.AccountId, account.Locale?.Name);
if (claimedBy is not null)
return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account, ClaimedBy: claimedBy);
persister.AccountsSettings.Add(account);
return new Mkb79ImportResult(Mkb79ImportOutcome.Success, account);
@@ -9,11 +9,11 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -35,4 +35,8 @@
<Folder Include="Migrations\" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -0,0 +1,581 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20260826123718_AddBookCopyright")]
partial class AddBookCopyright
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("integer");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("integer");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
b.Property<string>("AudibleProductId")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ContentType")
.HasColumnType("integer");
b.Property<string>("Copyright")
.HasColumnType("text");
b.Property<DateTime?>("DatePublished")
.HasColumnType("timestamp without time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsAbridged")
.HasColumnType("boolean");
b.Property<bool>("IsSpatial")
.HasColumnType("boolean");
b.Property<string>("Language")
.HasColumnType("text");
b.Property<int>("LengthInMinutes")
.HasColumnType("integer");
b.Property<string>("Locale")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PictureId")
.HasColumnType("text");
b.Property<string>("PictureLarge")
.HasColumnType("text");
b.Property<string>("Subtitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<int>("CategoryLadderId")
.HasColumnType("integer");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<int>("ContributorId")
.HasColumnType("integer");
b.Property<int>("Role")
.HasColumnType("integer");
b.Property<byte>("Order")
.HasColumnType("smallint");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
b.Property<string>("AudibleCategoryId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
b.Property<string>("AudibleContributorId")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
{
b.Property<int>("DownloadAttemptFailureId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadAttemptFailureId"));
b.Property<string>("Account")
.IsRequired()
.HasColumnType("text");
b.Property<string>("AudibleProductId")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ConsecutiveFailures")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<long>("LastFailedAtUtcTicks")
.HasColumnType("bigint");
b.Property<string>("Reason")
.HasColumnType("text");
b.Property<long>("RetryAfterUtcTicks")
.HasColumnType("bigint");
b.HasKey("DownloadAttemptFailureId");
b.HasIndex("Account", "AudibleProductId")
.IsUnique();
b.ToTable("DownloadAttemptFailures");
});
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
{
b.Property<int>("DownloadHistoryId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId"));
b.Property<string>("AudibleProductId")
.HasColumnType("text");
b.Property<long>("Bytes")
.HasColumnType("bigint");
b.Property<long>("CompletedAtUtcTicks")
.HasColumnType("bigint");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("boolean");
b.HasKey("DownloadHistoryId");
b.HasIndex("CompletedAtUtcTicks");
b.ToTable("DownloadHistory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("boolean");
b.Property<string>("Account")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("DateAdded")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("timestamp without time zone");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
b.Property<string>("AudibleSeriesId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("integer");
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<string>("Order")
.HasColumnType("text");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("integer");
b1.Property<float>("OverallRating")
.HasColumnType("real");
b1.Property<float>("PerformanceRating")
.HasColumnType("real");
b1.Property<float>("StoryRating")
.HasColumnType("real");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
b1.Property<int>("BookId")
.HasColumnType("integer");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("integer");
b1.Property<int>("BookStatus")
.HasColumnType("integer");
b1.Property<bool>("IsFinished")
.HasColumnType("boolean");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("timestamp without time zone");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("text");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("bigint");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("text");
b1.Property<int?>("PdfStatus")
.HasColumnType("integer");
b1.Property<string>("Tags")
.IsRequired()
.HasColumnType("text");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("integer");
b2.Property<float>("OverallRating")
.HasColumnType("real");
b2.Property<float>("PerformanceRating")
.HasColumnType("real");
b2.Property<float>("StoryRating")
.HasColumnType("real");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating")
.IsRequired();
});
b.Navigation("Rating")
.IsRequired();
b.Navigation("Supplements");
b.Navigation("UserDefinedItem")
.IsRequired();
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddBookCopyright : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Copyright",
table: "Books",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Copyright",
table: "Books");
}
}
}
@@ -17,7 +17,7 @@ namespace DataLayer.Postgres.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("ProductVersion", "10.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -52,6 +52,9 @@ namespace DataLayer.Postgres.Migrations
b.Property<int>("ContentType")
.HasColumnType("integer");
b.Property<string>("Copyright")
.HasColumnType("text");
b.Property<DateTime?>("DatePublished")
.HasColumnType("timestamp without time zone");
@@ -9,11 +9,11 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -31,4 +31,8 @@
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -0,0 +1,560 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20260826123725_AddBookCopyright")]
partial class AddBookCopyright
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<string>("Copyright")
.HasColumnType("TEXT");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<bool>("IsSpatial")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
{
b.Property<int>("DownloadAttemptFailureId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Account")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AudibleProductId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("ConsecutiveFailures")
.HasColumnType("INTEGER");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<long>("LastFailedAtUtcTicks")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.HasColumnType("TEXT");
b.Property<long>("RetryAfterUtcTicks")
.HasColumnType("INTEGER");
b.HasKey("DownloadAttemptFailureId");
b.HasIndex("Account", "AudibleProductId")
.IsUnique();
b.ToTable("DownloadAttemptFailures");
});
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
{
b.Property<int>("DownloadHistoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<long>("Bytes")
.HasColumnType("INTEGER");
b.Property<long>("CompletedAtUtcTicks")
.HasColumnType("INTEGER");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("INTEGER");
b.HasKey("DownloadHistoryId");
b.HasIndex("CompletedAtUtcTicks");
b.ToTable("DownloadHistory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("TEXT");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<bool>("IsFinished")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("TEXT");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("INTEGER");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.IsRequired()
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating")
.IsRequired();
});
b.Navigation("Rating")
.IsRequired();
b.Navigation("Supplements");
b.Navigation("UserDefinedItem")
.IsRequired();
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddBookCopyright : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Copyright",
table: "Books",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Copyright",
table: "Books");
}
}
}
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
@@ -45,6 +45,9 @@ namespace DataLayer.Migrations
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<string>("Copyright")
.HasColumnType("TEXT");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
+10 -6
View File
@@ -11,15 +11,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="10.2.4.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.2.4.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PackageReference Include="Dinah.Core" Version="11.0.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="11.0.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -35,6 +35,10 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
+50 -4
View File
@@ -52,6 +52,9 @@ public class Book
public bool IsSpatial { get; private set; }
public DateTime? DatePublished { get; private set; }
public string? Language { get; private set; }
/// <summary>The publisher's copyright line. Only returned by the API's product_details response
/// group, and null for plenty of titles even then.</summary>
public string? Copyright { get; private set; }
// is owned, not optional 1:1
public UserDefinedItem UserDefinedItem { get; private set; }
@@ -119,6 +122,13 @@ public class Book
public void UpdateLengthInMinutes(int lengthInMinutes)
=> LengthInMinutes = lengthInMinutes;
public void UpdateDescription(string? description)
{
// don't overwrite with default values
if (!string.IsNullOrWhiteSpace(description))
Description = description.Trim();
}
#region contributors, authors, narrators
internal HashSet<BookContributor> ContributorsLink { get; private set; }
@@ -232,31 +242,67 @@ public class Book
private readonly HashSet<Supplement>? _supplements;
public IEnumerable<Supplement> Supplements => _supplements?.ToList() ?? [];
public void AddSupplementDownloadUrl(string url)
/// <summary>
/// Records where Audible says this book's supplement can be found, replacing anything recorded before.
/// <para>
/// Audible reports one supplement per title, so this replaces rather than accumulates. It used to add, and
/// guarded against duplicates by comparing the incoming url to itself - which came to the same thing only
/// because the always-true comparison meant "this book already has a supplement", so a url that changed was
/// silently ignored.
/// </para>
/// </summary>
public void SetSupplementDownloadUrl(string url)
{
// supplements are owned by Book, so no need to Load():
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner.
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
if (_supplements?.Any(s => url.EqualsInsensitive(url)) is true)
if (_supplements is null)
return;
_supplements?.Add(new Supplement(this, url));
if (_supplements.Count == 1 && _supplements.First().Url.EqualsInsensitive(url))
return;
_supplements.Clear();
_supplements.Add(new Supplement(this, url));
// Only when the book had no supplement at all. A url Audible has changed says nothing about whether the
// file behind it is already downloaded.
UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated;
}
/// <summary>
/// Forgets that this book has a supplement, for when Audible says it has none after all.
/// <para>
/// The status goes back to null rather than to NotLiberated, because null is what "this title has no PDF"
/// is recorded as everywhere else: it is the state of a book that never had one, and it is what stops the
/// grid from showing a PDF marker and the counts from expecting one.
/// </para>
/// </summary>
/// <returns>Whether anything was removed.</returns>
public bool RemoveSupplements()
{
if (_supplements is null || _supplements.Count == 0)
return false;
_supplements.Clear();
UserDefinedItem.PdfStatus = null;
return true;
}
#endregion
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language)
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language, string? copyright = null)
{
// don't overwrite with default values
IsAbridged |= isAbridged;
IsSpatial = isSpatial ?? IsSpatial;
DatePublished = datePublished ?? DatePublished;
Language = language?.Trim().FirstCharToUpper() ?? Language;
Copyright = copyright?.Trim() ?? Copyright;
}
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
+4 -3
View File
@@ -16,11 +16,12 @@ public class Contributor
// id.s
// ----
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
// the name in the path is decorative; the id is what resolves it. Both of these reach the same page:
// https://www.audible.com/author/Robert-Bevan/B0EXAMPLE1 == https://www.audible.com/author/B0EXAMPLE1
// goes to summary page
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
// at bottom "See all titles by Robert Bevan" goes to https://www.audible.com/search?searchAuthor=Robert+Bevan
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
// all narrators have no id: https://www.audible.com/search?searchNarrator=Robert+Bevan
internal int ContributorId { get; private set; }
public string Name { get; private set; }
+2 -1
View File
@@ -89,6 +89,7 @@ public class MockLibraryBook : LibraryBook
bool isAbridged = false,
bool isSpatial = false,
string language = "English",
string? copyright = null,
LiberatedStatus bookStatus = LiberatedStatus.Liberated,
LiberatedStatus? pdfStatus = null,
AudioFormat? lastDlFormat = null,
@@ -113,7 +114,7 @@ public class MockLibraryBook : LibraryBook
book.UserDefinedItem.PdfStatus = pdfStatus;
book.UserDefinedItem.BookStatus = bookStatus;
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language);
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language, copyright);
return new MockLibraryBook(
book,
@@ -69,6 +69,17 @@ public static class LibraryBookQueries
.getLibrary()
.ToList();
/// <summary>
/// How many books a full search re-index writes: the same rows as
/// <see cref="GetLibrary_Flat_NoTracking"/> without the podcast parents, which are stripped before
/// indexing. A row count only; no entities are loaded, so this is cheap enough to check the index against.
/// </summary>
public int GetIndexableBookCount()
=> context
.LibraryBooks
.AsNoTracking()
.Count(lb => !lb.IsDeleted && lb.Book.ContentType != ContentType.Parent);
/// <summary>Counts <see cref="LibraryBook"/> rows by <see cref="LibraryBook.IsDeleted"/> (no related entities loaded).</summary>
public (int NotInTrash, int InTrash) GetLibraryBookCountsByTrashFlag()
{
+52 -4
View File
@@ -124,7 +124,7 @@ public class BookImporter : ItemsImporterBase
new AudibleProductId(item.ProductId),
item.Title,
item.Subtitle,
item.Description,
item.PlainTextDescription(),
item.LengthInMinutes,
contentType,
authors,
@@ -157,12 +157,54 @@ public class BookImporter : ItemsImporterBase
book.ReplacePublisher(publisher);
}
if (item.PdfUrl is not null)
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
syncSupplement(item, book);
return book;
}
/// <summary>
/// Brings a book's stored description into line with what this scan says about it.
/// <para>
/// The description used to be stored exactly as Audible sends it, which is HTML. It is flattened on the
/// way in now, so every book imported before that change is still holding markup and needs rewriting -
/// hence updating a description at all, which import never used to do.
/// </para>
/// <para>
/// A scan that reports no description leaves the stored one alone. Episodes are imported from the
/// catalog, and "no summary" there can just as easily mean the response group was not asked for.
/// </para>
/// </summary>
internal static void syncDescription(Item item, Book book)
=> book.UpdateDescription(item.PlainTextDescription());
/// <summary>
/// Brings a book's supplement into line with what this scan says about it.
/// <para>
/// Only new books used to get this far, so a title that gained a PDF after it was first imported never got
/// one, and a title that lost its PDF went on claiming one - and, until issue #1973, went on being asked
/// for on every run.
/// </para>
/// <para>
/// A supplement is dropped only when Audible says outright that no supplement url is available. A missing
/// url says nothing on its own: episodes are imported from the catalog, which is never asked for
/// <c>pdf_url</c>, so there "no url" means "not asked" rather than "none exists". A PDF already downloaded
/// is left alone either way, because the file is on disk and the library should go on saying so.
/// </para>
/// </summary>
internal static void syncSupplement(Item item, Book book)
{
if (item.PdfUrl is not null)
book.SetSupplementDownloadUrl(item.PdfUrl.ToString());
else if (item.IsPdfUrlAvailable is false
&& book.UserDefinedItem.PdfStatus is not LiberatedStatus.Liberated
&& book.RemoveSupplements())
{
Serilog.Log.Logger.Information(
"Audible no longer offers a supplement for {audibleProductId}, so Libation has stopped expecting one.",
book.AudibleProductId);
}
}
private void updateBook(ImportItem importItem, Book book)
{
var item = importItem.DtoItem;
@@ -179,6 +221,8 @@ public class BookImporter : ItemsImporterBase
// Update the book titles, since formatting can change
book.UpdateTitle(item.Title, item.Subtitle);
syncDescription(item, book);
// set/update book-specific info which may have changed
if (item.PictureId is not null)
book.PictureId = item.PictureId;
@@ -193,7 +237,11 @@ public class BookImporter : ItemsImporterBase
// updateBook must update language on books which were imported before the migration which added language.
// 2025-07-30
// updateBook must update isSpatial on books which were imported before the migration which added isSpatial.
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language);
// 2026-08-26
// updateBook must update copyright on books which were imported before the migration which added copyright.
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language, item.CopyrightString());
syncSupplement(item, book);
book.UpdateProductRating(
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
@@ -18,4 +18,8 @@
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -0,0 +1,4 @@
// ApplicationServices.Tests rather than a DtoImporterService.Tests of its own: this project has no test
// assembly, and ApplicationServices references it, so its tests can already see the importers. A literal
// because the dependency runs the other way, leaving nothing here to take the name from.
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ApplicationServices.Tests")]
+65 -18
View File
@@ -7,8 +7,10 @@ using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
using FileManager;
using LibationFileManager;
using LibationFileManager.Templates;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
@@ -16,17 +18,18 @@ using System.Threading.Tasks;
namespace FileLiberator;
public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptBook>
public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptBook>, ILicensedDownload
{
public override string Name => "Download & Decrypt";
private CancellationTokenSource? cancellationTokenSource;
private AudiobookDownloadBase? abDownloader;
/// <summary>
/// Optional override to supply license info directly instead of querying the api based on Configuration options
/// </summary>
/// <inheritdoc/>
public DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
/// <inheritdoc/>
public DownloadOptions.LicenseInfo? ObtainedLicense { get; private set; }
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.AudioExists;
protected override bool RecordsAttemptFailures => true;
@@ -41,6 +44,7 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
OnBegin(libraryBook);
cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;
ObtainedLicense = null;
try
{
@@ -54,6 +58,11 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
//Processable instances are reusable, so don't set LicenseInfo
//override from within a DownloadDecryptBook instance.
var license = LicenseInfo ?? await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration, cancellationToken);
// Published before the download begins so the supplement step gets it even from a run that fails
// later on: what it needs is the license, and this one is already granted.
ObtainedLicense = license;
using var downloadOptions = DownloadOptions.BuildDownloadOptions(libraryBook, Configuration, license);
var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken);
@@ -240,31 +249,69 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
#endregion
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
FillMissingTags(tags, options.LibraryBook.Book, options.LibraryBookDto, options.ContentMetadata.ContentReference, options.DrmType);
}
/// <summary>Audible's own format for <c>rldt</c>, which Libation matches when it supplies the tag itself.</summary>
private const string ReleaseDateFormat = "dd-MMM-yyyy";
/// <summary>
/// Audible's ADRM (.aaxc) downloads arrive with most of these tags already written; its Widevine
/// (DASH) downloads arrive with almost none of them, so the library's own data has to stand in.
/// </summary>
internal static void FillMissingTags(Mpeg4Lib.MetadataItems tags, Book book, LibraryBookDto dto, ContentReference contentReference, DrmType drmType)
{
tags.Title ??= dto.TitleWithSubtitle;
tags.Album ??= tags.Title;
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
tags.Artist ??= string.Join("; ", book.Authors.Select(a => a.Name));
tags.AlbumArtists ??= tags.Artist;
tags.Genres = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
tags.Comment ??= options.LibraryBook.Book.Description;
tags.Genres = string.Join(", ", book.LowestCategoryNames());
tags.ProductID ??= contentReference.Sku;
tags.Comment ??= book.Description;
tags.LongDescription ??= tags.Comment;
tags.Publisher ??= options.LibraryBook.Book.Publisher;
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
tags.Asin = options.LibraryBook.Book.AudibleProductId;
tags.Acr = options.ContentMetadata.ContentReference.Acr;
tags.Version = options.ContentMetadata.ContentReference.Version;
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
//Only the .aaxc files carry a copyright line of their own. AaxcDownloadConvertBase's fixup
//normalizes "(P)" to "℗" after this runs, so the catalog's wording lands the same way.
tags.Copyright ??= book.Copyright;
tags.Publisher ??= book.Publisher;
tags.Narrator ??= string.Join("; ", book.Narrators.Select(n => n.Name));
tags.Asin = book.AudibleProductId;
tags.Acr = contentReference.Acr;
tags.Version = contentReference.Version;
if (book.DatePublished is DateTime pubDate)
{
tags.Year ??= pubDate.Year.ToString();
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
tags.ReleaseDate ??= pubDate.ToString(ReleaseDateFormat);
//Audible's .aaxc files frequently carry 01-Jan-2000 where the real release date belongs.
//The file's own tag is otherwise the better source - it is what Audible shipped with this
//particular recording - so it still wins everywhere except for that one placeholder value.
//
//Only the exact sentinel is overridden, and only when the catalog disagrees with it, so a
//title genuinely released on 01-Jan-2000 comes out the same either way. Two broader rules
//were considered and rejected: always preferring the catalog date would discard Audible's
//date on re-releases the catalog dates wrong, and treating any year below some floor as
//bogus has no defensible floor, since real recordings predate 2000.
if (IsPlaceholderReleaseDate(tags.ReleaseDate) && pubDate.Date != PlaceholderReleaseDate)
{
tags.ReleaseDate = pubDate.ToString(ReleaseDateFormat);
//A file carrying the placeholder rldt carries the matching placeholder year.
if (tags.Year == PlaceholderReleaseDate.Year.ToString())
tags.Year = pubDate.Year.ToString();
}
}
const string tagDomain = "org.libation";
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ACR", tags.Acr);
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_DRM_TYPE", options.DrmType.ToString());
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_LOCALE", options.LibraryBook.Book.Locale);
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_DRM_TYPE", drmType.ToString());
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_LOCALE", book.Locale);
}
private static readonly DateTime PlaceholderReleaseDate = new(2000, 1, 1);
internal static bool IsPlaceholderReleaseDate(string? releaseDate)
=> DateTime.TryParse(releaseDate, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
&& parsed.Date == PlaceholderReleaseDate;
private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e)
{
if (Configuration.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader)
@@ -47,10 +47,12 @@ public static class DownloadFailureClassifier
/// <summary>
/// Audible attaches a rejection reason per validation type it ran. <c>GenericError</c> is Audible
/// declining to say why, which in practice means an outage or throttling rather than a decision about the
/// title; the GUI already reads it that way when it chooses which guidance to offer. Anything else names
/// an eligibility problem with the account or the title, which will not change within the hour. Saying
/// nothing at all is also treated as an outage: a refusal with no stated reason is not a settled one.
/// declining to say why, which in practice means an outage rather than a decision about the title;
/// the GUI already reads it that way when it chooses which guidance to offer. Explicit
/// <c>CustomerThrottled</c> is a license denial the UI names separately; backoff still treats it as
/// a settled refusal. Anything else names an eligibility problem with the account or the title,
/// which will not change within the hour. Saying nothing at all is also treated as an outage: a
/// refusal with no stated reason is not a settled one.
/// </summary>
private static DownloadFailureDiagnosis ClassifyLicenseDenial(ContentLicenseDeniedException ex)
{
+51 -20
View File
@@ -25,10 +25,17 @@ public partial class DownloadOptions
public static async Task<LicenseInfo> GetDownloadLicenseAsync(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
{
var license = await ChooseContent(api, libraryBook, config, token);
// Only the audiobook download needs this. A granted license always carries it, and a supplement is
// fetched from the same license without ever looking at it, so the requirement belongs here rather
// than in LicenseInfo.
if (license.ContentMetadata is not ContentMetadata contentMetadata)
throw new InvalidDataException($"Content license contains no content metadata for asin: [{libraryBook.Book.AudibleProductId}]");
Serilog.Log.Logger.Debug("Content License {@License}", new
{
license.DrmType,
license.ContentMetadata.ContentReference
contentMetadata.ContentReference
});
token.ThrowIfCancellationRequested();
@@ -40,8 +47,8 @@ public partial class DownloadOptions
var metadata = await api.GetContentMetadataAsync(
libraryBook.Book.AudibleProductId,
license.DrmType,
license.ContentMetadata.ContentReference.Acr,
license.ContentMetadata.ContentReference.FileVersion);
contentMetadata.ContentReference.Acr,
contentMetadata.ContentReference.FileVersion);
if (metadata is null)
{
@@ -49,19 +56,19 @@ public partial class DownloadOptions
{
libraryBook.Book.AudibleProductId,
license.DrmType,
license.ContentMetadata.ContentReference.Acr,
license.ContentMetadata.ContentReference.FileVersion
contentMetadata.ContentReference.Acr,
contentMetadata.ContentReference.FileVersion
});
}
else if (metadata.ContentReference != license.ContentMetadata.ContentReference)
else if (metadata.ContentReference != contentMetadata.ContentReference)
{
Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {DrmType}. {@Metadata}. {@License} ",
license.DrmType,
metadata.ContentReference,
license.ContentMetadata.ContentReference);
contentMetadata.ContentReference);
}
else
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
contentMetadata.ChapterInfo = metadata.ChapterInfo;
token.ThrowIfCancellationRequested();
return license;
@@ -70,25 +77,29 @@ public partial class DownloadOptions
public class LicenseInfo
{
public DrmType DrmType { get; set; }
public ContentMetadata ContentMetadata { get; set; }
public ContentMetadata? ContentMetadata { get; set; }
public KeyData[]? DecryptionKeys { get; set; }
/// <summary>
/// Where Audible says this title's supplement can be fetched from, or null when it says the title has
/// none. The license request asks for it (<c>response_groups</c> includes <c>pdf_url</c>), so carrying
/// it here is what lets one license serve both the audiobook and the PDF.
/// </summary>
public string? PdfUrl { get; set; }
[JsonConstructor]
private LicenseInfo()
{
ContentMetadata = null!;
}
private LicenseInfo() { }
public static LicenseInfo Create(ContentLicense license, IEnumerable<KeyData>? keys = null)
{
ArgumentNullException.ThrowIfNull(license, nameof(license));
ArgumentNullException.ThrowIfNull(license.ContentMetadata, nameof(license.ContentMetadata));
return new LicenseInfo
{
DrmType = license.DrmType,
ContentMetadata = license.ContentMetadata,
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher)
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher),
PdfUrl = license.PdfUrl
};
}
@@ -96,6 +107,23 @@ public partial class DownloadOptions
=> voucher?.Key is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
}
/// <summary>
/// Requests a plain content license, without the Widevine negotiation and the chapter-metadata correction
/// that <see cref="GetDownloadLicenseAsync"/> layers on for the audiobook download.
/// <para>
/// A supplement needs nothing from a license but its <see cref="LicenseInfo.PdfUrl"/>, and asking for the
/// same license the audiobook download asks for is what makes one license usable by both steps.
/// </para>
/// </summary>
public static async Task<LicenseInfo> GetSupplementLicenseAsync(Api api, LibraryBook libraryBook, Configuration config)
{
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, ChooseQuality(config));
return LicenseInfo.Create(license);
}
private static DownloadQuality ChooseQuality(Configuration config)
=> config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
{
Serilog.Log.Logger.Information("Download Settings {@Settings}", new
@@ -107,7 +135,7 @@ public partial class DownloadOptions
config.SpatialAudioCodec
});
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
var dlQuality = ChooseQuality(config);
bool canUseWidevine = api.SupportsWidevine();
if (!config.UseWidevine || !canUseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
@@ -117,7 +145,7 @@ public partial class DownloadOptions
if (canUseWidevine)
Serilog.Log.Logger.Warning("Unable to get a Widevine CDM. Falling back to ADRM.");
else
Serilog.Log.Logger.Warning("Account {account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask());
Serilog.Log.Logger.Warning("Account {account} is not registered as an android device, so content will not be downloaded with Widevine DRM. The iPhone registration cannot use Widevine. To use Widevine, remove and re-add the account while registered as Android.", libraryBook.Account.ToMask());
}
token.ThrowIfCancellationRequested();
@@ -181,9 +209,12 @@ public partial class DownloadOptions
/// </summary>
public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
{
if (licInfo.ContentMetadata?.ChapterInfo is not ChapterInfo chapterInfo)
throw new InvalidDataException($"Content license contains no chapter info for asin: [{libraryBook.Book.AudibleProductId}]");
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
= flattenChapters(chapterInfo.Chapters, titleConcat)
.OrderBy(c => c.StartOffsetMs)
.ToList();
@@ -191,7 +222,7 @@ public partial class DownloadOptions
combineCredits(chapters);
if (config.StripAudibleBrandAudio)
stripBranding(chapters, licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs, licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
stripBranding(chapters, chapterInfo.BrandIntroDurationMs, chapterInfo.BrandOutroDurationMs);
if (config.SplitFilesByChapter)
combineShortChapters(chapters, config.MinimumFileDuration * 1000);
@@ -199,7 +230,7 @@ public partial class DownloadOptions
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
{
ChapterInfo = new Mpeg4Lib.ChapterInfo(TimeSpan.FromMilliseconds(chapters[0].StartOffsetMs)),
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
RuntimeLength = TimeSpan.FromMilliseconds(chapterInfo.RuntimeLengthMs),
};
//Build AAXClean.ChapterInfo
+5 -2
View File
@@ -56,13 +56,16 @@ public partial class DownloadOptions : IDownloadOptions, IDisposable
ArgumentValidator.EnsureNotNull(licInfo, nameof(licInfo));
if (licInfo.ContentMetadata.ContentUrl?.OfflineUrl is not string licUrl)
if (licInfo.ContentMetadata is not AudibleApi.Common.ContentMetadata contentMetadata)
throw new InvalidDataException("Content license doesn't contain content metadata");
if (contentMetadata.ContentUrl?.OfflineUrl is not string licUrl)
throw new InvalidDataException("Content license doesn't contain an offline Url");
DownloadUrl = licUrl;
DecryptionKeys = licInfo.DecryptionKeys;
DrmType = licInfo.DrmType;
ContentMetadata = licInfo.ContentMetadata;
ContentMetadata = contentMetadata;
InputType
= licInfo.DrmType is AudibleApi.Common.DrmType.Widevine ? AAXClean.FileType.Dash
: licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 4 && licInfo.DecryptionKeys[0].KeyPart2 is null ? AAXClean.FileType.Aax
+123 -21
View File
@@ -13,44 +13,80 @@ using System.Threading.Tasks;
namespace FileLiberator;
public class DownloadPdf : Processable, IProcessable<DownloadPdf>
public class DownloadPdf : Processable, IProcessable<DownloadPdf>, ILicensedDownload
{
public override string Name => "Download Pdf";
/// <summary>
/// A supplement is delivered through a content license, the same request the audiobook download makes, so
/// a refusal of one is a refusal of the other and belongs in the record that keeps a scheduled run from
/// asking again.
/// </summary>
protected override bool RecordsAttemptFailures => true;
/// <inheritdoc/>
public DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
/// <inheritdoc/>
public DownloadOptions.LicenseInfo? ObtainedLicense { get; private set; }
/// <summary>
/// A title Audible has a supplement for, whose supplement is not downloaded and was not written off. The
/// status check matches <see cref="DataLayer.LibraryBookQueries.NeedsPdfDownload"/>, and treats
/// <see cref="LiberatedStatus.Error"/> as "don't retry" exactly as the audiobook download does through
/// <c>AudioExists</c>.
/// </summary>
public override bool Validate(LibraryBook libraryBook)
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
&& !libraryBook.Book.PdfExists;
&& libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
OnBegin(libraryBook);
string? createdDirectory = null;
ObtainedLicense = null;
try
{
// A license this run already holds is used as it is. Requesting another would repeat the request
// the audiobook download just made, which is what filled the log in issue #1973.
var carriedLicense = LicenseInfo;
var license = carriedLicense ?? await requestLicenseAsync(libraryBook);
ObtainedLicense = license;
if (license.PdfUrl is not string downloadUrl)
return await noSupplementAvailableAsync(libraryBook);
var proposedDownloadFilePath = GetProposedDownloadFilePath(libraryBook);
createdDirectory = createDirectoryFor(proposedDownloadFilePath);
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
var result = verifyDownload(actualDownloadedFilePath);
if (result.IsSuccess)
var result = await downloadAndVerifyAsync(libraryBook, downloadUrl, proposedDownloadFilePath);
// Audible's download links are signed and expire, and a carried license was granted before an
// audiobook download that may have taken hours. One fresh license is worth trying before the
// title is reported as failed; a license requested moments ago would only fail the same way.
if (!result.IsSuccess && carriedLicense is not null)
{
OnFileCreated(libraryBook, actualDownloadedFilePath);
SetFileTime(libraryBook, actualDownloadedFilePath);
if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir)
SetDirectoryTime(libraryBook, outputDir);
}
else
{
// Keeping it would leave a file in the library that is not the supplement, under a name that
// says it is, and would stop the folder cleanup below from running.
FileUtility.SaferDelete(actualDownloadedFilePath);
Serilog.Log.Logger.Information(
"Requesting a new license to fetch the supplement of {libraryBook}: the one from its audiobook download did not work.",
libraryBook.LogFriendly());
license = await requestLicenseAsync(libraryBook);
ObtainedLicense = license;
if (license.PdfUrl is not string freshDownloadUrl)
return await noSupplementAvailableAsync(libraryBook);
result = await downloadAndVerifyAsync(libraryBook, freshDownloadUrl, proposedDownloadFilePath);
}
await libraryBook.UpdatePdfStatusAsync(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
return result;
}
catch (Exception ex)
// Refusals are left to reach ProcessSingleAsync, which records them for every step alike. Everything
// else is still swallowed here, which is what stopped a missing PDF from taking the app down with it.
catch (Exception ex) when (DownloadFailureClassifier.Classify(ex) is null)
{
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
@@ -66,6 +102,75 @@ public class DownloadPdf : Processable, IProcessable<DownloadPdf>
}
}
private async Task<DownloadOptions.LicenseInfo> requestLicenseAsync(LibraryBook libraryBook)
{
var api = await libraryBook.GetApiAsync();
return await DownloadOptions.GetSupplementLicenseAsync(api, libraryBook, Configuration);
}
/// <summary>
/// Audible granted a license and it carried no supplement link, which is Audible saying this title has no
/// PDF to give. Written off the way the audiobook download writes off a title it should not attempt again:
/// <see cref="LiberatedStatus.Error"/> means "don't retry" for a supplement exactly as it does for audio,
/// and the same controls - a forced or named <c>liberate</c> run, Mark PDF as Download Pending - set it back.
/// <para>
/// Reported as a completed step and not as a failure. Nothing went wrong and nothing is left to attempt, and
/// a failure here would put the queue's Abort / Retry / Ignore question to the user about a book whose audio
/// is fine.
/// </para>
/// </summary>
private async Task<StatusHandler> noSupplementAvailableAsync(LibraryBook libraryBook)
{
const string explanation = "Audible has no PDF for this title, although the library listing says it has one. "
+ "Its PDF will not be asked for again; mark the PDF 'Download Pending' to try once more.";
Serilog.Log.Logger.Information(
"Audible granted a license for {libraryBook} that carried no supplement link, so it has no PDF to download. {explanation}",
libraryBook.LogFriendly(),
explanation);
OnStatusUpdate(explanation);
await libraryBook.UpdatePdfStatusAsync(LiberatedStatus.Error);
return new StatusHandler();
}
/// <summary>
/// Fetches the supplement and keeps it only if what came back is one. A transport failure is reported as a
/// failed status rather than thrown, so that a license worth retrying still gets retried.
/// </summary>
private async Task<StatusHandler> downloadAndVerifyAsync(LibraryBook libraryBook, string downloadUrl, string proposedDownloadFilePath)
{
string actualDownloadedFilePath;
try
{
actualDownloadedFilePath = await downloadPdfAsync(downloadUrl, proposedDownloadFilePath);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
return new StatusHandler { $"Error downloading PDF. See log for details. Error summary: {ex.Message}" };
}
var result = verifyDownload(actualDownloadedFilePath);
if (result.IsSuccess)
{
OnFileCreated(libraryBook, actualDownloadedFilePath);
SetFileTime(libraryBook, actualDownloadedFilePath);
if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir)
SetDirectoryTime(libraryBook, outputDir);
}
else
{
// Keeping it would leave a file in the library that is not the supplement, under a name that
// says it is, and would stop the folder cleanup in ProcessAsync from running.
FileUtility.SaferDelete(actualDownloadedFilePath);
}
return result;
}
/// <summary>The directory this run had to create, or null when it was already there.</summary>
private static string? createDirectoryFor(string filePath)
{
@@ -113,17 +218,14 @@ public class DownloadPdf : Processable, IProcessable<DownloadPdf>
= Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId))
?? AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook, Configuration);
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, destinationDir, extension);
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, destinationDir, extension, returnFirstExisting: Configuration.OverwriteExisting);
}
private static string? getdownloadUrl(LibraryBook libraryBook)
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
private async Task<string> downloadPdfAsync(string downloadUrl, string proposedDownloadFilePath)
{
var api = await libraryBook.GetApiAsync();
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
var client = new HttpClient();
@@ -26,4 +26,8 @@
</Compile>
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
+25
View File
@@ -0,0 +1,25 @@
namespace FileLiberator;
/// <summary>
/// A step that downloads something Audible delivers through a content license: the audiobook, and the
/// supplement whose link the same license carries.
/// <para>
/// One license serves every such step for a title. The request is identical - <c>response_groups</c> asks for
/// <c>pdf_url</c> alongside the content reference - so a run holding a license must hand it on rather than ask
/// for another, which would be a second refusal to record for a title already refused once.
/// </para>
/// </summary>
public interface ILicensedDownload
{
/// <summary>
/// Optional override to supply license info directly instead of querying the api based on Configuration options
/// </summary>
DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
/// <summary>
/// The license this step ended up using, whether supplied or requested, so the next step for the same title
/// can reuse it. Null until a license has been obtained, and reset at the start of every attempt: a
/// Processable instance is reused across books, so a license left behind belongs to a different title.
/// </summary>
DownloadOptions.LicenseInfo? ObtainedLicense { get; }
}
+4 -2
View File
@@ -45,8 +45,10 @@ public abstract class Processable
/// <summary>
/// Whether a refusal from Audible during this step should be remembered, so a scheduled run stops asking
/// for the same license every time. Only the audiobook download does: it is the request Audible refuses,
/// and the record gates that same request.
/// for the same license every time. True for the steps that request a content license - the audiobook
/// download and the supplement download, which make the same request - because that request is the one
/// Audible refuses and the one the record gates. A step that only works on files already on disk has
/// nothing to record.
/// </summary>
protected virtual bool RecordsAttemptFailures => false;
+10 -4
View File
@@ -1,4 +1,5 @@
using AudibleUtilities;
using AudibleApi;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
using LibationFileManager;
@@ -21,18 +22,23 @@ public static class UtilityExtensions
account: libraryBook.Account.ToMask()
);
public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; }
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
{
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
var account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale)
?? throw new InvalidCredentialException($"No account found for '{libraryBook.Account}' and locale '{libraryBook.Book.Locale}'");
var apiExtended = await ApiExtended.CreateAsync(account);
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin: true, storeLocale: libraryBook.StoreLocale());
return apiExtended.Api;
}
/// <summary>
/// Marketplace a download or license request must speak to: the one the book was scanned from, which is not
/// always the account's home store. See issue #2020.
/// </summary>
internal static Locale StoreLocale(this LibraryBook libraryBook)
=> Localization.Get(libraryBook.Book.Locale);
public static bool SupportsWidevine(this AudibleApi.Api api)
{
//TODO: Expose Api's identity maintainer directly instead of using reflection.
+73 -16
View File
@@ -69,19 +69,33 @@ public class BackgroundFileSystem : IDisposable
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
}
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
try
{
IncludeSubdirectories = true,
EnableRaisingEvents = true
};
fileSystemWatcher.Created += FileSystemWatcher_Changed;
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
fileSystemWatcher.Error += FileSystemWatcher_Error;
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
{
IncludeSubdirectories = true,
EnableRaisingEvents = true
};
fileSystemWatcher.Created += FileSystemWatcher_Changed;
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
fileSystemWatcher.Error += FileSystemWatcher_Error;
backgroundScanner = new Task(BackgroundScanner);
backgroundScanner.Start();
backgroundScanner = new Task(BackgroundScanner);
backgroundScanner.Start();
}
// Watching a directory fails for the same reasons reading one does, and a removable drive can be pulled
// between the listing above and this. Constructing this type happens inside the static initializer that
// builds the Books file cache, and the runtime caches a failed initializer for the life of the process:
// one throw here would be rethrown at every later reader of the Books directory. Give up the live
// updates instead. Dropping RootDirectory has the owner rebuild this once the directory works again.
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Could not watch a directory for changes, so its file cache was abandoned: {@DebugText}", new { path = (string?)RootDirectory });
Stop();
RootDirectory = null;
}
}
private void Stop()
{
@@ -107,7 +121,17 @@ public class BackgroundFileSystem : IDisposable
catch (ObjectDisposedException) { }
//Wait for background scanner to terminate before reinitializing.
backgroundScanner?.Wait();
try
{
backgroundScanner?.Wait();
}
// A scanner that died still has to be waited on, but its failure is not this caller's to raise: Stop() is
// reached from Refresh() and from Dispose(), neither of which has anything to do with whatever went wrong,
// and both of which are about to replace the scanner anyway.
catch (AggregateException ex)
{
Serilog.Log.Logger.Error(ex, "The file cache's background scanner had already stopped on an error.");
}
//Dispose of directoryChangesEvents after backgroundScanner exists. Clear the field first so a late event
//has nothing to add to. CompleteAdding has to have happened while it was still set, or the scanner would
@@ -146,8 +170,19 @@ public class BackgroundFileSystem : IDisposable
{
while (directoryChangesEvents?.TryTake(out var change, -1) is true)
{
lock (fsCacheLocker)
UpdateLocalCache(change);
try
{
lock (fsCacheLocker)
UpdateLocalCache(change);
}
// One path this cannot read must not end the scan. An exception here used to be terminal twice over:
// it stopped the cache tracking anything further, and it was stored on the task, so the next Stop()
// rethrew it as an AggregateException at whoever had called Refresh() - a caller with nothing to do
// with the file that went missing.
catch (Exception ex)
{
Serilog.Log.Logger.Debug(ex, "Could not apply a file system change to the file cache: {@DebugText}", new { change.ChangeType, change.FullPath });
}
}
}
@@ -181,14 +216,36 @@ public class BackgroundFileSystem : IDisposable
{
path = path.LongPathName;
//Temporary files created when updating the db will disappear before their attributes can be read.
if (Path.GetFileName(path).Contains("LibationContext.db") || !File.Exists(path) && !Directory.Exists(path))
if (Path.GetFileName(path).Contains("LibationContext.db"))
return;
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
// Whether it exists and what it is were two questions, and the answer to the first could stop being true
// before the second was asked: a download's temp file, or a folder the user has just moved or deleted, is
// gone by the time its attributes are read. One question instead, and its failure means the same thing
// the existence check meant - there is nothing here to add.
if (TryGetAttributes(path) is not FileAttributes attributes)
return;
if (attributes.HasFlag(FileAttributes.Directory))
AddUniqueFiles(SafestEnumerateFiles(path));
else
AddUniqueFile(path);
}
/// <returns>What the path is, or null when it cannot be read and so has nothing to add.</returns>
internal static FileAttributes? TryGetAttributes(LongPath path)
{
try
{
return File.GetAttributes(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
Serilog.Log.Logger.Debug(ex, "Nothing to add to the file cache for {@DebugText}", new { path = (string)path });
return null;
}
}
private IEnumerable<LongPath> SafestEnumerateFiles(string path)
{
try
+6 -2
View File
@@ -6,8 +6,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="10.2.4.1" />
<PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="Dinah.Core" Version="11.0.0.1" />
<PackageReference Include="Polly" Version="8.7.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+98 -2
View File
@@ -263,8 +263,10 @@ public static class FileUtility
/// <param name="rootPath">Starting directory</param>
/// <param name="patternMatch">Filename pattern match</param>
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
/// <param name="onIncomplete">Called with the reason when the walk ends early, so a caller that must not
/// mistake a truncated list for an empty directory can tell the difference.</param>
/// <returns>List of files</returns>
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly, Action<Exception>? onIncomplete = null)
{
var enumOptions = new EnumerationOptions
{
@@ -273,7 +275,101 @@ public static class FileUtility
ReturnSpecialDirectories = false,
MatchType = MatchType.Simple
};
return Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath)p);
return IterateSafely(
() => Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath)p),
path,
onIncomplete);
}
/// <summary>
/// Walks a file system sequence so that a directory which stops being readable partway through ends the walk
/// instead of throwing at whoever is consuming it.
/// <para>
/// <see cref="EnumerationOptions.IgnoreInaccessible"/> only forgives permissions. A disconnected or failing
/// volume raises an I/O error from the enumerator itself, and because enumeration is lazy that error is
/// raised wherever the sequence is finally walked - which is past any try/catch the caller wrapped around
/// the call that produced it. Libation lost a whole session that way: a Books folder on a USB drive that
/// started returning I/O errors took down the file cache, the type initializer that builds it, and with it
/// every subsequent launch. See issue #1984.
/// </para>
/// </summary>
internal static IEnumerable<LongPath> IterateSafely(Func<IEnumerable<LongPath>> getSequence, LongPath path, Action<Exception>? onIncomplete = null)
{
IEnumerator<LongPath> enumerator;
try
{
//Opening the directory is itself a read, and fails the same way.
enumerator = getSequence().GetEnumerator();
}
catch (Exception ex) when (IsUnreadable(ex))
{
ReportIncomplete(ex, path, onIncomplete);
yield break;
}
try
{
while (true)
{
LongPath current;
try
{
if (!enumerator.MoveNext())
break;
current = enumerator.Current;
}
catch (Exception ex) when (IsUnreadable(ex))
{
//Whatever has already been read is still good and still worth returning.
ReportIncomplete(ex, path, onIncomplete);
break;
}
yield return current;
}
}
finally
{
enumerator.Dispose();
}
}
private static bool IsUnreadable(Exception ex)
=> ex is IOException or UnauthorizedAccessException or System.Security.SecurityException;
private static void ReportIncomplete(Exception ex, LongPath path, Action<Exception>? onIncomplete)
{
try
{
//A directory that has simply gone is routine: temp folders are created and cleaned up under a scan
//all the time. A directory that is there and cannot be read is worth seeing in a bug report.
if (ex is DirectoryNotFoundException)
Serilog.Log.Logger.Debug(ex, "Stopped listing files in a directory that is no longer there: {@DebugText}", new { path = (string)path });
else
Serilog.Log.Logger.Warning(ex, "Could not finish listing files. The results are incomplete: {@DebugText}", new { path = (string)path });
}
catch { /* logging must not be the thing that breaks a file listing */ }
onIncomplete?.Invoke(ex);
}
/// <summary>
/// Whether a directory can actually be read, as opposed to merely existing. A removable drive that has been
/// pulled, and a failing one, can both still answer that they are a directory while every read of them fails.
/// </summary>
public static bool CanEnumerate(LongPath path)
{
var readable = true;
//The first entry is enough. A volume that cannot be read fails on the first attempt, and this must not
//pay for walking a whole library to answer the question.
foreach (var _ in IterateSafely(
() => Directory.EnumerateFileSystemEntries(path.Path).Select(p => (LongPath)p),
path,
_ => readable = false))
break;
return readable;
}
/// <summary>
+1
View File
@@ -0,0 +1 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileManager) + ".Tests")]
@@ -70,15 +70,17 @@
<TrimmableAssembly Include="Avalonia.Themes.Default" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.14" />
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageReference Include="ReactiveUI.Avalonia" Version="12.1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
+1 -1
View File
@@ -5,7 +5,7 @@ namespace HangoverAvalonia.ViewModels;
public partial class MainVM : ViewModelBase
{
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; }
public MainVM()
{
@@ -77,7 +77,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
item.IsChecked = false;
}
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; }
public async Task RestoreCheckedAsync()
{
+4
View File
@@ -10,6 +10,10 @@
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
@@ -53,6 +53,10 @@
</Compile>
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
<!-- Remove obj folder -->
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
+67 -15
View File
@@ -29,6 +29,15 @@ public class App : Application
/// <summary>Set by <see cref="Program"/> when another Libation instance already holds this folder's lock.</summary>
public static bool IsAnotherInstanceRunning { get; set; }
/// <summary>
/// Set by <see cref="Program"/> when startup rolled back an incomplete in-app upgrade. The restored
/// files on disk no longer match the assemblies this process loaded, so it shows this and quits.
/// </summary>
public static StartupRecoveryNotice? StartupRecoveryNotice { get; set; }
/// <summary>Set when the user accepted the offer to start Libation again. Read by <see cref="Program"/> after shutdown.</summary>
public static bool RestartRequested { get; private set; }
public static ChardonnayTheme? DefaultThemeColors { get; private set; }
public static MainWindow? MainWindow { get; private set; }
public static Uri AssetUriBase { get; } = new("avares://Libation/Assets/");
@@ -50,17 +59,27 @@ public class App : Application
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
// Another instance already owns this folder. No database work has been done in this process,
// so just tell the user and shut down instead of racing on shared files. See issue #1931.
if (IsAnotherInstanceRunning)
// The install folder was just rolled back underneath us. See issue #2001.
if (StartupRecoveryNotice is { } recovery)
{
_ = ShowAlreadyRunningThenShutdownAsync(desktop);
_ = ShowRecoveryThenShutdownAsync(desktop, recovery);
base.OnFrameworkInitializationCompleted();
return;
}
if (InstallUpgradeManager.TakeStartupRecoveryAlert() is { } recovery)
_ = MessageBox.Show(null, recovery.Body, recovery.Title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
// Another instance already owns this folder. No database work has been done in this process,
// so just tell the user and shut down instead of racing on shared files. See issue #1931.
if (IsAnotherInstanceRunning)
{
_ = ShowThenShutdownAsync(
desktop,
"Libation is already running.\r\n\r\n"
+ "Please use the Libation window that is already open. Running more than one copy of "
+ "Libation against the same folder at the same time can corrupt your library.",
"Libation is already running");
base.OnFrameworkInitializationCompleted();
return;
}
BadBookActionDialogBase.ShowAsyncImpl = (owner, message, caption) =>
Dialogs.BadBookActionDialog.ShowAsync(owner as Window, message, caption);
@@ -80,21 +99,54 @@ public class App : Application
base.OnFrameworkInitializationCompleted();
}
private static async Task ShowAlreadyRunningThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop)
/// <summary>
/// Reports the rollback and shuts down. When the restored install is worth going back into, the user is
/// asked whether to start Libation again, and <see cref="Program"/> does it after this shuts down.
/// </summary>
private static async Task ShowRecoveryThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop, StartupRecoveryNotice recovery)
{
if (!recovery.OfferRestart)
{
await ShowThenShutdownAsync(desktop, recovery.Body, recovery.Title);
return;
}
try
{
await MessageBox.Show(
"Libation is already running.\r\n\r\n"
+ "Please use the Libation window that is already open. Running more than one copy of "
+ "Libation against the same folder at the same time can corrupt your library.",
"Libation is already running",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
var answer = await MessageBox.Show(
recovery.Body,
recovery.Title,
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button1);
RestartRequested = answer is DialogResult.Yes;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to show the 'already running' message");
// Serilog is unavailable on this path, and shutting down matters more than the question.
StartupLog.Error(ex, "Failed to ask whether to restart after the rollback");
}
finally
{
desktop.Shutdown();
}
}
/// <summary>
/// Shows one modal and then shuts the app down without opening the main window, for the cases where
/// Libation has decided at startup that it must not keep running.
/// </summary>
private static async Task ShowThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop, string body, string title)
{
try
{
await MessageBox.Show(body, title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
catch (Exception ex)
{
// Serilog is unavailable on the rollback path, and shutting down matters more than the message.
StartupLog.Error(ex, $"Failed to show the startup message '{title}'");
}
finally
{
@@ -1,56 +1,54 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Interactivity;
using DataLayer;
namespace LibationAvalonia.Controls;
public class DataGridMyRatingColumn : DataGridBoundColumn
public class DataGridMyRatingColumn : DataGridTemplateColumn
{
[AssignBinding] public BindingBase? BackgroundBinding { get; set; }
[AssignBinding] public BindingBase? OpacityBinding { get; set; }
[AssignBinding] public BindingBase? RatingBinding { get; set; }
private static Rating DefaultRating => new Rating(0, 0, 0);
public DataGridMyRatingColumn()
{
BindingTarget = MyRatingCellEditor.RatingProperty;
this.IsReadOnly = false;
//Must set the CellEditingTemplate to enable cell editing in a DataGridTemplateColumn.
CellEditingTemplate = new FuncDataTemplate(typeof(Rating), (value, _) =>
{
var myRatingElement = CreateControl();
myRatingElement.Name = "CellMyRatingEditor";
myRatingElement.IsEditingMode = true;
return myRatingElement;
});
}
protected override Control GenerateElement(DataGridCell cell, object dataItem)
{
var myRatingElement = new MyRatingCellEditor
{
Name = "CellMyRatingDisplay",
IsEditingMode = false
};
var myRatingElement = CreateControl();
myRatingElement.Name = "CellMyRatingDisplay";
myRatingElement.IsEditingMode = false;
cell.Tag = this;
if (!IsReadOnly)
ToolTip.SetTip(myRatingElement, "Click to change ratings");
if (Binding != null)
myRatingElement.Bind(BindingTarget, Binding);
if (BackgroundBinding != null)
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
if (OpacityBinding != null)
myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding);
return myRatingElement;
}
protected override Control GenerateEditingElementDirect(DataGridCell cell, object dataItem)
private MyRatingCellEditor CreateControl()
{
var myRatingElement = new MyRatingCellEditor
{
Name = "CellMyRatingEditor",
IsEditingMode = true
};
var myRatingElement = new MyRatingCellEditor();
if (RatingBinding != null)
myRatingElement.Bind(MyRatingCellEditor.RatingProperty, RatingBinding);
if (BackgroundBinding != null)
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
if (OpacityBinding != null)
myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding);
return myRatingElement;
}
@@ -23,13 +23,19 @@ public partial class MyRatingCellEditor : UserControl
{
InitializeComponent();
var subscriber = this.ObservableForProperty(p => p.Rating).Subscribe(o => DisplayStarRating(o.Value ?? new Rating(0, 0, 0)));
Unloaded += (_, _) => subscriber.Dispose();
if (Design.IsDesignMode)
Rating = new Rating(5, 4, 3);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == RatingProperty)
{
DisplayStarRating(change.GetNewValue<Rating>() ?? new Rating(0f, 0f, 0f));
}
}
private void DisplayStarRating(Rating rating)
{
var blankValue = IsEditingMode ? HOLLOW_STAR : string.Empty;
@@ -69,21 +69,45 @@
</StackPanel>
</controls:GroupBox>
<CheckBox
<StackPanel
Grid.Row="1"
Margin="10,5"
IsEnabled="{Binding !UseWebViewSettingDisabled}"
IsChecked="{Binding UseWebView, Mode=TwoWay}">
<StackPanel>
<TextBlock Text="{Binding UseWebViewText}" />
<TextBlock
IsVisible="{Binding UseWebViewSettingDisabled}"
FontStyle="Italic"
Opacity="0.8"
Margin="0,2,0,0"
Text="{Binding UseWebViewSnapMessage}" />
</StackPanel>
</CheckBox>
Spacing="5">
<CheckBox
IsEnabled="{Binding !UseWebViewSettingDisabled}"
IsChecked="{Binding UseWebView, Mode=TwoWay}">
<StackPanel>
<TextBlock Text="{Binding UseWebViewText}" />
<TextBlock
IsVisible="{Binding UseWebViewSettingDisabled}"
FontStyle="Italic"
Opacity="0.8"
Margin="0,2,0,0"
Text="{Binding UseWebViewSnapMessage}" />
</StackPanel>
</CheckBox>
<TextBlock
Margin="0,8,0,0"
Text="{Binding DeviceRegistrationKindText}" />
<controls:WheelComboBox
Height="25"
HorizontalContentAlignment="Stretch"
SelectedItem="{Binding SelectedDeviceRegistration, Mode=TwoWay}"
ItemsSource="{Binding DeviceRegistrationOptions}"
ToolTip.Tip="{Binding DeviceRegistrationKindTip}" />
<TextBlock
FontStyle="Italic"
Opacity="0.8"
TextWrapping="Wrap"
Text="{Binding DeviceRegistrationReLoginNote}" />
<CheckBox
IsChecked="{Binding CheckForUpgradesAtStartup, Mode=TwoWay}"
ToolTip.Tip="{Binding CheckForUpgradesAtStartupTip}">
<TextBlock Text="{Binding CheckForUpgradesAtStartupText}" />
</CheckBox>
</StackPanel>
<controls:GroupBox
Grid.Row="2"
@@ -44,7 +44,7 @@ public partial class AboutDialog : DialogWindow
async Task OnUpgradeAvailable(UpgradeEventArgs e)
{
var notificationResult = await new UpgradeNotificationDialog(e.UpgradeProperties, e.CapUpgrade).ShowDialogAsync(this);
var notificationResult = await new UpgradeNotificationDialog(e.UpgradeProperties, e.CapUpgrade, e.UpgradeUnavailableReason).ShowDialogAsync(this);
e.Ignore = notificationResult == DialogResult.Ignore;
e.InstallUpgrade = notificationResult == DialogResult.OK;
@@ -87,6 +87,23 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Width="Auto" Header="Marketplaces">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button
Content="{Binding MarketplacesButtonText}"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding CanCheckMarketplaces}"
ToolTip.Tip="{Binding MarketplacesButtonToolTip}"
Click="MarketplacesButton_Clicked" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn
Width="Auto"
Binding="{Binding AccountName, Mode=TwoWay}"
@@ -3,6 +3,7 @@ using AudibleUtilities;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using LibationUiBase;
using LibationUiBase.Forms;
using ReactiveUI;
using System;
@@ -17,10 +18,16 @@ namespace LibationAvalonia.Dialogs;
public partial class AccountsDialog : DialogWindow
{
public AvaloniaList<AccountDto> Accounts { get; } = new();
private bool _isDirty;
private bool _closeConfirmed;
public class AccountDto : ViewModels.ViewModelBase
{
public IReadOnlyList<Locale> Locales => AccountsDialog.Locales;
public bool LibraryScan { get; set; } = true;
public bool LibraryScan
{
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
} = true;
public string? AccountId
{
get => field;
@@ -42,9 +49,19 @@ public partial class AccountsDialog : DialogWindow
}
}
public string? AccountName { get; set; }
public string? AccountName
{
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
}
public bool IsDefault => string.IsNullOrEmpty(AccountId);
/// <summary>
/// Marketplaces beyond <see cref="SelectedLocale"/> that this account should also scan. Edited by the
/// marketplaces dialog and written on save, like every other field here.
/// </summary>
public List<string> AdditionalLocaleNames { get; } = new();
public bool CanExport
{
get => field;
@@ -56,6 +73,17 @@ public partial class AccountsDialog : DialogWindow
? "Export account authorization to audible-cli"
: "Authenticate this account (e.g. library scan) before exporting to audible-cli.";
/// <summary>
/// Checking other marketplaces uses this account's stored credentials, so it needs the same thing an
/// export does: an account that has logged in at least once.
/// </summary>
public bool CanCheckMarketplaces => CanExport;
public string MarketplacesButtonText => MarketplacesUi.ButtonText(AdditionalLocaleNames.Count + 1);
public string MarketplacesButtonToolTip
=> CanCheckMarketplaces ? MarketplacesUi.ButtonToolTip : MarketplacesUi.NotAuthenticatedToolTip;
public AccountDto() => RefreshCanExport();
public AccountDto(Account account)
@@ -64,13 +92,28 @@ public partial class AccountsDialog : DialogWindow
AccountId = account.AccountId;
SelectedLocale = Locales.Single(l => l.Name == account.Locale?.Name);
AccountName = account.AccountName;
AdditionalLocaleNames.AddRange(account.AdditionalLocales.Select(l => l.Name));
RefreshCanExportFromAccount(account);
}
public void SetAdditionalLocaleNames(IEnumerable<string> localeNames)
{
AdditionalLocaleNames.Clear();
AdditionalLocaleNames.AddRange(localeNames);
this.RaisePropertyChanged(nameof(MarketplacesButtonText));
}
private void RefreshCanExportFromAccount(Account account)
{
CanExport = account.IdentityTokens?.IsValid == true;
RaiseDerivedFromCanExport();
}
private void RaiseDerivedFromCanExport()
{
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
this.RaisePropertyChanged(nameof(CanCheckMarketplaces));
this.RaisePropertyChanged(nameof(MarketplacesButtonToolTip));
}
private void RefreshCanExport()
@@ -78,7 +121,7 @@ public partial class AccountsDialog : DialogWindow
if (string.IsNullOrEmpty(AccountId) || SelectedLocale is null)
{
CanExport = false;
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
RaiseDerivedFromCanExport();
return;
}
@@ -86,7 +129,7 @@ public partial class AccountsDialog : DialogWindow
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a =>
a.AccountId == AccountId && a.Locale?.Name == SelectedLocale.Name);
CanExport = account?.IdentityTokens?.IsValid == true;
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
RaiseDerivedFromCanExport();
}
}
@@ -120,7 +163,11 @@ public partial class AccountsDialog : DialogWindow
else if (e.Action is NotifyCollectionChangedAction.Remove && e.OldItems?.Count > 0)
{
foreach (var oldItem in e.OldItems.OfType<AccountDto>())
{
oldItem.PropertyChanged -= AccountDto_PropertyChanged;
if (!oldItem.IsDefault)
_isDirty = true;
}
}
}
@@ -128,6 +175,13 @@ public partial class AccountsDialog : DialogWindow
private void AccountDto_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
// only user-editable fields; skip derived props like CanExport / button tooltips
if (e.PropertyName is nameof(AccountDto.LibraryScan)
or nameof(AccountDto.AccountId)
or nameof(AccountDto.SelectedLocale)
or nameof(AccountDto.AccountName))
_isDirty = true;
if (!Accounts.Any(a => a.IsDefault))
addBlankAccount();
}
@@ -174,14 +228,17 @@ public partial class AccountsDialog : DialogWindow
return;
}
if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is { } dup)
if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is not null)
{
await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {dup.AccountId}\r\nCountry: {dup.Locale?.Name}", "Cannot Add Duplicate Account");
await MessageBox.Show(this, Mkb79AuthImporter.DuplicateMessage(importResult), "Cannot Add Duplicate Account");
return;
}
if (importResult.Account is { } account)
{
Accounts.Add(new AccountDto(account));
_isDirty = true;
}
}
catch (Exception ex)
{
@@ -199,6 +256,32 @@ public partial class AccountsDialog : DialogWindow
Export(acc);
}
public async void MarketplacesButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (e.Source is not Button btn || btn.DataContext is not AccountDto acc)
return;
// the probe speaks to Audible with this account's stored credentials, so it needs the saved account,
// not the grid's copy of it
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a =>
a.AccountId == acc.AccountId && a.Locale?.Name == acc.SelectedLocale?.Name);
if (account is null || account.IdentityTokens?.IsValid != true)
{
await MessageBox.Show(this, MarketplacesUi.NotAuthenticatedToolTip, "Account Not Authenticated");
return;
}
var dialog = new MarketplacesDialog(account, persister.AccountsSettings, acc.AdditionalLocaleNames);
if (await dialog.ShowDialog<DialogResult>(this) == DialogResult.OK)
{
acc.SetAdditionalLocaleNames(dialog.SelectedAdditionalLocaleNames);
_isDirty = true;
}
}
protected override async Task SaveAndCloseAsync()
{
try
@@ -226,6 +309,30 @@ public partial class AccountsDialog : DialogWindow
public async void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
protected override async void OnClosing(WindowClosingEventArgs e)
{
if (!_closeConfirmed && _isDirty && DialogResult != DialogResult.OK)
{
e.Cancel = true;
var result = await MessageBox.Show(
this,
"You have unsaved changes. Close without saving?",
"Unsaved Changes",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
if (result == DialogResult.Yes)
{
_closeConfirmed = true;
Close(DialogResult.Cancel);
}
}
base.OnClosing(e);
}
private void persist(AccountsSettings accountsSettings)
{
var existingAccounts = accountsSettings.Accounts;
@@ -246,6 +353,7 @@ public partial class AccountsDialog : DialogWindow
}
// upsert each. validation occurs through Account and AccountsSettings
var upserted = new List<(AccountDto Dto, Account Account)>();
foreach (var dto in Accounts.Where(a => a.AccountId is not null))
{
var acct = accountsSettings.Upsert(dto.AccountId!, dto.SelectedLocale?.Name);
@@ -254,7 +362,15 @@ public partial class AccountsDialog : DialogWindow
= string.IsNullOrWhiteSpace(dto.AccountName)
? $"{dto.AccountId} - {dto.SelectedLocale?.Name}"
: dto.AccountName.Trim();
// drop every marketplace before assigning any, so that moving one from one account to another in a
// single sitting cannot trip the "no two accounts scan one marketplace" rule halfway through
acct.SetAdditionalMarketplaces([]);
upserted.Add((dto, acct));
}
foreach (var (dto, acct) in upserted)
acct.SetAdditionalMarketplaces(dto.AdditionalLocaleNames);
}
private async Task<bool> inputIsValid()
{
@@ -71,7 +71,7 @@
FontSize="12"
VerticalAlignment="Top"
Margin="10,10,0,0"
Text="To download again next time: change to Not Downloaded&#xA;To not download: change to Downloaded" />
Text="To download again next time: change to Download Pending&#xA;To not download: change to Downloaded" />
<Grid Margin="0,10,0,5" ColumnDefinitions="Auto,Auto,50,Auto,Auto,*">
@@ -147,7 +147,7 @@ public partial class BookDetailsDialog : DialogWindow
var status = libraryBook.Book.UserDefinedItem.BookStatus;
BookLiberatedItems.Add(new() { Status = LiberatedStatus.Liberated, Text = "Downloaded" });
BookLiberatedItems.Add(new() { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" });
BookLiberatedItems.Add(new() { Status = LiberatedStatus.NotLiberated, Text = "Download Pending" });
if (status == LiberatedStatus.Error)
BookLiberatedItems.Add(new() { Status = LiberatedStatus.Error, Text = "Error" });
@@ -162,7 +162,7 @@ public partial class BookDetailsDialog : DialogWindow
if (status is not null)
{
PdfLiberatedItems.Add(new() { Status = LiberatedStatus.Liberated, Text = "Downloaded" });
PdfLiberatedItems.Add(new() { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" });
PdfLiberatedItems.Add(new() { Status = LiberatedStatus.NotLiberated, Text = "Download Pending" });
PdfLiberatedSelectedItem = PdfLiberatedItems.SingleOrDefault(s => s.Status == status);
}
@@ -136,8 +136,8 @@ public partial class FindBetterQualityBooksDialog : DialogWindow
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Failed to mark books as Not Liberated");
await MessageBox.Show(this, "An error occurred while marking books as Not Liberated. Please see the logs for more information.", "Error Marking Books", MessageBoxButtons.OK, MessageBoxIcon.Error);
Serilog.Log.Error(ex, "Failed to mark books as Download Pending");
await MessageBox.Show(this, "An error occurred while marking books as Download Pending. Please see the logs for more information.", "Error Marking Books", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
@@ -50,7 +50,7 @@ public partial class ImageDisplayDialog : DialogWindow, INotifyPropertyChanged
try
{
_bitmapHolder.CoverImage?.Save(selectedFile);
_bitmapHolder.CoverImage?.Save(selectedFile, JpegBitmapEncoderOptions.Default);
}
catch (Exception ex)
{
@@ -25,7 +25,7 @@
<TextBlock
TextWrapping="Wrap"
Text="If the audio file can be found, set download status to 'Downloaded'" />
Text="If the audio file can be found, mark the book 'Downloaded'" />
</CheckBox>
<CheckBox
Margin="0,0,0,10"
@@ -33,7 +33,7 @@
<TextBlock
TextWrapping="Wrap"
Text="If the audio file cannot be found, set download status to 'Not Downloaded'" />
Text="If the audio file cannot be found, mark the book 'Download Pending'" />
</CheckBox>
</StackPanel>
@@ -18,7 +18,7 @@
<TextBlock
Grid.ColumnSpan="2"
Margin="10"
Text="To download again next time: change to Not Downloaded&#xa;To not download: change to Downloaded"/>
Text="To download again next time: change to Download Pending&#xa;To not download: change to Downloaded"/>
<StackPanel
Margin="10,0"
@@ -30,7 +30,7 @@ public partial class LiberatedStatusBatchManualDialog : DialogWindow
public List<liberatedComboBoxItem> BookStatuses { get; } =
[
new liberatedComboBoxItem { Status = LiberatedStatus.Liberated, Text = "Downloaded" },
new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" },
new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Download Pending" },
];
public LiberatedStatusBatchManualDialog(bool isPdf) : this()
@@ -10,7 +10,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Threading;
namespace LibationAvalonia.Dialogs;
@@ -1,18 +0,0 @@
using AudibleApi;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs.Login;
public class AvaloniaLoginCallback : ILoginCallback
{
public string DeviceName { get; } = "Libation";
public Task<string> Get2faCodeAsync(string prompt) => throw new System.NotSupportedException();
public Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage)
=> throw new System.NotSupportedException();
public Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig)
=> throw new System.NotSupportedException();
public Task<(string email, string password)> GetLoginAsync()
=> throw new System.NotSupportedException();
public Task ShowApprovalNeededAsync() => throw new System.NotSupportedException();
}
@@ -14,19 +14,17 @@ namespace LibationAvalonia.Dialogs.Login;
public class AvaloniaLoginChoiceEager : ILoginChoiceEager
{
public ILoginCallback LoginCallback { get; } = new AvaloniaLoginCallback();
private readonly Account _account;
public AvaloniaLoginChoiceEager(Account account)
{
_account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
_account = ArgumentValidator.EnsureNotNull(account, nameof(account));
}
public async Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn)
public async Task<string?> StartAsync(ChoiceIn choiceIn)
=> await Dispatcher.UIThread.InvokeAsync(() => StartAsyncInternal(choiceIn));
private async Task<ChoiceOut?> StartAsyncInternal(ChoiceIn choiceIn)
private async Task<string?> StartAsyncInternal(ChoiceIn choiceIn)
{
try
{
@@ -35,7 +33,7 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
{
try
{
if (await BrowserLoginAsync(choiceIn) is ChoiceOut external)
if (await BrowserLoginAsync(choiceIn) is string external)
return external;
}
catch (Exception ex) when (WebView2LoginErrorMessage.IsWebView2SignInInfrastructureFailure(ex))
@@ -65,17 +63,17 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
var externalDialog = new LoginExternalDialog(_account, choiceIn.LoginUrl);
return await externalDialog.ShowDialogAsync() is DialogResult.OK
? ChoiceOut.External(externalDialog.ResponseUrl)
? externalDialog.ResponseUrl
: null;
}
private async Task<ChoiceOut?> BrowserLoginAsync(ChoiceIn shoiceIn)
private async Task<string?> BrowserLoginAsync(ChoiceIn shoiceIn)
{
// Time-of-use: setting can change before Show (e.g. settings saved) — never open NativeWebView when disabled.
if (!Configuration.Instance.UseWebView)
return null;
TaskCompletionSource<ChoiceOut?> tcs = new();
TaskCompletionSource<string?> tcs = new();
NativeWebDialog dialog = new()
{
@@ -89,7 +87,7 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
{
if (e.Request?.AbsolutePath.StartsWith("/ap/maplanding") is true)
{
tcs.TrySetResult(ChoiceOut.External(e.Request.ToString()));
tcs.TrySetResult(e.Request.ToString());
dialog.Close();
}
};
@@ -182,20 +180,21 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
void Dialog_EnvironmentRequested(object? sender, WebViewEnvironmentRequestedEventArgs e)
{
var userAgent = Configuration.Instance.GetDeviceRegistrationProfile().UserAgent;
// Private browsing & user agent setting
switch (e)
{
case WindowsWebView2EnvironmentRequestedEventArgs webView2Args:
webView2Args.IsInPrivateModeEnabled = true;
webView2Args.AdditionalBrowserArguments = "--user-agent=\"" + Resources.User_Agent + "\"";
webView2Args.AdditionalBrowserArguments = "--user-agent=\"" + userAgent + "\"";
break;
case AppleWKWebViewEnvironmentRequestedEventArgs appleArgs:
appleArgs.NonPersistentDataStore = true;
appleArgs.ApplicationNameForUserAgent = Resources.User_Agent;
appleArgs.ApplicationNameForUserAgent = userAgent;
break;
case GtkWebViewEnvironmentRequestedEventArgs gtkArgs:
gtkArgs.EphemeralDataManager = true;
gtkArgs.ApplicationNameForUserAgent = Resources.User_Agent;
gtkArgs.ApplicationNameForUserAgent = userAgent;
break;
}
}
@@ -0,0 +1,81 @@
<Window
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="560" d:DesignHeight="520"
MinWidth="400" MinHeight="360"
Width="560" Height="520"
x:Class="LibationAvalonia.Dialogs.MarketplacesDialog"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
x:DataType="dialogs:MarketplacesDialog"
x:CompileBindings="True"
Title="Marketplaces"
WindowStartupLocation="CenterOwner">
<Grid
ColumnDefinitions="Auto,*,Auto"
RowDefinitions="Auto,Auto,*,Auto,Auto"
Margin="10">
<TextBlock
Grid.Row="0"
Grid.Column="0"
Grid.ColumnSpan="3"
TextWrapping="Wrap"
Text="{Binding Intro}" />
<TextBlock
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="3"
Margin="0,10,0,0"
FontWeight="Bold"
Text="{Binding AccountLabel}" />
<ListBox
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="3"
Margin="0,10"
Name="lbMarketplaces"
ItemsSource="{Binding Marketplaces}">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding CanCheck}"
ToolTip.Tip="{Binding ToolTip}">
<TextBlock Text="{Binding Text}" />
</CheckBox>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock
Grid.Row="3"
Grid.Column="0"
Grid.ColumnSpan="3"
TextWrapping="Wrap"
Margin="0,0,0,10"
Name="StatusTextBlock" />
<Button
Grid.Row="4"
Grid.Column="0"
Padding="20,6"
Name="CheckButton"
Content="{Binding CheckButtonText}"
Click="CheckButton_Clicked" />
<Button
Grid.Row="4"
Grid.Column="2"
Classes="SaveButton"
HorizontalAlignment="Right"
Content="Save"
Name="SaveButton"
Command="{Binding SaveAndClose}" />
</Grid>
</Window>
@@ -0,0 +1,148 @@
using AudibleApi;
using AudibleUtilities;
using Avalonia.Collections;
using LibationUiBase;
using ReactiveUI;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs;
/// <summary>
/// Which Audible marketplaces one account should read. Opened from the accounts grid, and only for an account
/// that has already logged in - the check is made with that account's own credentials.
/// </summary>
public partial class MarketplacesDialog : DialogWindow
{
public string Intro => MarketplacesUi.Intro;
public string CheckButtonText => MarketplacesUi.CheckButton;
public string AccountLabel { get; } = "";
public AvaloniaList<ListItem> Marketplaces { get; } = new();
/// <summary>The additional marketplaces the user checked. The registered one is never among them.</summary>
public IReadOnlyList<string> SelectedAdditionalLocaleNames
=> Marketplaces
.Where(m => m.IsChecked && m.CanCheck)
.Select(m => m.Locale.Name)
.ToList();
public class ListItem : ViewModels.ViewModelBase
{
public ListItem(Locale locale, string text, bool isChecked, bool canCheck, string? toolTip = null)
{
Locale = locale;
Text = text;
IsChecked = isChecked;
CanCheck = canCheck;
ToolTip = toolTip;
}
public Locale Locale { get; }
public string Text
{
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
}
public bool IsChecked
{
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
}
public bool CanCheck
{
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
}
public string? ToolTip { get; }
public override string ToString() => Text;
}
private readonly Account? account;
private readonly AccountsSettings? accountsSettings;
// parameterless ctor for the axaml designer
public MarketplacesDialog()
{
InitializeComponent();
DataContext = this;
}
/// <param name="selectedAdditionalLocaleNames">
/// What the accounts grid currently shows for this account, which may not yet be saved.
/// </param>
public MarketplacesDialog(Account account, AccountsSettings accountsSettings, IEnumerable<string> selectedAdditionalLocaleNames)
{
InitializeComponent();
this.account = account;
this.accountsSettings = accountsSettings;
AccountLabel = AccountCredentialStatus.FormatAccountLabel(account);
var selected = selectedAdditionalLocaleNames.ToHashSet();
// list every candidate up front, so the dialog is a full picture before anything is asked of Audible
foreach (var locale in MarketplaceProbe.CandidateLocales(account))
{
var isRegistered = locale.Name == account.Locale?.Name;
Marketplaces.Add(new ListItem(
locale,
isRegistered
? $"{locale.Name} - this account's own marketplace"
: locale.Name,
isChecked: isRegistered || selected.Contains(locale.Name),
canCheck: !isRegistered,
toolTip: isRegistered ? "Always scanned. This is where the account is registered." : null));
}
StatusTextBlock.Text = MarketplacesUi.ButtonToolTip;
ControlToFocusOnShow = CheckButton;
DataContext = this;
}
public async void CheckButton_Clicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await ProbeAsync();
public async Task ProbeAsync()
{
if (account is null || accountsSettings is null)
return;
CheckButton.IsEnabled = false;
StatusTextBlock.Text = MarketplacesUi.Checking;
var results = new List<MarketplaceProbeResult>();
try
{
await foreach (var result in MarketplaceProbe.ProbeAsync(account, accountsSettings))
{
results.Add(result);
if (Marketplaces.FirstOrDefault(m => m.Locale.Name == result.Locale.Name) is not { } item)
continue;
item.Text = MarketplacesUi.ResultText(result);
// a marketplace another account already scans must not be checkable here: two rows scanning one
// marketplace would import it twice
if (result.Outcome is MarketplaceProbeOutcome.ScannedByAnotherAccount or MarketplaceProbeOutcome.Failed)
item.CanCheck = false;
if (result.Outcome is MarketplaceProbeOutcome.TitlesFound)
item.IsChecked = true;
}
StatusTextBlock.Text = MarketplacesUi.Summary(results);
}
finally
{
CheckButton.IsEnabled = true;
}
}
public new void SaveAndClose() => base.SaveAndClose();
}
@@ -4,8 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
MinWidth="600" MinHeight="450"
MaxWidth="600" MaxHeight="450"
Width="600" Height="450"
MaxWidth="600" MaxHeight="620"
Width="600" Height="560"
x:Class="LibationAvalonia.Dialogs.MessageBoxAlertAdminDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
@@ -20,12 +20,22 @@
Margin="10,10,10,0"
ColumnDefinitions="Auto,*">
<Image Grid.Column="0" Width="64" Height="64" Source="/Assets/MBIcons/Error_64.png" />
<TextBlock
<Image Grid.Column="0" Width="64" Height="64" VerticalAlignment="Top" Source="/Assets/MBIcons/Error_64.png" />
<!--
Scrolled and capped: a startup failure that names the file, its version and the recovery steps
runs far longer than the two lines this dialog was built for, and without a bound on the
description the rows below it were pushed out of the window. See issue #2001.
-->
<ScrollViewer
Grid.Column="1"
Margin="10"
TextWrapping="Wrap"
Text="{Binding ErrorDescription}" />
MaxHeight="260"
VerticalScrollBarVisibility="Auto">
<TextBlock
Margin="10"
TextWrapping="Wrap"
Text="{Binding ErrorDescription}" />
</ScrollViewer>
</Grid>
<TextBox
@@ -1,5 +1,6 @@
using AudibleUtilities;
using Avalonia.Collections;
using LibationUiBase;
using LibationUiBase.Forms;
using System.Collections.Generic;
using System.Linq;
@@ -17,7 +18,8 @@ public partial class ScanAccountsDialog : DialogWindow
{
Account = account;
IsChecked = account.LibraryScan;
Text = $"{account.AccountName} ({account.AccountId} - {account.Locale?.Name})";
// lists every marketplace: one checkbox here can scan more than one
Text = MarketplacesUi.ScanPickerText(account);
}
public Account Account { get; }
public string Text { get; }
@@ -14,6 +14,7 @@ public partial class UpgradeNotificationDialog : DialogWindow
public string? ReleaseNotes { get; }
public string? OkText { get; }
private string? PackageUrl { get; }
private bool CanUpgrade { get; } = true;
public UpgradeNotificationDialog()
{
if (Design.IsDesignMode)
@@ -30,18 +31,23 @@ public partial class UpgradeNotificationDialog : DialogWindow
ControlToFocusOnShow = btnYes;
}
public UpgradeNotificationDialog(UpgradeProperties upgradeProperties, bool canUpgrade) : this()
public UpgradeNotificationDialog(UpgradeProperties upgradeProperties, bool canUpgrade, string? upgradeUnavailableReason = null) : this()
{
Title = $"Libation version {upgradeProperties.LatestRelease.ToVersionString()} is now available.";
CanUpgrade = canUpgrade;
PackageUrl = upgradeProperties.ZipUrl;
DownloadLinkText = upgradeProperties.ZipName;
ReleaseNotes = upgradeProperties.Notes;
TopMessage = canUpgrade ? UpdateMessage : "";
TopMessage = canUpgrade ? UpdateMessage : upgradeUnavailableReason ?? "";
OkText = canUpgrade ? "Yes" : "OK";
DataContext = this;
}
public void OK_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => Close(DialogResult.OK);
// When Libation cannot install the upgrade itself, this button reads "OK" and the dialog is a
// notice with a download link. Reporting OK there would be read as "yes, install it", starting a
// download and an install that was never on offer, so acknowledging a notice closes and no more.
public void OK_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> Close(CanUpgrade ? DialogResult.OK : DialogResult.Cancel);
public void DontRemind_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => Close(DialogResult.Ignore);
public void Download_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
=> Go.To.Url(PackageUrl);
+4 -2
View File
@@ -70,7 +70,9 @@ public static class FormSaveExtension
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to save {form} size and location", form.GetType().Name);
// The crash dialog restores its own position, so this runs while the install may be broken.
// Logging through Serilog here took the dialog down with it. See issue #2001.
StartupLog.Error(ex, $"Failed to restore {form.GetType().Name} size and location");
}
}
public static void SaveSizeAndLocation(this Window form, Configuration config)
@@ -99,7 +101,7 @@ public static class FormSaveExtension
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to save {form} size and location", form.GetType().Name);
StartupLog.Error(ex, $"Failed to save {form.GetType().Name} size and location");
}
}
@@ -72,19 +72,23 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.0.2" />
<PackageReference Include="Avalonia.Controls.WebView" Version="12.0.0" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.14" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.1.2" />
<PackageReference Include="Avalonia.Controls.WebView" Version="12.1.0" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
<PackageReference Include="ReactiveUI.Avalonia" Version="12.1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LibationUiBase\LibationUiBase.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
<!-- Remove obj folder -->
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
+41 -80
View File
@@ -3,14 +3,12 @@ using AppScaffolding;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using FileManager;
using LibationAvalonia.Dialogs;
using LibationFileManager;
using LibationUiBase.Forms;
using ReactiveUI.Avalonia;
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@@ -56,21 +54,34 @@ static class Program
try
{
var config = LibationScaffolding.RunPreConfigMigrations();
StartupAssemblyBootstrap.RecoverFromIncompleteUpgradeIfNeeded();
// Prevent a second instance from racing on the same database, search index, and log file.
// Hold the lock for the whole process; skip all database access when we are not the first
// instance so the running copy's state is never touched. See issue #1931.
SingleInstanceLock = SingleInstance.TryAcquire(config.LibationFiles.Location);
App.IsAnotherInstanceRunning = !SingleInstanceLock.IsFirstInstance;
// A rollback swaps install files out from under assemblies this process has already loaded, so
// it must not go on to touch the database or open a window. App reports it and shuts down
// instead. See issue #2001.
App.StartupRecoveryNotice = StartupAssemblyBootstrap.RecoverFromIncompleteUpgradeIfNeeded();
if (SingleInstanceLock.IsFirstInstance && config.LibationFiles.SettingsAreValid)
if (App.StartupRecoveryNotice is null)
{
App.RunMigrations(config);
StartupAssemblyBootstrap.PrepareForBackgroundDataAccess();
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
// Prevent a second instance from racing on the same database, search index, and log file.
// Hold the lock for the whole process; skip all database access when we are not the first
// instance so the running copy's state is never touched. See issue #1931.
SingleInstanceLock = SingleInstance.TryAcquire(config.LibationFiles.Location);
App.IsAnotherInstanceRunning = !SingleInstanceLock.IsFirstInstance;
if (SingleInstanceLock.IsFirstInstance && config.LibationFiles.SettingsAreValid)
{
App.RunMigrations(config);
StartupAssemblyBootstrap.PrepareForBackgroundDataAccess();
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
}
}
BuildAvaloniaApp()?.StartWithClassicDesktopLifetime([], ShutdownMode.OnExplicitShutdown);
// After the lifetime ends, so the new process starts into a folder this one has finished with
// and does not race it for the single-instance lock.
if (App.RestartRequested)
InstallRelauncher.TryRelaunch();
}
catch (Exception ex)
{
@@ -112,13 +123,14 @@ static class Program
private static void LogAndShowCrashMessage(Exception exception)
{
string? crashLogFile = null;
try
{
//Try to log the error message before displaying the crash dialog
if (Configuration.Instance.SerilogInitialized)
Serilog.Log.Logger.Error(exception, "CRASH");
else
LogErrorWithoutSerilog(exception);
crashLogFile = PreLoggingCrashLog.TryWrite(exception, [("ReleaseIdentifier", LibationScaffolding.ReleaseIdentifier.ToString())]);
}
catch { /* continue to show the crash dialog even if logging fails */ }
@@ -127,7 +139,7 @@ static class Program
try
{
Dispatcher.UIThread.Invoke(() => DisplayErrorMessage(exception));
Dispatcher.UIThread.Invoke(() => DisplayErrorMessage(exception, crashLogFile));
}
catch (Exception ex)
{
@@ -135,7 +147,7 @@ static class Program
}
}
private static void DisplayErrorMessage(Exception exception)
private static void DisplayErrorMessage(Exception exception, string? crashLogFile)
{
var dispatcher = new DispatcherFrame();
@@ -143,10 +155,10 @@ static class Program
exception,
new FatalStartupMessage(
"Libation Crash",
"""
$"""
Libation encountered a fatal error and must close.
Please consider reporting this issue on GitHub, including the contents of the LibationCrash.log file created in your user folder.
{DescribeCrashLog(crashLogFile)}
"""));
var mbAlert = new MessageBoxAlertAdminDialog(fatalMessage.Body, fatalMessage.Title, exception);
@@ -155,68 +167,17 @@ static class Program
Dispatcher.UIThread.PushFrame(dispatcher);
}
private static void LogErrorWithoutSerilog(object exceptionObject)
{
var logError = $"""
{DateTime.Now} - Libation Crash
OS {Configuration.OS}
Version {LibationScaffolding.BuildVersion}
ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier}
InteropFunctionsType {InteropFactory.InteropFunctionsType}
LibationFiles {getConfigValue(c => c.LibationFiles.Location)}
Books Folder {getConfigValue(c => c.Books)}
=== EXCEPTION ===
{exceptionObject}
""";
/// <summary>
/// Names the file the crash was actually written to. This used to name LibationCrash.log
/// unconditionally, which is not where the record goes when a Log*.log already exists, so reporters
/// went looking for a file that was not there and attached nothing. See issue #2001.
/// </summary>
private static string DescribeCrashLog(string? crashLogFile)
=> crashLogFile is null
? "Please consider reporting this issue on GitHub. Libation could not write this error to a log file, so please include the text below."
: $"""
Please consider reporting this issue on GitHub, including the contents of this file:
{crashLogFile}
""";
LongPath logFile;
try
{
//Try to add crash message to the newest existing Libation log file
//then to LibationFiles/LibationCrash.log
//then to %UserProfile%/LibationCrash.log
string logDir = Configuration.Instance.LibationFiles.Location;
var existingLogFiles = Directory.GetFiles(logDir, "Log*.log");
logFile = existingLogFiles.Length == 0 ? getFallbackLogFile()
: existingLogFiles.Select(f => new FileInfo(f)).OrderByDescending(f => f.CreationTimeUtc).First().FullName;
}
catch
{
logFile = getFallbackLogFile();
}
using var sw = new StreamWriter(logFile, true);
sw.WriteLine(logError);
static string getConfigValue(Func<Configuration, string?> selector)
{
try
{
return selector(Configuration.Instance) ?? "[null]";
}
catch (Exception ex)
{
return ex.ToString();
}
}
static string getFallbackLogFile()
{
try
{
string logDir = Configuration.Instance.LibationFiles.Location;
if (!Directory.Exists(logDir))
logDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return Path.Combine(logDir, "LibationCrash.log");
}
catch
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "LibationCrash.log");
}
}
}
}
@@ -2,6 +2,7 @@
using Avalonia.Threading;
using DataLayer;
using LibationFileManager;
using LibationUiBase;
using ReactiveUI;
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -17,6 +18,43 @@ partial class MainVM
/// <summary> The "Begin PDF Only Backup" menu item header text </summary>
public string PdfBackupsToolStripText { get; private set; } = "Begin PDF Only Backups: 0";
/// <summary> How many books are in the trash, where nothing else on screen would reveal them </summary>
public int BooksInTrash
{
get => field;
private set
{
this.RaiseAndSetIfChanged(ref field, value);
this.RaisePropertyChanged(nameof(TrashBinMenuText));
this.RaisePropertyChanged(nameof(TrashBinStatusText));
this.RaisePropertyChanged(nameof(TrashBinStatusVisible));
RaiseGettingStartedChanged();
}
}
public string TrashBinMenuText => TrashBinUi.MenuText(BooksInTrash);
public string TrashBinStatusText => TrashBinUi.StatusText(BooksInTrash);
public bool TrashBinStatusVisible => TrashBinUi.ShowStatus(BooksInTrash);
public string TrashBinStatusToolTip => TrashBinUi.StatusToolTip;
/// <summary>
/// Re-read the trash count. Kept off <see cref="LibraryCommands.GetCounts"/> on purpose: that also runs
/// against the visible subset on every filter change, where a database round trip would be wasted work.
/// </summary>
public async Task RefreshBooksInTrashAsync()
{
try
{
var count = await Task.Run(DbContexts.GetTrashedBookCount);
await Dispatcher.UIThread.InvokeAsync(() => BooksInTrash = count);
}
catch (System.Exception ex)
{
//A stale count must not take down the window that displays it.
Serilog.Log.Logger.Error(ex, "Failed to count books in the trash");
}
}
/// <summary> The user's library statistics </summary>
public LibraryCommands.LibraryStats? LibraryStats
{
@@ -37,6 +75,9 @@ partial class MainVM
this.RaisePropertyChanged(nameof(BookBackupsToolStripText));
this.RaisePropertyChanged(nameof(PdfBackupsToolStripText));
//Whether the library is empty is only known once these counts have been taken.
RaiseGettingStartedChanged();
}
}
@@ -1,3 +1,4 @@
using ApplicationServices;
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
@@ -19,7 +20,17 @@ partial class MainVM
private QuickFilters.NamedFilter? lastGoodFilter => new(lastGoodSearch, null);
/// <summary> Library filterting query </summary>
public QuickFilters.NamedFilter? SelectedNamedFilter { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = new(string.Empty, null);
public QuickFilters.NamedFilter? SelectedNamedFilter
{
get => field;
set
{
this.RaiseAndSetIfChanged(ref field, value);
//Which explanation the empty grid carries depends on whether a filter is applied.
this.RaisePropertyChanged(nameof(HasActiveFilter));
RaiseGettingStartedChanged();
}
} = new(string.Empty, null);
public AvaloniaList<Control> QuickFilterMenuItems { get; } = new();
/// <summary> Indicates if the first quick filter is the default filter </summary>
public bool FirstFilterIsDefault { get => field; set => QuickFilters.UseDefault = this.RaiseAndSetIfChanged(ref field, value); }
@@ -88,6 +99,7 @@ partial class MainVM
{
await ProductsDisplay.Filter(namedFilter?.Filter);
lastGoodSearch = namedFilter?.Filter ?? "";
await RefreshNoMatchesStateAsync(namedFilter?.Filter);
return null;
}
catch (Exception ex)
@@ -96,6 +108,88 @@ partial class MainVM
}
}
/// <summary>
/// A trashed book is filtered out of the library and out of the search index, so searching for one looks
/// exactly like searching for a book that was never imported. When a filter matches nothing, say whether
/// the thing being looked for is sitting in the trash.
/// </summary>
private async Task RefreshNoMatchesStateAsync(string? searchString)
{
NoMatchesText = GridEmptyStateUi.NoMatchesText(searchString);
//Search the trash whenever a filter emptied the grid, including when the library itself is empty:
//someone who trashed everything and then searched for one of those books is exactly who needs telling.
if (_visibleCount > 0 || string.IsNullOrWhiteSpace(searchString))
{
NoMatchesTrashHintVisible = false;
return;
}
var matches = await Task.Run(() => TrashBinSearch.Search(searchString));
NoMatchesTrashHintText = GridEmptyStateUi.NoMatchesTrashHintText(matches.Count);
NoMatchesTrashHintVisible = matches.Count > 0;
}
/// <summary> Shown over the grid when a filter matches nothing </summary>
public string NoMatchesText { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); } = "";
/// <summary> Follow-up naming matches that are in the trash </summary>
public string NoMatchesTrashHintText { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); } = "";
public bool NoMatchesTrashHintVisible { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); }
/// <summary> The grid is empty because of a filter, not because the library is empty </summary>
public bool NoMatchesVisible { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); }
#region Getting started
/// <summary>A filter is applied, so an empty grid is the filter's doing rather than the library's.</summary>
public bool HasActiveFilter => !string.IsNullOrWhiteSpace(SelectedNamedFilter?.Filter);
/// <summary>
/// The grid is empty because there is nothing in the library at all. Held back until
/// <see cref="LibraryStats"/> has been counted at least once, which is what keeps "Add your Audible
/// account" off the screen during the moment between the window appearing and a full library loading.
/// Also held back mid-scan, where "no books yet" would be answering a question already being answered,
/// and while a filter is applied, because someone searching is not someone getting started.
/// </summary>
public bool GettingStartedVisible
=> !ActivelyScanning
&& !HasActiveFilter
&& LibraryStats is { HasBookResults: false };
public string GettingStartedHeadline => GridEmptyStateUi.EmptyLibraryHeadline(AnyAccounts);
public string GettingStartedDetail => GridEmptyStateUi.EmptyLibraryDetail(AnyAccounts);
/// <summary>Adding an account comes first; telling someone without one to scan is a dead end.</summary>
public bool GettingStartedAddAccountVisible => !AnyAccounts;
public bool GettingStartedScanVisible => AnyAccounts;
/// <summary>
/// An empty library whose books are all in the trash. "Libation is empty" would be wrong on its own:
/// the books are not gone, they are one click away.
/// </summary>
public bool GettingStartedTrashVisible => BooksInTrash > 0;
public string GettingStartedTrashText => GridEmptyStateUi.EmptyLibraryTrashHintText(BooksInTrash);
internal void RaiseGettingStartedChanged()
{
this.RaisePropertyChanged(nameof(GettingStartedVisible));
this.RaisePropertyChanged(nameof(GettingStartedHeadline));
this.RaisePropertyChanged(nameof(GettingStartedDetail));
this.RaisePropertyChanged(nameof(GettingStartedAddAccountVisible));
this.RaisePropertyChanged(nameof(GettingStartedScanVisible));
this.RaisePropertyChanged(nameof(GettingStartedTrashVisible));
this.RaisePropertyChanged(nameof(GettingStartedTrashText));
updateNoMatchesVisible();
}
/// <summary>
/// Scan whatever accounts exist. The button only shows when there is at least one, and an empty library
/// is a case where every account is worth scanning, so this does not need the menu's one/many split.
/// </summary>
public async Task GettingStartedScanAsync() => await ScanAllAccountsAsync();
#endregion
private void updateFiltersMenu(object? _ = null, object? __ = null)
{
if (NativeMenu.GetMenu(MainWindow)?.Items[3] is not NativeMenuItem ss ||
@@ -56,6 +56,7 @@ public partial class MainVM
this.RaisePropertyChanged(nameof(AnyAccounts));
this.RaisePropertyChanged(nameof(OneAccount));
this.RaisePropertyChanged(nameof(MultipleAccounts));
RaiseGettingStartedChanged();
}
}
@@ -191,6 +192,7 @@ public partial class MainVM
this.RaisePropertyChanged(nameof(ActivelyScanning));
this.RaisePropertyChanged(nameof(RemoveMenuItemsEnabled));
this.RaisePropertyChanged(nameof(ScanningText));
RaiseGettingStartedChanged();
}
private async Task scanLibrariesRemovedBooks(params Account[] accounts)
@@ -3,8 +3,8 @@ using LibationFileManager;
using LibationUiBase.Forms;
using ReactiveUI;
using System;
using System.Reactive;
using System.Threading.Tasks;
using System.Windows.Input;
namespace LibationAvalonia.ViewModels;
@@ -12,7 +12,7 @@ partial class MainVM
{
public string FindBetterQualityBooksTip => Configuration.GetHelpText("FindBetterQualityBooks");
public bool MenuBarVisible { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = !Configuration.IsMacOs;
public ReactiveCommand<Unit, Unit> LaunchHangover { get; private set; } = null!;
public ICommand LaunchHangover { get; private set; } = null!;
private void Configure_Settings()
{
@@ -20,13 +20,18 @@ partial class MainVM
if (App.Current is Avalonia.Application app &&
NativeMenu.GetMenu(app)?.Items[0] is NativeMenuItem aboutMenu)
aboutMenu.Command = ReactiveCommand.Create(ShowAboutAsync);
aboutMenu.Command = ReactiveCommand.CreateFromTask(ShowAboutAsync);
}
public Task ShowAboutAsync() => new LibationAvalonia.Dialogs.AboutDialog().ShowDialog(MainWindow);
public Task ShowAccountsAsync() => new LibationAvalonia.Dialogs.AccountsDialog().ShowDialog(MainWindow);
public Task ShowSettingsAsync() => new LibationAvalonia.Dialogs.SettingsDialog().ShowDialog(MainWindow);
public Task ShowTrashBinAsync() => new LibationAvalonia.Dialogs.TrashBinDialog().ShowDialog(MainWindow);
public async Task ShowTrashBinAsync()
{
await new LibationAvalonia.Dialogs.TrashBinDialog().ShowDialog(MainWindow);
//Restoring or permanently deleting inside the dialog changes the count behind it.
await RefreshBooksInTrashAsync();
}
public Task ShowFindBetterQualityBooksAsync() => new LibationAvalonia.Dialogs.FindBetterQualityBooksDialog().ShowDialog(MainWindow);
private async Task LaunchHangoverAsync()
@@ -37,8 +37,16 @@ partial class MainVM
_visibleCount = visibleCount;
this.RaisePropertyChanged(nameof(VisibleCountText));
this.RaisePropertyChanged(nameof(VisibleCountMenuItemText));
updateNoMatchesVisible();
}
/// <summary>
/// An empty grid only needs explaining when a filter emptied it. Mutually exclusive with the
/// getting-started panel, which stands down while a filter is applied.
/// </summary>
private void updateNoMatchesVisible()
=> NoMatchesVisible = _visibleCount == 0 && HasActiveFilter;
private void setVisibleNotLiberatedCount(int visibleNotLiberated)
{
_visibleNotLiberated = visibleNotLiberated;
@@ -191,8 +199,19 @@ partial class MainVM
private async Task setLiberatedVisibleMenuItemAsync()
{
var visible = ProductsDisplay.GetVisibleBookEntries();
var libraryStats = await Task.Run(() => LibraryCommands.GetCounts(visible));
await Dispatcher.UIThread.InvokeAsync(() => setVisibleNotLiberatedCount(libraryStats.PendingBooks));
try
{
var visible = ProductsDisplay.GetVisibleBookEntries();
var libraryStats = await Task.Run(() => LibraryCommands.GetCounts(visible));
await Dispatcher.UIThread.InvokeAsync(() => setVisibleNotLiberatedCount(libraryStats.PendingBooks));
}
// Every caller is an async void event handler, where a failure is not a faulted task anyone awaits but
// an unhandled exception that closes Libation. Counting these books reads the file system, which can
// fail at any moment for reasons that have nothing to do with the app - a Books folder on a drive that
// was just unplugged is enough. A stale number above a menu item is not worth the session.
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error counting the visible books which are not yet liberated");
}
}
}
@@ -52,6 +52,7 @@ public partial class MainVM : ViewModelBase
await Task.WhenAll(
SetBackupCountsAsync(fullLibrary),
RefreshBooksInTrashAsync(),
Task.Run(() => ProductsDisplay.UpdateGridAsync(fullLibrary)));
}
catch (System.Exception ex)
@@ -1,3 +1,4 @@
using AudibleApi;
using AudibleApi.Authorization;
using AudibleUtilities;
using Dinah.Core;
@@ -35,6 +36,8 @@ public class ImportantSettingsVM : ViewModelBase
CreationTime = DateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? DateTimeSources[0];
LastWriteTime = DateTimeSources.SingleOrDefault(v => v.Value == config.LastWriteTime) ?? DateTimeSources[0];
UseWebView = config.UseWebView;
SelectedDeviceRegistration = DeviceRegistrationSettingsUi.Display(config.DeviceRegistrationKind);
CheckForUpgradesAtStartup = config.CheckForUpgradesAtStartup;
LoggingLevel = config.LogLevel;
GridScaleFactor = scaleFactorToLinearRange(config.GridScaleFactor);
GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor);
@@ -77,6 +80,8 @@ public class ImportantSettingsVM : ViewModelBase
config.CreationTime = CreationTime.Value;
config.LastWriteTime = LastWriteTime.Value;
config.UseWebView = UseWebView;
config.DeviceRegistrationKind = SelectedDeviceRegistration.Value;
config.CheckForUpgradesAtStartup = CheckForUpgradesAtStartup;
config.LogLevel = LoggingLevel;
config.TokenStorageMethod = SelectedTokenStorageMethod;
initialTokenStorageMethod = SelectedTokenStorageMethod;
@@ -142,13 +147,18 @@ public class ImportantSettingsVM : ViewModelBase
.ToArray();
public string UseWebViewText { get; } = Configuration.GetDescription(nameof(Configuration.UseWebView));
public string DeviceRegistrationKindText { get; } = DeviceRegistrationSettingsUi.SettingLabel;
public string DeviceRegistrationKindTip { get; } = Configuration.GetHelpText(nameof(Configuration.DeviceRegistrationKind));
public string DeviceRegistrationReLoginNote { get; } = DeviceRegistrationSettingsUi.ReLoginNote;
public EnumDisplay<DeviceRegistrationKind>[] DeviceRegistrationOptions { get; } = DeviceRegistrationSettingsUi.Options;
/// <summary>When true, the Use WebView setting is disabled (e.g. when running in Linux Snap to avoid portal/sandbox crashes).</summary>
public bool UseWebViewSettingDisabled => Configuration.IsRunningUnderSnap;
public string UseWebViewSnapMessage { get; } = Configuration.IsRunningUnderSnap ? "Disabled when running in Linux Snap (avoids login crash). Use external browser instead." : "";
public string CheckForUpgradesAtStartupText { get; } = Configuration.GetDescription(nameof(Configuration.CheckForUpgradesAtStartup));
public string CheckForUpgradesAtStartupTip { get; } = Configuration.GetHelpText(nameof(Configuration.CheckForUpgradesAtStartup));
public Serilog.Events.LogEventLevel[] LoggingLevels { get; } = Enum.GetValues<Serilog.Events.LogEventLevel>();
public string GridScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridScaleFactor));
public string GridFontScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridFontScaleFactor));
public string BetaOptInText { get; } = Configuration.GetDescription(nameof(Configuration.BetaOptIn));
public EnumDisplay<Configuration.Theme>[] Themes { get; }
= Enum.GetValues<Configuration.Theme>()
.Select(v => new EnumDisplay<Configuration.Theme>(v))
@@ -171,6 +181,8 @@ public class ImportantSettingsVM : ViewModelBase
public EnumDisplay<Configuration.DateTimeSource> CreationTime { get; set; }
public EnumDisplay<Configuration.DateTimeSource> LastWriteTime { get; set; }
public bool UseWebView { get; set; }
public EnumDisplay<DeviceRegistrationKind> SelectedDeviceRegistration { get; set; }
public bool CheckForUpgradesAtStartup { get; set; }
public Serilog.Events.LogEventLevel LoggingLevel { get; set; }
public bool EncryptTokens
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff. Show more